package se.urmo.game.entities; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import se.urmo.game.collision.GhostCollisionChecker; import se.urmo.game.graphics.SpriteLocation; import se.urmo.game.graphics.SpriteSheetManager; import se.urmo.game.main.Game; import se.urmo.game.map.GameMap; import se.urmo.game.state.GhostManager; import se.urmo.game.util.Direction; import se.urmo.game.util.MiscUtil; import java.awt.Color; import java.awt.Graphics; import java.awt.Point; import java.awt.image.BufferedImage; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Slf4j public class Ghost { private static final int WARNING_THRESHOLD = 180; // 3 seconds of warning private static final int COLLISION_BOX_SIZE = 16; private static final int GHOST_MOVEMENT_UPDATE_FREQUENCY = 2; public static final int GHOST_SIZE = 32; private static final int ANIMATION_UPDATE_FREQUENCY = 25; private static final int COLLISION_BOX_OFFSET = COLLISION_BOX_SIZE / 2; private static final BufferedImage COLLISION_BOX = MiscUtil.createOutlinedBox(COLLISION_BOX_SIZE, COLLISION_BOX_SIZE, Color.black, 2); private static final int FRIGHTENED_DURATION_TICKS = 10 * Game.UPS_SET; private final GhostCollisionChecker collisionChecker; private final GhostStrategy chaseStrategy; private final Point startPos; private final BufferedImage[] fearAnimation; private final BufferedImage[] baseAnimation; @Getter private Point position; private boolean moving = true; private int aniTick = 0; private int aniIndex = 0; private final GhostStrategy scaterStrategy; private GhostStrategy currentStrategy; private BufferedImage[] animation; private int movementTick = 0; private Direction direction; private Direction prevDirection; private GhostMode mode; private final GhostStrategy fearStrategy = new FearStrategy(); private int frightenedTimer = 0; private boolean isBlinking = false; public Ghost(GhostCollisionChecker collisionChecker, GhostStrategy strategy, GhostStrategy scaterStrategy, int animation) { this.collisionChecker = collisionChecker; this.chaseStrategy = strategy; this.scaterStrategy = scaterStrategy; this.baseAnimation = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(animation);; this.fearAnimation = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(8);; position = new Point( 13 * GameMap.MAP_TILESIZE + GameMap.OFFSET_X + (GameMap.MAP_TILESIZE / 2), 4 * GameMap.MAP_TILESIZE + GameMap.OFFSET_Y + (GameMap.MAP_TILESIZE / 2) ); startPos = position; this.currentStrategy = chaseStrategy; this.animation = baseAnimation; } public void draw(Graphics g) { g.drawImage( animation[aniIndex], position.x - GHOST_SIZE / 2, position.y - GHOST_SIZE / 2, GHOST_SIZE, GHOST_SIZE, null); g.drawImage(COLLISION_BOX, position.x - COLLISION_BOX_OFFSET, position.y - COLLISION_BOX_OFFSET, COLLISION_BOX_SIZE, COLLISION_BOX_SIZE, null); } public void update(PacMan pacman, GameMap map) { updateAnimationTick(); if(mode == GhostMode.FRIGHTENED){ updateInFrightendMode(); } updatePosition(pacman, map); } private void updateInFrightendMode() { frightenedTimer--; if(frightenedTimer <= WARNING_THRESHOLD){ isBlinking = (frightenedTimer / 25) % 2 == 0; animation = isBlinking ? fearAnimation : baseAnimation; } if(frightenedTimer <= 0){ setMode(GhostMode.CHASE); } } private void updatePosition(PacMan pacman, GameMap map) { // only move ghost on update interval - this is basically ghost speed; if (movementTick >= GHOST_MOVEMENT_UPDATE_FREQUENCY) { if (map.isAligned(position)) { //log.info("Evaluating possible directions"); prevDirection = direction; direction = chooseDirection( prioritize(collisionChecker.calculateDirectionAlternatives(position)), currentStrategy.chooseTarget(this, pacman, map)); //log.info("selecting direction {}", direction); } moveTo(getNewPosition()); movementTick = 0; } else movementTick++; } /** * Given a position and a direction - calculate the new position * Moves one pixel in the given direction * * @return new position */ private Point getNewPosition() { Point point = new Point( position.x + direction.dx, position.y + direction.dy); //log.debug("Next position {}", point); return point; } private void moveTo(Point newPosition) { Point destination = collisionChecker.canMoveTo(direction, newPosition); if (destination != null) { position = destination; } } private Map prioritize(List directions) { return directions.stream() .filter(d -> d != Direction.NONE) .collect(Collectors.toMap( d -> d, d -> (prevDirection != null && d == prevDirection.opposite()) ? 2 : 1 )); } private Direction chooseDirection(Map options, Point target) { // Find the lowest priority int lowestPriority = options.values().stream() .mapToInt(Integer::intValue) .min() .orElse(Integer.MAX_VALUE); // Return all directions that have this priority List directions = options.entrySet().stream() .filter(entry -> entry.getValue() == lowestPriority) .map(Map.Entry::getKey) .toList(); Direction best = directions.getFirst(); double bestDist = Double.MAX_VALUE; for (Direction d : directions) { int nx = position.x + d.dx * GameMap.MAP_TILESIZE; int ny = position.y + d.dy * GameMap.MAP_TILESIZE; double dist = target.distance(nx, ny); if (dist < bestDist) { bestDist = dist; best = d; } } return best; } private void updateAnimationTick() { if (moving) { aniTick++; if (aniTick >= ANIMATION_UPDATE_FREQUENCY) { aniTick = 0; aniIndex++; if (aniIndex >= GhostManager.MAX_SPRITE_FRAMES) { aniIndex = 0; } } } } public void setMode(GhostMode mode) { this.mode = mode; switch (mode) { case CHASE -> { animation = baseAnimation; currentStrategy = chaseStrategy; } case SCATTER -> currentStrategy = scaterStrategy; case FRIGHTENED -> { frightenedTimer = FRIGHTENED_DURATION_TICKS; isBlinking = false; animation = fearAnimation; currentStrategy = fearStrategy; } case EATEN -> currentStrategy = null; } } public boolean isFrightened() { return mode == GhostMode.FRIGHTENED; } public void resetPosition() { position = startPos; } }