Added Scatter-strategy

This commit is contained in:
Urban Modig
2025-08-19 21:17:36 +02:00
parent 64dcba2584
commit 9f316e5b43
13 changed files with 183 additions and 80 deletions

View File

@ -2,6 +2,7 @@ package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.main.GamePanel;
import se.urmo.game.util.Direction;
import se.urmo.game.map.GameMap;
@ -44,7 +45,7 @@ public class CollisionChecker {
/**
* Applies specific rules to movement
* This for instance makes sure the tunnel left/right works.
* This, for instance, makes sure the tunnel left/right works.
*
* @param dir
* @param pos
@ -55,7 +56,7 @@ public class CollisionChecker {
public Point normalizePosition(Direction dir, Point pos, int agent_width, int agent_height) {
int x = pos.x;
int y = pos.y;
int width = map.getWidth();
int width = GamePanel.SCREEN_WIDTH;
int height = map.getHeight();
// tunnel

View File

@ -2,10 +2,12 @@ package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.map.GameMap;
import se.urmo.game.map.MapTile;
import se.urmo.game.util.Direction;
import java.awt.Point;
import java.util.List;
import java.util.stream.Stream;
@Slf4j
public class GhostCollisionChecker {
@ -16,15 +18,38 @@ public class GhostCollisionChecker {
}
public List<Direction> calculateDirectionAlternatives(Point position) {
List<Direction> intersection = map.directionAlternatives(position);
List<Direction> intersection = directionAlternatives(position);
log.info("Possible travel directions: {}", intersection);
return intersection;
}
public List<Direction> directionAlternatives(Point position) {
int row = (position.y - GameMap.OFFSET_Y) / GameMap.MAP_TILESIZE;
int col = (position.x - GameMap.OFFSET_X) / GameMap.MAP_TILESIZE;
record DirectionCheck(int rowOffset, int colOffset, Direction direction) {}
log.debug("At [{}][{}]", row, col);
return Stream.of(
new DirectionCheck(0, 1, Direction.RIGHT),
new DirectionCheck(0, -1, Direction.LEFT),
new DirectionCheck(1, 0, Direction.DOWN),
new DirectionCheck(-1, 0, Direction.UP)
)
.filter(dc -> {
int r = row + dc.rowOffset;
int c = col + dc.colOffset;
boolean solid = map.isSolid(r, c);
log.debug("[{}][{}] {} is {}", r, c, dc.direction, solid ? "solid" : " not solid");
return !solid;
})
.map(DirectionCheck::direction)
.toList();
}
public Point canMoveTo(Direction dir, Point pos) {
// -1 is because else we endup in next tile
Point pp = new Point(pos.x + dir.dx * (GameMap.MAP_TILESIZE/2 - 1), pos.y + dir.dy * (GameMap.MAP_TILESIZE/2 -1) );
return ! map.isSolid(pp.x, pp.y) ? pos : null;
return ! map.isSolid(pp) ? pos : null;
}
}

View File

@ -1,10 +1,12 @@
package se.urmo.game.entities;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public class BlinkyStrategy implements GhostStrategy {
@Override
public Point chooseTarget(PacMan pacman) {
public Point chooseTarget(PacMan pacman, GameMap map) {
return pacman.getTilePosition();
}
}

View File

@ -11,7 +11,11 @@ import java.awt.Color;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
public class Ghost {
@ -30,22 +34,25 @@ public class Ghost {
private boolean moving = true;
private int aniTick = 0;
private int aniIndex = 0;
private final GhostStrategy scaterStrategy;
private GhostStrategy currentStrategy;
private final BufferedImage[] animation;
private int movementTick = 0;
private Direction direction;
private Direction prevDirection;
private GhostMode mode;
private GhostStrategy currentStrategy;
public Ghost(GhostCollisionChecker collisionChecker, GhostStrategy strategy, BufferedImage[] animation) {
public Ghost(GhostCollisionChecker collisionChecker, GhostStrategy strategy, GhostStrategy scaterStrategy, BufferedImage[] animation) {
this.collisionChecker = collisionChecker;
this.chaseStrategy = strategy;
this.currentStrategy = strategy;
this.scaterStrategy = scaterStrategy;
this.animation = animation;
position = new Point(
13 * GameMap.MAP_TILESIZE + (GameMap.MAP_TILESIZE / 2) + GameMap.OFFSET_X,
4 * GameMap.MAP_TILESIZE + (GameMap.MAP_TILESIZE / 2) + GameMap.OFFSET_Y);
this.currentStrategy = chaseStrategy;
}
public void draw(Graphics g) {
@ -62,20 +69,19 @@ public class Ghost {
COLLISION_BOX_SIZE, null);
}
public void update(PacMan pacman) {
public void update(PacMan pacman, GameMap map) {
updateAnimationTick();
updatePosition(pacman);
updatePosition(pacman, map);
}
private void updatePosition(PacMan pacman) {
private void updatePosition(PacMan pacman, GameMap map) {
if (movementTick >= GHOST_MOVEMENT_UPDATE_FREQUENCY) {
if (isAlligned(position)) {
log.info("Evaluating possible directions");
prevDirection = direction;
direction = chooseDirection(
collisionChecker.calculateDirectionAlternatives(position),
currentStrategy.chooseTarget(pacman),
prevDirection);
prioritize(collisionChecker.calculateDirectionAlternatives(position)),
currentStrategy.chooseTarget(pacman, map));
log.info("selecting direction {}", direction);
}
@ -93,17 +99,36 @@ public class Ghost {
} else movementTick++;
}
private Map<Direction, Integer> prioritize(List<Direction> directions) {
return directions.stream()
.filter(d -> d != Direction.NONE)
.collect(Collectors.toMap(
d -> d,
d -> (prevDirection != null && d == prevDirection.opposite()) ? 2 : 1
));
}
private boolean isAlligned(Point pos) {
int row = pos.x % GameMap.MAP_TILESIZE;
int col = pos.y % GameMap.MAP_TILESIZE;
return row == GameMap.MAP_TILESIZE / 2 && col == GameMap.MAP_TILESIZE / 2;
}
private Direction chooseDirection(List<Direction> options, Point target, Direction prevDirection) {
List<Direction> l = options.stream()
// remove any option to go back
.filter(d -> !(prevDirection != null && d.equals(prevDirection.opposite())))
private Direction chooseDirection(Map<Direction, Integer> 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<Direction> l = options.entrySet().stream()
.filter(entry -> entry.getValue() == lowestPriority)
.map(Map.Entry::getKey)
.toList();
Direction best = l.getFirst();
double bestDist = Double.MAX_VALUE;
@ -137,7 +162,7 @@ public class Ghost {
this.mode = mode;
switch (mode) {
case CHASE -> currentStrategy = chaseStrategy;
case SCATTER -> currentStrategy = null;
case SCATTER -> currentStrategy = scaterStrategy;
case FRIGHTENED -> currentStrategy = null;
case EATEN -> currentStrategy = null;
}

View File

@ -1,7 +1,9 @@
package se.urmo.game.entities;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public interface GhostStrategy {
Point chooseTarget(PacMan pacman);
Point chooseTarget(PacMan pacman, GameMap map);
}

View File

@ -18,19 +18,20 @@ import java.util.Arrays;
@Slf4j
public class PacMan {
public static final int PACMAN_SIZE = 32;
public static final int PACMAN_OFFSET = PACMAN_SIZE / 2;
private static final int COLLISION_BOX_SIZE = 16;
private static final int COLLISION_BOX_OFFSET = (PACMAN_SIZE - COLLISION_BOX_SIZE) / 2;
private final Game game;
private int aniTick = 0;
private int aniIndex = 0;
private static final int ANIMATION_UPDATE_FREQUENCY = 25;
private static final int ANIMATION_UPDATE_FREQUENCY = 10;
private int speed = 1;
@Setter
private boolean moving;
private final BufferedImage[][] movmentImages = new BufferedImage[4][4];
private Point position;
private static final BufferedImage COLLISION_BOX = MiscUtil.createOutlinedBox(COLLISION_BOX_SIZE, COLLISION_BOX_SIZE, Color.yellow, 2);
private CollisionChecker collisionChecker;
private final CollisionChecker collisionChecker;
@Setter
@Getter
private Direction direction = Direction.NONE;
@ -65,17 +66,16 @@ public class PacMan {
.toArray(BufferedImage[]::new);
}
public void draw(Graphics g) {
g.drawImage(
movmentImages[direction==Direction.NONE ? 0 : direction.ordinal()][aniIndex],
position.x - PACMAN_SIZE / 2,
position.y - PACMAN_SIZE / 2,
position.x - PACMAN_OFFSET,
position.y - PACMAN_OFFSET,
PACMAN_SIZE,
PACMAN_SIZE, null);
g.drawImage(COLLISION_BOX, position.x - COLLISION_BOX_OFFSET, position.y - COLLISION_BOX_OFFSET, COLLISION_BOX_SIZE, COLLISION_BOX_SIZE, null);
g.setColor(Color.BLUE);
g.fillRect(position.x-1, position.y-1, 3, 3);
//g.fillRect(position.x-1, position.y-1, 3, 3);
}
public void update() {
@ -98,7 +98,6 @@ public class PacMan {
}
}
private void updateAnimationTick() {
if (moving) {
aniTick++;

View File

@ -1,21 +1,21 @@
package se.urmo.game.entities;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import java.awt.Point;
public class PinkyStrategy implements GhostStrategy{
@Override
public Point chooseTarget(PacMan pacman) {
public Point chooseTarget(PacMan pacman, GameMap map) {
Direction pacmanDir = pacman.getDirection();
Point pacmanPos = pacman.getTilePosition();
return switch (pacmanDir){
case RIGHT -> new Point(pacmanPos.x + 4 * 16, pacmanPos.y);
case LEFT -> new Point(pacmanPos.x - 4 * 16, pacmanPos.y);
case DOWN -> new Point(pacmanPos.x, pacmanPos.y + 4 * 16);
case UP -> new Point(pacmanPos.x, pacmanPos.y - 4 * 16);
case RIGHT -> new Point(pacmanPos.x + 4 * GameMap.MAP_TILESIZE, pacmanPos.y);
case LEFT -> new Point(pacmanPos.x - 4 * GameMap.MAP_TILESIZE, pacmanPos.y);
case DOWN -> new Point(pacmanPos.x, pacmanPos.y + 4 * GameMap.MAP_TILESIZE);
case UP -> new Point(pacmanPos.x, pacmanPos.y - 4 * GameMap.MAP_TILESIZE);
case NONE -> pacmanPos;
default -> throw new IllegalStateException("Illegal direction");
};
}
}

View File

@ -0,0 +1,12 @@
package se.urmo.game.entities;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public class ScatterToTopRight implements GhostStrategy{
@Override
public Point chooseTarget(PacMan pacman, GameMap map) {
return new Point((map.columns() -1) * GameMap.MAP_TILESIZE, 0);
}
}

View File

@ -2,7 +2,6 @@ package se.urmo.game.map;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.main.GamePanel;
import se.urmo.game.util.Direction;
import se.urmo.game.util.LoadSave;
import java.awt.*;
@ -12,7 +11,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import java.util.stream.Stream;
@Slf4j
public class GameMap {
@ -127,9 +125,7 @@ public class GameMap {
public void draw(Graphics g) {
for (int row = 0; row < mapData.length; row++) {
for (int col = 0; col < mapData[row].length; col++) {
MapTile tile = mapData[row][col];
BufferedImage tileImage = tile.getImage();
BufferedImage tileImage = mapData[row][col].getImage();
if (tileImage != null) {
int x = OFFSET_X + col * MAP_TILESIZE;
@ -144,8 +140,8 @@ public class GameMap {
return list.stream().allMatch(p -> isPassable(p.x, p.y));
}
public boolean isPassable(int x, int y) {
int row = (y - OFFSET_Y) / MAP_TILESIZE;
int col = (x - OFFSET_X) / MAP_TILESIZE;
int row = getRow(y);
int col = getCol(x);
int tileY = (y - OFFSET_Y) % MAP_TILESIZE;
int tileX = (x - OFFSET_X) % MAP_TILESIZE;
log.trace("Point[x="+x+",y="+y+"] is row="+ row + ", col=" + col + " with reminder x=" +tileX+",y=" +tileY);
@ -156,8 +152,12 @@ public class GameMap {
return b;
}
public int getWidth() {
return GamePanel.SCREEN_WIDTH;
private static int getCol(int x) {
return (x - OFFSET_X) / MAP_TILESIZE;
}
private static int getRow(int y) {
return (y - OFFSET_Y) / MAP_TILESIZE;
}
public int getHeight() {
@ -165,42 +165,36 @@ public class GameMap {
}
public void removeTileImage(Point destination) {
int row = (destination.y - OFFSET_Y) / MAP_TILESIZE;
int col = (destination.x - OFFSET_X) / MAP_TILESIZE;
int row = getRow(destination);
int col = getCol(destination);
MapTile tile = mapData[row][col];
if(tile.getValue() == 0) tile.setImage(null);
}
public List<Direction> directionAlternatives(Point position) {
int row = (position.y - OFFSET_Y) / MAP_TILESIZE;
int col = (position.x - OFFSET_X) / MAP_TILESIZE;
record DirectionCheck(int rowOffset, int colOffset, Direction direction) {}
log.debug("At [{}][{}]", row, col);
return Stream.of(
new DirectionCheck(0, 1, Direction.RIGHT),
new DirectionCheck(0, -1, Direction.LEFT),
new DirectionCheck(1, 0, Direction.DOWN),
new DirectionCheck(-1, 0, Direction.UP)
)
.filter(dc -> {
int r = row + dc.rowOffset;
int c = col + dc.colOffset;
MapTile mapTile = mapData[r][c];
boolean solid = mapTile.isSolid();
log.debug("[{}][{}] {} is {} ({})", r, c, dc.direction, solid ? "solid" : " not solid", mapTile.getValue());
return !solid;
})
.map(DirectionCheck::direction)
.toList();
private static int getCol(Point point) {
return getCol(point.x);
}
public boolean isSolid(int x, int y) {
int row = (y - OFFSET_Y) / MAP_TILESIZE;
int col = (x - OFFSET_X) / MAP_TILESIZE;
private static int getRow(Point point) {
return getRow(point.y);
}
public boolean isSolid(Point pos) {
int row = getRow(pos);
int col = getCol(pos);
return isSolid(row,col);
}
public boolean isSolid(int row, int col) {
if (col >= columns() || col < 0 ) return true;
MapTile mapTile = mapData[row][col];
boolean solid = mapTile.isSolid();
log.debug("[{}][{}] is {} ({})", row, col, solid ? "solid" : " not solid", mapTile.getValue());
return solid;
}
public int columns() {
return (GamePanel.SCREEN_WIDTH - 2 * OFFSET_X) / MAP_TILESIZE;
}
}

View File

@ -1,5 +1,6 @@
package se.urmo.game.state;
import lombok.Getter;
import se.urmo.game.main.Game;
import java.awt.*;
@ -9,6 +10,7 @@ import java.util.Map;
public class GameStateManager {
private final Game game;
private Map<GameStateType, GameState> states = new HashMap<>();
@Getter
private GameState currentState;
public GameStateManager(Game game) {
@ -28,8 +30,4 @@ public class GameStateManager {
public void render(Graphics2D g) {
currentState.render(g);
}
public GameState getCurrentState() {
return currentState;
}
}

View File

@ -1,12 +1,15 @@
package se.urmo.game.state;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.BlinkyStrategy;
import se.urmo.game.entities.Ghost;
import se.urmo.game.entities.GhostMode;
import se.urmo.game.entities.PacMan;
import se.urmo.game.entities.PinkyStrategy;
import se.urmo.game.entities.ScatterToTopRight;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.LoadSave;
import java.awt.Graphics2D;
@ -14,21 +17,34 @@ import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class GhostManager {
public static final int SPRITE_SHEET_ROWS = 10;
public static final int MAX_SPRITE_FRAMES = 4;
@Getter
private final List<Ghost> ghosts = new ArrayList<>();
private BufferedImage[][] image;
private GhostMode globalMode = GhostMode.CHASE;
private GhostMode globalMode;
private long lastModeSwitchTime;
private int phaseIndex = 0;
// cycle in milliseconds: {scatter, chase, scatter, chase, ...}
private final int[] cycleDurations = {
7000, 20000, // scatter 7s, chase 20s
7000, 20000, // scatter 7s, chase 20s
5000, 20000, // scatter 5s, chase 20s
5000, Integer.MAX_VALUE // scatter 5s, then chase forever
};
public GhostManager(GhostCollisionChecker ghostCollisionChecker) {
loadAnimation();
// Create ghosts with their strategies
//ghosts.add(new Ghost(ghostCollisionChecker, new BlinkyStrategy(),image[0]));
//ghosts.add(new Ghost(ghostCollisionChecker, new PinkyStrategy(), image[1]));
ghosts.add(new Ghost(ghostCollisionChecker, new BlinkyStrategy(),new ScatterToTopLeft(), image[0]));
ghosts.add(new Ghost(ghostCollisionChecker, new PinkyStrategy(),new ScatterToTopRight(), image[1]));
//ghosts.add(new Ghost(240, 200, new InkyStrategy(), loader.getSprite("inky")));
//ghosts.add(new Ghost(260, 200, new ClydeStrategy(), loader.getSprite("clyde")));
setMode(GhostMode.CHASE);
}
private void loadAnimation() {
@ -44,14 +60,28 @@ public class GhostManager {
public void setMode(GhostMode mode) {
this.globalMode = mode;
log.debug("Mode changed to {}", globalMode);
for (Ghost g : ghosts) {
g.setMode(mode);
}
}
public void update(PacMan pacman) {
public void update(PacMan pacman, GameMap map) {
long now = System.currentTimeMillis();
if (phaseIndex < cycleDurations.length) {
int duration = cycleDurations[phaseIndex];
if (now - lastModeSwitchTime >= duration) {
phaseIndex++;
if (phaseIndex < cycleDurations.length) {
GhostMode newMode = (phaseIndex % 2 == 0) ? GhostMode.SCATTER : GhostMode.CHASE;
setMode(newMode);
}
lastModeSwitchTime = now;
}
}
for (Ghost g : ghosts) {
g.update(pacman);
g.update(pacman, map);
}
}

View File

@ -32,7 +32,7 @@ public class PlayingState implements GameState {
@Override
public void update() {
pacman.update();
ghostManager.update(pacman);
ghostManager.update(pacman, map);
}
@Override

View File

@ -0,0 +1,15 @@
package se.urmo.game.state;
import se.urmo.game.entities.GhostStrategy;
import se.urmo.game.entities.PacMan;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public class ScatterToTopLeft implements GhostStrategy {
@Override
public Point chooseTarget(PacMan pacman, GameMap map) {
return new Point(1, 0);
}
}