feat: Mobs following player logic

This commit is contained in:
2026-01-01 16:32:48 +01:00
parent 6373c7694f
commit 3071651ab6
14 changed files with 378 additions and 27 deletions

View File

@@ -6,6 +6,6 @@ import lombok.Getter;
@Getter
@Config
public class Debugging {
private final boolean renderColliders = false;
private final boolean renderPlayerCollider = false;
private final boolean renderColliders = true;
private final boolean renderPlayerCollider = true;
}

View File

@@ -28,31 +28,28 @@ import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@EventHandler(FullRoomDraw.class)
public class FullRoomDrawHandler extends AbstractEventHandler<FullRoomDraw> {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@InjectState
private GameState gameState;
@InjectState
private ScreenBuffer screenBuffer;
@InjectDependency
private ResourceManager resourceManager;
@InjectState
private TerminalState terminalState;
@InjectDependency
private EventManager eventManager;
@InjectState
private RenderState renderState;
@InjectConfig
private Debugging debugging;
@InjectDependency
private RoomTaskScheduler roomTaskScheduler;
@@ -90,7 +87,9 @@ public class FullRoomDrawHandler extends AbstractEventHandler<FullRoomDraw> {
if (renderState.isFirstRender() || event.isFullRerender()) {
eventManager.emitEvent(RerenderScreen.full(terminalSize));
renderState.setFirstRender(false);
roomTaskScheduler.setupNewSchedulers(currentRoom);
scheduler.schedule(() -> {
roomTaskScheduler.setupNewSchedulers(currentRoom);
}, 200, TimeUnit.MILLISECONDS);
} else {
eventManager.emitEvent(new RerenderScreen(partsToRerender.toArray(RerenderScreen.ScreenPart[]::new)));
}

View File

@@ -14,22 +14,25 @@ import cz.jzitnik.utils.events.EventManager;
import cz.jzitnik.utils.roomtasks.RoomTaskScheduler;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@EventHandler(RoomChangeEvent.class)
public class RoomChangeEventHandler extends AbstractEventHandler<RoomChangeEvent> {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@InjectState
private GameState gameState;
@InjectDependency
private EventManager eventManager;
@InjectDependency
private RoomTaskScheduler roomTaskScheduler;
public RoomChangeEventHandler(DependencyManager dm) {
super(dm);
}
@InjectState
private GameState gameState;
@InjectDependency
private EventManager eventManager;
@InjectDependency
private RoomTaskScheduler roomTaskScheduler;
@Override
public void handle(RoomChangeEvent event) {
RoomCords playerCords = gameState.getPlayer().getPlayerCords();
@@ -53,7 +56,9 @@ public class RoomChangeEventHandler extends AbstractEventHandler<RoomChangeEvent
}
gameState.setCurrentRoom(newRoom);
roomTaskScheduler.setupNewSchedulers(newRoom);
scheduler.schedule(() -> {
roomTaskScheduler.setupNewSchedulers(newRoom);
}, 200, TimeUnit.MILLISECONDS);
eventManager.emitEvent(new FullRoomDraw());
}
}

View File

@@ -18,6 +18,7 @@ import cz.jzitnik.utils.DependencyManager;
import cz.jzitnik.utils.RerenderUtils;
import cz.jzitnik.utils.events.EventManager;
import cz.jzitnik.utils.roomtasks.RoomTask;
import cz.jzitnik.utils.roomtasks.RoomTaskScheduler;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -49,6 +50,8 @@ public abstract class HittableMob extends Mob {
private ScreenBuffer screenBuffer;
@InjectConfig
private Debugging debugging;
@InjectDependency
private RoomTaskScheduler roomTaskScheduler;
public HittableMob(BufferedImage texture, RoomTask task, RoomCords cords, int initialHealth) {
super(texture, task, cords);
@@ -66,6 +69,9 @@ public abstract class HittableMob extends Mob {
if (health <= 0) {
onKilled();
if (task != null) {
roomTaskScheduler.stopTask(task);
}
gameState.getCurrentRoom().getMobs().remove(this);
rerender();
return;

View File

@@ -5,18 +5,24 @@ import cz.jzitnik.game.utils.RoomCords;
import cz.jzitnik.game.utils.Selectable;
import cz.jzitnik.utils.roomtasks.RoomTask;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import java.awt.image.BufferedImage;
@Getter
@RequiredArgsConstructor
public abstract class Mob implements Renderable, Selectable {
protected final BufferedImage texture;
private final RoomTask task;
@Setter
protected RoomTask task;
protected final RoomCords cords;
public Mob(BufferedImage texture, RoomTask task, RoomCords cords) {
this.texture = texture;
this.task = task;
this.cords = cords;
}
@Setter
private boolean selected = false;
}

View File

@@ -0,0 +1,133 @@
package cz.jzitnik.game.mobs.tasks;
import cz.jzitnik.game.GameRoomPart;
import cz.jzitnik.game.utils.RoomCords;
import java.util.*;
public class AStarAlg {
// Boundaries matching your Player switch statement
// Player: if (x <= 30) return -> means 30 is the edge, valid area is > 30?
// User Update: "he can be on 30". So valid range is [30, 155]
private static final int MIN_X = 30;
private static final int MAX_X = 155;
private static final int MIN_Y = 10;
private static final int MAX_Y = 113;
public static List<RoomCords> findPath(RoomCords start, RoomCords target, List<GameRoomPart> colliders) {
PriorityQueue<Node> openSet = new PriorityQueue<>(Comparator.comparingInt(n -> n.f));
Set<String> closedSet = new HashSet<>();
// We use Chebyshev distance for the heuristic (best for 8-way movement)
Node startNode = new Node(start.getX(), start.getY(), 0, getHeuristic(start, target), null);
openSet.add(startNode);
while (!openSet.isEmpty()) {
Node current = openSet.poll();
// Reached target?
if (current.x == target.getX() && current.y == target.getY()) {
return reconstructPath(current);
}
String key = current.x + "," + current.y;
if (closedSet.contains(key)) continue;
closedSet.add(key);
for (Node neighbor : getNeighbors(current, target)) {
String neighborKey = neighbor.x + "," + neighbor.y;
if (closedSet.contains(neighborKey)) continue;
// Check collisions and boundaries
if (!isValidPosition(neighbor.x, neighbor.y, colliders)) {
// EDGE CASE FIX:
// If the Player (target) is standing exactly on a wall/edge that the Mob considers invalid,
// A* will usually fail.
// We add a check: if this neighbor IS the target, we allow it.
// This lets the mob walk right up to the player's face even if they are hugging the wall.
if (neighbor.x != target.getX() || neighbor.y != target.getY()) {
continue;
}
}
int newG = current.g + 1;
neighbor.g = newG;
neighbor.f = newG + neighbor.h;
neighbor.parent = current;
openSet.add(neighbor);
}
}
return new ArrayList<>();
}
private static List<Node> getNeighbors(Node current, RoomCords target) {
List<Node> neighbors = new ArrayList<>();
// Added Diagonal Directions
int[][] directions = {
{0, 1}, {0, -1}, {1, 0}, {-1, 0}, // Up, Down, Right, Left
{1, 1}, {1, -1}, {-1, 1}, {-1, -1} // Diagonals
};
for (int[] dir : directions) {
int newX = current.x + dir[0];
int newY = current.y + dir[1];
int h = getHeuristic(new RoomCords(newX, newY), target);
neighbors.add(new Node(newX, newY, 0, h, null));
}
return neighbors;
}
private static boolean isValidPosition(int x, int y, List<GameRoomPart> colliders) {
// Updated checks to be inclusive so 30 is valid.
// Valid X: 30 to 155
if (x < MIN_X || x > MAX_X) return false;
// Valid Y: 10 to 110
if (y < MIN_Y || y > MAX_Y) return false;
// Check Colliders
RoomCords temp = new RoomCords(x, y);
for (GameRoomPart part : colliders) {
if (part.isWithin(temp)) {
return false;
}
}
return true;
}
private static List<RoomCords> reconstructPath(Node endNode) {
List<RoomCords> path = new ArrayList<>();
Node current = endNode;
while (current != null) {
path.add(new RoomCords(current.x, current.y));
current = current.parent;
}
Collections.reverse(path);
return path;
}
// Changed to Chebyshev Distance for better diagonal estimation
private static int getHeuristic(RoomCords a, RoomCords b) {
int dx = Math.abs(a.getX() - b.getX());
int dy = Math.abs(a.getY() - b.getY());
return Math.max(dx, dy);
}
private static class Node {
int x, y;
int g, h, f;
Node parent;
public Node(int x, int y, int g, int h, Node parent) {
this.x = x;
this.y = y;
this.g = g;
this.h = h;
this.f = g + h;
this.parent = parent;
}
}
}

View File

@@ -0,0 +1,60 @@
package cz.jzitnik.game.mobs.tasks;
import cz.jzitnik.annotations.injectors.InjectConfig;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.config.Debugging;
import cz.jzitnik.game.GameState;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.game.mobs.Mob;
import cz.jzitnik.game.utils.RoomCords;
import cz.jzitnik.states.ScreenBuffer;
import cz.jzitnik.states.SoundState;
import cz.jzitnik.states.TerminalState;
import cz.jzitnik.utils.events.EventManager;
import cz.jzitnik.utils.roomtasks.RoomTask;
import lombok.RequiredArgsConstructor;
import java.util.concurrent.TimeUnit;
public class BlindMobFollowingPlayerTask extends RoomTask {
public BlindMobFollowingPlayerTask(Mob mob, int speed, int updateRate) {
super(new Task(mob, speed), updateRate, TimeUnit.MILLISECONDS);
}
@RequiredArgsConstructor
private static class Task implements Runnable {
private final Mob mob;
private final int speed;
private RoomCords playerCords;
@InjectState
private GameState gameState;
@InjectDependency
private EventManager eventManager;
@InjectDependency
private ResourceManager resourceManager;
@InjectState
private TerminalState terminalState;
@InjectState
private ScreenBuffer screenBuffer;
@InjectConfig
private Debugging debugging;
@InjectState
private SoundState soundState;
@Override
public void run() {
if (playerCords == null || (soundState.isMicrophoneSetup() && soundState.getSoundVolume() > 2f)) {
playerCords = gameState.getPlayer().getPlayerCords().clone();
}
MobFollowingPlayerTask.Task.moveMob(playerCords, mob, gameState, speed, resourceManager, terminalState, screenBuffer, debugging, eventManager);
}
}
}

View File

@@ -0,0 +1,99 @@
package cz.jzitnik.game.mobs.tasks;
import com.googlecode.lanterna.TerminalPosition;
import cz.jzitnik.annotations.injectors.InjectConfig;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.config.Debugging;
import cz.jzitnik.events.RerenderScreen;
import cz.jzitnik.game.GameRoomPart;
import cz.jzitnik.game.GameState;
import cz.jzitnik.game.Player;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.game.mobs.Mob;
import cz.jzitnik.game.utils.RoomCords;
import cz.jzitnik.states.ScreenBuffer;
import cz.jzitnik.states.TerminalState;
import cz.jzitnik.utils.RerenderUtils;
import cz.jzitnik.utils.events.EventManager;
import cz.jzitnik.utils.roomtasks.RoomTask;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
public class MobFollowingPlayerTask extends RoomTask {
public MobFollowingPlayerTask(Mob mob, int speed, int updateRate) {
super(new Task(mob, speed), updateRate, TimeUnit.MILLISECONDS);
}
@RequiredArgsConstructor
static class Task implements Runnable {
private final Mob mob;
private final int speed;
@InjectState
private GameState gameState;
@InjectDependency
private EventManager eventManager;
@InjectDependency
private ResourceManager resourceManager;
@InjectState
private TerminalState terminalState;
@InjectState
private ScreenBuffer screenBuffer;
@InjectConfig
private Debugging debugging;
protected static void moveMob(RoomCords playerCords, Mob mob, GameState gameState, int speed, ResourceManager resourceManager, TerminalState terminalState, ScreenBuffer screenBuffer, Debugging debugging, EventManager eventManager) {
RoomCords mobCords = mob.getCords();
List<GameRoomPart> solidParts = gameState.getCurrentRoom().getColliders();
List<RoomCords> path = AStarAlg.findPath(mobCords, playerCords, solidParts);
if (path.size() > 1) {
int targetIndex = Math.min(speed, path.size() - 1);
RoomCords newCords = path.get(targetIndex);
mob.getCords().updateCords(newCords.getX(), newCords.getY());
int forStartX = Math.min(mobCords.getX(), newCords.getX());
int forStartY = Math.min(mobCords.getY(), newCords.getY());
int forEndX = Math.max(mobCords.getX(), newCords.getX()) + mob.getTexture().getWidth() + 1;
int forEndY = Math.max(mobCords.getY(), newCords.getY()) + mob.getTexture().getHeight() + 1;
BufferedImage room = resourceManager.getResource(gameState.getCurrentRoom().getTexture());
var start = RerenderUtils.getStart(room, terminalState.getTerminalScreen().getTerminalSize());
int startX = start.getX();
int startY = start.getY();
Player player = gameState.getPlayer();
BufferedImage playerTexture = RerenderUtils.getPlayer(resourceManager, player);
RerenderUtils.rerenderPart(forStartX, forEndX, forStartY, forEndY, startX, startY, gameState.getCurrentRoom(), room, player, playerTexture, screenBuffer, resourceManager, debugging);
eventManager.emitEvent(new RerenderScreen(
new RerenderScreen.ScreenPart(
new TerminalPosition(forStartX + startX, forStartY + startY),
new TerminalPosition(forEndX + 1 + startX, forEndY + startY)
)
));
} else {
log.debug("Mob is effectively at the target or trapped.");
}
}
@Override
public void run() {
moveMob(gameState.getPlayer().getPlayerCords(), mob, gameState, speed, resourceManager, terminalState, screenBuffer, debugging, eventManager);
}
}
}

View File

@@ -2,6 +2,7 @@ package cz.jzitnik.game.setup.mobs;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.game.mobs.HittableMob;
import cz.jzitnik.game.mobs.tasks.MobFollowingPlayerTask;
import cz.jzitnik.game.utils.RoomCords;
import lombok.extern.slf4j.Slf4j;
@@ -9,7 +10,9 @@ import lombok.extern.slf4j.Slf4j;
public class Zombie extends HittableMob {
public Zombie(ResourceManager resourceManager, RoomCords cords) {
super(resourceManager.getResource(ResourceManager.Resource.CHEST), null, cords, 10);
super(resourceManager.getResource(ResourceManager.Resource.PLAYER_FRONT), null, cords, 10);
//setTask(new BlindMobFollowingPlayerTask(this, 1, 100));
setTask(new MobFollowingPlayerTask(this, 1, 100));
}
@Override

View File

@@ -10,7 +10,7 @@ import java.util.List;
@Slf4j
@ToString
@Getter
public class RoomCords {
public class RoomCords implements Cloneable {
private int x;
private int y;
@@ -29,4 +29,13 @@ public class RoomCords {
}
updateCords(x, y);
}
@Override
public RoomCords clone() {
try {
return (RoomCords) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}

View File

@@ -7,4 +7,5 @@ import lombok.Data;
@State
public class SoundState {
private double soundVolume;
private boolean microphoneSetup = false;
}

View File

@@ -41,6 +41,7 @@ public class MicrophoneThread extends ShutdownableThread {
if (bytesRead > 0) {
double volume = calculateRMS(buffer, bytesRead);
soundState.setSoundVolume(volume);
soundState.setMicrophoneSetup(true);
}
}
} catch (LineUnavailableException e) {

View File

@@ -1,6 +1,14 @@
package cz.jzitnik.utils.roomtasks;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.concurrent.TimeUnit;
public record RoomTask(Runnable task, long rate, TimeUnit rateUnit) {
@RequiredArgsConstructor
@Getter
public class RoomTask {
private final Runnable task;
private final long rate;
private final TimeUnit rateUnit;
}

View File

@@ -3,13 +3,17 @@ package cz.jzitnik.utils.roomtasks;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.PostInit;
import cz.jzitnik.annotations.injectors.InjectConfig;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.config.ThreadPoolConfig;
import cz.jzitnik.game.GameRoom;
import cz.jzitnik.game.mobs.Mob;
import cz.jzitnik.utils.DependencyManager;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@@ -18,6 +22,10 @@ public class RoomTaskScheduler {
ScheduledExecutorService scheduler;
@InjectConfig
private ThreadPoolConfig threadPoolConfig;
@InjectDependency
private DependencyManager dependencyManager;
private HashMap<RoomTask, ScheduledFuture<?>> tasks = new HashMap<>();
private boolean firstRun = true;
@@ -46,6 +54,16 @@ public class RoomTaskScheduler {
shutdownAll();
}
public void stopTask(RoomTask task) {
if (!tasks.containsKey(task)) {
return;
}
ScheduledFuture<?> future = tasks.get(task);
future.cancel(true);
tasks.remove(task);
}
public void setupNewSchedulers(GameRoom currentRoom) {
if (!firstRun) {
shutdownAll();
@@ -54,8 +72,11 @@ public class RoomTaskScheduler {
for (Mob mob : currentRoom.getMobs()) {
RoomTask task = mob.getTask();
if (task != null) {
scheduler.scheduleAtFixedRate(task.task(), 0, task.rate(), task.rateUnit());
dependencyManager.inject(task.getTask());
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(task.getTask(), 0, task.getRate(), task.getRateUnit());
tasks.put(task, future);
}
}