Compare commits

...

36 Commits

Author SHA1 Message Date
5b2626ad5d Added last pellets 2025-09-07 17:28:47 +02:00
c65787dfc9 Implemented sound - first rev. 2025-09-07 17:26:09 +02:00
fd85d8cd0f Added TUNNEL TileType 2025-09-07 12:15:29 +02:00
32e884ec36 minor fix 2025-09-06 21:57:25 +02:00
5f653d3252 ScorePopup working 2025-09-06 21:53:18 +02:00
4b262362be Fixed FrightenedGhostMode blinking 2025-09-06 21:18:55 +02:00
c696e666d0 Added highscore 2025-09-06 20:55:57 +02:00
ec89422e81 Changed ghost/packman-collision 2025-09-06 20:42:33 +02:00
a8dc81984f Fixed so that ghosts dont accidently go back in house 2025-09-06 20:30:12 +02:00
8865a196f8 Added logging 2025-09-06 20:26:56 +02:00
80f7897da6 Fixed issue with freezing ghosts 2025-09-06 20:16:13 +02:00
c1c998c1cd Refactor ghost behavior and improve mode transition logic
Replaced hardcoded coordinates with `Ghost.getHouseEntrance` for clarity and flexibility. Introduced `requestModeChange` to streamline state transitions. Updated collision and movement logic to handle ghost-specific tile interactions, enhancing gameplay accuracy.
2025-09-06 18:55:25 +02:00
291f929064 Refactor Ghost mode handling to prioritize mode transitions
Reordered GhostMode enum for logical priority and updated `setMode` to enforce transitions only to higher-priority modes. Added `currentMode` method for determining the Ghost's active mode, ensuring cleaner and safer state management.
2025-09-06 00:42:40 +02:00
e299a4173e Added HOUSE mode 2025-09-06 00:12:08 +02:00
d551b464b1 Moved mode-related in to package 2025-09-05 23:25:09 +02:00
5f118a75f6 Ghost refactoring 2025-09-05 23:22:45 +02:00
ebbc82b70e Minor fixes 2025-09-04 23:12:39 +02:00
c475d3cb02 Minor fixes 2025-09-03 21:10:34 +02:00
6dabc63892 Refactor Pacman state handling to use setState method.
Centralized Pacman state transitions into a new `setState` method to improve clarity and consistency. Removed redundant `reset` and `startDeathAnimation` methods, merging their logic into state management. Adjusted game state transitions to align with the new approach.
2025-09-03 20:58:57 +02:00
2f9106c1c9 Minor change 2025-09-03 20:32:33 +02:00
b317581e9c Add "FROZEN" mode to Ghost behavior and update logic
Introduced a new "FROZEN" mode to the Ghost state machine, ensuring proper handling in ghost behaviors and animations. Updated `GhostManager` and `Ghost` classes to integrate this mode, including relevant logic for updates and animations. Simplified and refined ghost initialization and strategy handling for better code maintainability.
2025-09-03 20:25:54 +02:00
11a550e997 Implement ghost "eaten" behavior and refine fright mechanics
Added a new "eaten" mode for ghosts, including animations, movement strategy, and logic to reset ghosts after being eaten. Adjusted scoring for frightened ghosts using a fright multiplier and introduced enhancements like streamlined direction prioritization and position alignment. Also temporarily disabled non-essential ghosts for testing purposes.
2025-09-03 12:53:26 +02:00
d4b980f522 Moved around 2025-09-03 00:42:48 +02:00
9c0a613e4d Minor changes 2025-09-03 00:38:28 +02:00
c05398201f Minot pacman-changes 2025-09-03 00:27:44 +02:00
4638484b97 Refactor CollisionChecker for cleaner boundary handling
Streamlined collision logic by introducing helper methods for boundary calculations and movement validation. Replaced commented-out code with efficient implementations, improving readability and maintainability. Added clear documentation for new methods to better convey their purpose and usage.
2025-09-03 00:18:27 +02:00
328098bbe7 Refactor PacMan to utilize SpriteSheetManager.
Replaced hardcoded sprite loading logic with centralized SpriteSheetManager to improve maintainability and reduce duplication. Adjusted related methods to use the new Sprites record for streamlined sprite management.
2025-09-02 21:47:50 +02:00
5ba16402e4 Deathanimation working 2025-09-02 21:30:08 +02:00
96c89b6598 Inlcuded deathanimation in spritesheet 2025-09-02 12:26:18 +02:00
2d5c4c18f5 HighScoreManager 2025-09-01 21:53:10 +02:00
b13f87f77b GameOverState WIP 2025-09-01 21:48:34 +02:00
fcd686fad0 Added visuals to RoundPhase 2025-09-01 20:36:54 +02:00
be3c4deb3c Implemented level-change 2025-09-01 15:29:25 +02:00
3a4a0a1824 Moved speeds to LevelManager 2025-08-31 19:53:38 +02:00
2e9e7cc45e Refactored package-structure
Extracted Animated-interface
2025-08-31 17:57:46 +02:00
61d9df04f9 Refactored Ghost positioning and speed - now uses Double x,y 2025-08-31 16:02:38 +02:00
87 changed files with 1796 additions and 671 deletions

View File

@ -2,107 +2,93 @@ package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.MyPoint;
import se.urmo.game.main.GamePanel; import se.urmo.game.main.GamePanel;
import se.urmo.game.util.Direction;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.Pair; import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.awt.Point;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@Slf4j @Slf4j
public class CollisionChecker { public class CollisionChecker {
private GameMap map; private final GameMap map;
public CollisionChecker(GameMap map) { public CollisionChecker(GameMap map) {
this.map = map; this.map = map;
} }
// public Point getValidDestination(Direction direction, Point position, int agent_width, int agent_height) { public MyPoint getValidDestination(Direction direction, MyPoint position, int agentWidth, int agentHeight) {
// List<Point> boundaries = switch (direction) { List<MyPoint> boundaries = getBoundariesForDirection(direction, position, agentWidth / 2, agentHeight / 2);
// case NONE -> Collections.emptyList();
// case RIGHT, LEFT -> List.of(
// new Point(position.x + (direction.dx * agent_width/2), position.y - agent_height/2),
// new Point(position.x + (direction.dx * agent_width/2), position.y + agent_height/2)
// );
// case UP, DOWN -> List.of(
// new Point(position.x - agent_width/2, position.y + (direction.dy * agent_height/2)),
// new Point(position.x + agent_width/2, position.y + (direction.dy * agent_height/2))
// );
// };
//
// List<Pair> bs = boundaries.stream().map(p -> new Pair(p.x, p.y, GameMap.screenToRow(p.y), GameMap.screenToCol(p.x))).toList();
// log.debug("{} boundaries for {} are {}", direction, position, bs);
//
// List<Point> normalized = boundaries.stream()
// .map(p -> normalizePosition(direction, p, agent_width, agent_height))
// .toList();
//
// if (map.isPassable(normalized)) {
// return normalizePosition(direction, position, agent_width, agent_height);
// }
// return null; // Blocked
// }
// /** return canMoveInDirection(agentWidth, boundaries)
// * Applies specific rules to movement ? normalizePosition(position, agentWidth)
// * This, for instance, makes sure the tunnel left/right works. : null; // Blocked
// * }
// * @param dir
// * @param pos
// * @param agent_width
// * @param agent_height
// * @return
// */
// public Point normalizePosition(Direction dir, Point pos, int agent_width, int agent_height) {
// int x = pos.x;
// int y = pos.y;
// int width = GamePanel.SCREEN_WIDTH;
// int height = GamePanel.SCREEN_HEIGHT;
//
// // tunnel
// if (x < GameMap.OFFSET_X) x = width - agent_width/2 - GameMap.OFFSET_X; // right
// if (x >= (width - GameMap.OFFSET_X)) x = GameMap.OFFSET_X; // left
//
// return new Point(x, y);
// }
public MyPoint getValidDestination(Direction direction, MyPoint position, int agent_width, int agent_height) { /**
List<MyPoint> boundaries = switch (direction) { * Determines whether boundaries (corners) are in a passible i.e., not blocked, position
*
* @param agentWidth The width of the entity attempting to move.
* @param boundaries The boundary points representing the edges of the area occupied by the entity.
* @return {@code true} if movement in the specified direction is possible; {@code false} otherwise.
*/
private boolean canMoveInDirection(int agentWidth, List<MyPoint> boundaries) {
return boundaries.stream()
.map(boundary -> normalizePosition(boundary, agentWidth))
.allMatch(myPoint -> map.isPassable((int) myPoint.x, (int) myPoint.y));
}
/**
* Calculates the boundary points of a given position based on a specified direction
* and horizontal and vertical offsets. The resulting boundaries represent the edges
* of the area occupied by the entity moving in the specified direction.
*
* @param direction The direction of movement (e.g., UP, DOWN, LEFT, RIGHT, or NONE).
* @param position The current position represented as a {@code MyPoint}.
* @param horizontalOffset The horizontal offset to determine the boundary width.
* @param verticalOffset The vertical offset to determine the boundary height.
* @return A list of boundary points in the form of {@code MyPoint} objects. If the
* direction is {@code NONE}, an empty list is returned.
*/
private static List<MyPoint> getBoundariesForDirection(Direction direction, MyPoint position, int horizontalOffset, int verticalOffset) {
return switch (direction) {
case NONE -> Collections.emptyList(); case NONE -> Collections.emptyList();
case RIGHT, LEFT -> List.of( case RIGHT, LEFT -> List.of(
new MyPoint(position.x + (direction.dx * agent_width/2), position.y - agent_height/2), new MyPoint(position.x + ((double) (direction.dx * horizontalOffset) / 2), position.y - (double) verticalOffset / 2),
new MyPoint(position.x + (direction.dx * agent_width/2), position.y + agent_height/2) new MyPoint(position.x + ((double) (direction.dx * horizontalOffset) / 2), position.y + (double) verticalOffset / 2)
); );
case UP, DOWN -> List.of( case UP, DOWN -> List.of(
new MyPoint(position.x - agent_width/2, position.y + (direction.dy * agent_height/2)), new MyPoint(position.x - (double) horizontalOffset / 2, position.y + ((double) (direction.dy * verticalOffset) / 2)),
new MyPoint(position.x + agent_width/2, position.y + (direction.dy * agent_height/2)) new MyPoint(position.x + (double) horizontalOffset / 2, position.y + ((double) (direction.dy * verticalOffset) / 2))
); );
}; };
List<MyPoint> normalized = boundaries.stream()
.map(p -> normalizePosition(direction, p.x, p.y, agent_width, agent_height))
.toList();
boolean passable = normalized.stream().allMatch(myPoint -> map.isPassable((int) myPoint.x, (int) myPoint.y));
if (passable) {
return normalizePosition(direction, position.x, position.y, agent_width, agent_height);
}
return null; // Blocked
} }
private MyPoint normalizePosition(Direction direction, double x, double y, int agent_width, int agent_height) { /**
double x1 = x; * Normalizes the position of an agent when it reaches screen boundaries,
double y1 = y; * implementing tunnel-like behavior when crossing horizontal boundaries.
int width = GamePanel.SCREEN_WIDTH; *
int height = GamePanel.SCREEN_HEIGHT; * @param position The current position to normalize
* @param agentWidth The width of the agent
// tunnel * @return Normalized position as MyPoint
if (x < GameMap.OFFSET_X) x1 = width - agent_width/2 - GameMap.OFFSET_X; // right */
if (x>= (width - GameMap.OFFSET_X)) x1 = GameMap.OFFSET_X; // left private MyPoint normalizePosition(MyPoint position, int agentWidth) {
if (isLeftBoundary(position.x)) {
return new MyPoint(x1, y1); return new MyPoint(GamePanel.SCREEN_WIDTH - (double) agentWidth / 2 - GameMap.OFFSET_X, position.y);
} }
if (isRightBoundary(position.x)) {
return new MyPoint(GameMap.OFFSET_X, position.y);
}
return position;
}
private boolean isLeftBoundary(double x) {
return x < GameMap.OFFSET_X;
}
private boolean isRightBoundary(double x) {
return x >= (GamePanel.SCREEN_WIDTH - GameMap.OFFSET_X);
}
} }

View File

@ -1,13 +1,12 @@
package se.urmo.game.collision; package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.map.MapTile;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.awt.Point;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
@Slf4j @Slf4j
public class GhostCollisionChecker { public class GhostCollisionChecker {
@ -17,18 +16,17 @@ public class GhostCollisionChecker {
this.map = map; this.map = map;
} }
public List<Direction> calculateDirectionAlternatives(Point position) { public List<Direction> calculateDirectionAlternatives(Ghost ghost, MyPoint position) {
List<Direction> intersection = map.directionAlternatives(position); List<Direction> intersection = map.directionAlternatives(ghost, (int) position.x, (int) position.y);
log.info("Possible travel directions: {}", intersection); log.info("Possible travel directions: {}", intersection);
return intersection; return intersection;
} }
public MyPoint canMoveTo(Ghost ghost, Direction dir, double x, double y) {
public Point canMoveTo(Direction dir, Point pos) {
// -1 is because else we endup in next tile // -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.isSolidXY(ghost,
(int) (x) + dir.dx * (GameMap.MAP_TILESIZE/2 - 1),
return ! map.isSolid(pp) ? pos : null; (int) (y) + dir.dy * (GameMap.MAP_TILESIZE/2 - 1)) ? new MyPoint(x,y) : null;
} }
} }

View File

@ -0,0 +1,8 @@
package se.urmo.game.entities;
public interface Animated {
void updateAnimationTick();
int getAnimationSpeed();
boolean isPaused();
}

View File

@ -0,0 +1,41 @@
package se.urmo.game.entities;
import lombok.Setter;
abstract public class BaseAnimated implements Animated {
private final int animationSpeed;
private final int maxSpriteFrames;
@Setter
protected boolean paused = true;
private int aniTick = 0;
protected int aniIndex = 0;
protected BaseAnimated(int animationSpeed, int maxSpriteFrames) {
this.animationSpeed = animationSpeed;
this.maxSpriteFrames = maxSpriteFrames;
}
public void updateAnimationTick() {
if (!paused) {
aniTick++;
if (aniTick >= animationSpeed) {
aniTick = 0;
aniIndex++;
if (aniIndex >= maxSpriteFrames) {
aniIndex = 0;
}
}
}
}
@Override
public int getAnimationSpeed() {
return animationSpeed;
}
@Override
public boolean isPaused() {
return paused;
}
}

View File

@ -1,223 +0,0 @@
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<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 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> 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;
}
}

View File

@ -1,8 +0,0 @@
package se.urmo.game.entities;
public enum GhostMode {
CHASE,
SCATTER,
FRIGHTENED,
EATEN
}

View File

@ -1,145 +0,0 @@
package se.urmo.game.entities;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.CollisionChecker;
import se.urmo.game.util.Direction;
import se.urmo.game.main.Game;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.LoadSave;
import se.urmo.game.util.MiscUtil;
import java.awt.*;
import java.awt.image.BufferedImage;
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 final Point startPosition;
private final MyPoint startPosition;
private int aniTick = 0;
private int aniIndex = 0;
private static final int ANIMATION_UPDATE_FREQUENCY = 10;
private static final double BASE_SPEED = 0.40;
@Setter
private boolean moving;
private final BufferedImage[][] movmentImages = new BufferedImage[4][4];
private MyPoint position;
private static final BufferedImage COLLISION_BOX = MiscUtil.createOutlinedBox(COLLISION_BOX_SIZE, COLLISION_BOX_SIZE, Color.yellow, 2);
private final CollisionChecker collisionChecker;
@Setter
@Getter
private Direction direction = Direction.NONE;
public PacMan(Game game, CollisionChecker collisionChecker) {
this.game = game;
this.collisionChecker = collisionChecker;
this.position = new MyPoint(
26 * GameMap.MAP_TILESIZE + GameMap.OFFSET_X,
13 * GameMap.MAP_TILESIZE + GameMap.OFFSET_Y + ((double) GameMap.MAP_TILESIZE / 2));
this.startPosition = this.position;
loadAnimation();
}
private void loadAnimation() {
BufferedImage[][] image = new BufferedImage[3][4];
BufferedImage img = LoadSave.GetSpriteAtlas("sprites/PacManAssets-PacMan.png");
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 4; col++) {
image[row][col] = img.getSubimage(PACMAN_SIZE * col, PACMAN_SIZE * row, PACMAN_SIZE, PACMAN_SIZE);
}
}
movmentImages[Direction.RIGHT.ordinal()] = image[0];
movmentImages[Direction.LEFT.ordinal()] = Arrays.stream(image[0])
.map(i -> LoadSave.rotate(i, 180))
.toArray(BufferedImage[]::new);
movmentImages[Direction.DOWN.ordinal()] = Arrays.stream(image[0])
.map(i -> LoadSave.rotate(i, 90))
.toArray(BufferedImage[]::new);
movmentImages[Direction.UP.ordinal()] = Arrays.stream(image[0])
.map(i -> LoadSave.rotate(i, 270))
.toArray(BufferedImage[]::new);
}
public void draw(Graphics g) {
g.drawImage(
movmentImages[direction==Direction.NONE ? 0 : direction.ordinal()][aniIndex],
(int) position.x - PACMAN_OFFSET,
(int) 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);
}
public void update() {
updateAnimationTick();
if(moving) {
log.debug("Moving to {}", direction);
MyPoint mpoint = switch (direction) {
case RIGHT -> new MyPoint(position.x + getSpeed(), position.y);
case LEFT -> new MyPoint(position.x - getSpeed(), position.y);
case UP -> new MyPoint(position.x, position.y - getSpeed());
case DOWN -> new MyPoint(position.x, position.y + getSpeed());
default -> throw new IllegalStateException("Unexpected value: " + direction);
};
MyPoint destination = collisionChecker.getValidDestination(direction, mpoint, COLLISION_BOX_SIZE, COLLISION_BOX_SIZE);
if (destination != null) {
position = destination;
}
}
}
private double getSpeed() {
return BASE_SPEED * 0.8;
}
private void updateAnimationTick() {
if (moving) {
aniTick++;
if (aniTick >= ANIMATION_UPDATE_FREQUENCY) {
aniTick = 0;
aniIndex++;
if (aniIndex >= 4) {
aniIndex = 0;
}
}
}
}
public double distanceTo(Point point) {
return new Point((int) position.x, (int) position.y).distance(point);
}
public void loseLife() {
}
public void resetPosition() {
position = startPosition;
}
public Image getLifeIcon() {
return movmentImages[0][1];
}
public Rectangle getBounds() {
return new Rectangle((int) (position.x - COLLISION_BOX_OFFSET), (int) (position.y - COLLISION_BOX_OFFSET), COLLISION_BOX_SIZE, COLLISION_BOX_SIZE);
}
public Point getPosition() {
return new Point((int) position.x, (int) position.y);
}
}

View File

@ -0,0 +1,62 @@
package se.urmo.game.entities;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
public final class ScorePopup {
private final double startX, startY; // world px (not screen)
private final String text;
private final long startNs;
private final long lifeNs; // e.g. 1_000_000_000L (1s)
// simple motion params
private final double risePixels; // e.g. 16 px total rise
public ScorePopup(double x, double y, String text, long lifeNs, double risePixels) {
this.startX = x;
this.startY = y;
this.text = text;
this.lifeNs = lifeNs;
this.risePixels = risePixels;
this.startNs = System.nanoTime();
}
public boolean isAlive() {
return (System.nanoTime() - startNs) < lifeNs;
}
public void draw(Graphics2D g, int offsetX, int offsetY, Font font) {
long dt = System.nanoTime() - startNs;
double t = Math.min(1.0, dt / (double) lifeNs); // 0..1
// motion: ease-out upward (quadratic)
double y = startY - (risePixels * (1 - (1 - t) * (1 - t)));
// fade: alpha 1 → 0
float alpha = (float) (1.0 - t);
var oldComp = g.getComposite();
var oldFont = g.getFont();
g.setFont(font);
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
// draw centered on tile center
var fm = g.getFontMetrics();
int sx = offsetX + (int) Math.round(startX);
int sy = offsetY + (int) Math.round(y);
int x = sx - fm.stringWidth(text) / 2;
int baseline = sy; // tune if you want it a tad above the exact point
// g.setColor(Color.WHITE);
g.setColor(Color.BLACK);
g.drawString(text, x + 1, baseline + 1);
g.setColor(Color.WHITE);
g.drawString(text, x, baseline);
g.drawString(text, x, baseline);
g.setComposite(oldComp);
g.setFont(oldFont);
}
}

View File

@ -1,9 +1,10 @@
package se.urmo.game.entities; package se.urmo.game.entities.collectibles;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.state.FruitType; import se.urmo.game.util.FruitType;
import java.awt.Graphics; import java.awt.Graphics;
import java.awt.Point; import java.awt.Point;
@ -35,8 +36,6 @@ public class Fruit {
} }
public boolean collidesWith(PacMan pacman) { public boolean collidesWith(PacMan pacman) {
//return pacman.distanceTo(position) < GameMap.MAP_TILESIZE / 2.0;
Rectangle pacmanBounds = pacman.getBounds(); Rectangle pacmanBounds = pacman.getBounds();
Rectangle fruitBounds = new Rectangle(position.x, position.y, sprite.getWidth(), sprite.getHeight()); Rectangle fruitBounds = new Rectangle(position.x, position.y, sprite.getWidth(), sprite.getHeight());
return pacmanBounds.intersects(fruitBounds); return pacmanBounds.intersects(fruitBounds);

View File

@ -0,0 +1,139 @@
package se.urmo.game.entities.ghost;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.BaseAnimated;
import se.urmo.game.entities.ghost.mode.ChaseGhostMode;
import se.urmo.game.entities.ghost.mode.EatenGhostMode;
import se.urmo.game.entities.ghost.mode.FrightenedGhostMode;
import se.urmo.game.entities.ghost.mode.FrozenGhostMode;
import se.urmo.game.entities.ghost.mode.GhostMode;
import se.urmo.game.entities.ghost.mode.GhostState;
import se.urmo.game.entities.ghost.mode.HouseGhostMode;
import se.urmo.game.entities.ghost.mode.ScatterGhostMode;
import se.urmo.game.entities.ghost.strategy.GhostStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.main.GhostManager;
import se.urmo.game.main.LevelManager;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.awt.Color;
import java.awt.Graphics;
import java.util.EnumMap;
import java.util.Map;
@Slf4j
public class Ghost extends BaseAnimated {
public static final double BASE_SPEED = 0.40;
public static final int GHOST_SIZE = 32;
private static final int ANIMATION_UPDATE_FREQUENCY = 25;
@Getter
private final GhostCollisionChecker collisionChecker;
@Getter
private final int animation;
@Getter
private final LevelManager levelManager;
@Getter
private final GhostStrategy scaterStrategy;
private GhostState currentState;
private final Map<GhostMode, GhostState> states = new EnumMap<>(GhostMode.class);
// Movement-related state
@Getter
@Setter
protected MyPoint position; // center of sprite
@Getter
@Setter
protected Direction direction;
@Getter
@Setter
protected Direction prevDirection;
@Getter
private static final MyPoint startPosition = new MyPoint(
GameMap.colToScreen(13) + ((double) GameMap.MAP_TILESIZE / 2),
GameMap.rowToScreen(12) + ((double) GameMap.MAP_TILESIZE / 2));
@Getter
private static final MyPoint houseEntrance = new MyPoint(
GameMap.colToScreen(13) + ((double) GameMap.MAP_TILESIZE / 2),
GameMap.rowToScreen(10) + ((double) GameMap.MAP_TILESIZE / 2));
public Ghost(GhostCollisionChecker collisionChecker, GhostStrategy chaseStrategy, GhostStrategy scaterStrategy, int animation, LevelManager levelManager) {
super(ANIMATION_UPDATE_FREQUENCY, GhostManager.MAX_SPRITE_FRAMES);
this.collisionChecker = collisionChecker;
this.scaterStrategy = scaterStrategy;
this.animation = animation;
this.levelManager = levelManager;
this.position = startPosition;
states.put(GhostMode.CHASE, new ChaseGhostMode(this, chaseStrategy));
states.put(GhostMode.SCATTER, new ScatterGhostMode(this, scaterStrategy));
states.put(GhostMode.FRIGHTENED, new FrightenedGhostMode(this));
states.put(GhostMode.EATEN, new EatenGhostMode(this));
states.put(GhostMode.FROZEN, new FrozenGhostMode(this));
states.put(GhostMode.HOUSE, new HouseGhostMode(this));
}
public void draw(Graphics g) {
g.drawImage(
currentState.getAnimation()[aniIndex],
(int) position.x - GHOST_SIZE / 2,
(int) position.y - GHOST_SIZE / 2,
GHOST_SIZE,
GHOST_SIZE, null);
g.setColor(Color.YELLOW);
// g.fillRect((int) Ghost.startPosition.x, (int) Ghost.startPosition.y, 2, 2);
// g.fillRect((int) Ghost.houseEntrance.x, (int) Ghost.houseEntrance.y, 2, 2);
// g.fillRect((int) position.x, (int) position.y, 4, 4);
}
public void update(PacMan pacman, GameMap map) {
currentState.update(this, pacman, map);
}
public void setMode(GhostMode mode) {
GhostMode currentMode = currentMode();
if (currentMode == null || mode.ordinal() < currentMode.ordinal()) { // only if new mode has higher prio
log.debug("Mode changed to {}", mode);
currentState = states.get(mode);
} else {
log.debug("Mode not changed to {}, current mode is {}", mode, currentMode);
}
}
/**
* Used by a state itself to request a transition to any other state
* This bypasses the priority system since the state itself is requesting the change
*/
public void requestModeChange(GhostMode mode) {
log.debug("Requested mode change to {}", mode);
currentState = states.get(mode);
}
public boolean isFrightened() {
return states.get(GhostMode.FRIGHTENED) == currentState;
}
public boolean isEaten() {
return states.get(GhostMode.EATEN) == currentState;
}
public GhostMode currentMode() {
return states.entrySet().stream()
.filter(s -> s.getValue() == currentState)
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
public void reset() {
currentState = states.get(GhostMode.CHASE);
((ChaseGhostMode) currentState).resetPosition();
}
}

View File

@ -0,0 +1,148 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.GhostStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.graphics.SpriteLocation;
import se.urmo.game.graphics.SpriteSheetManager;
import se.urmo.game.main.LevelManager;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
public abstract class AbstractGhostModeImpl implements GhostState {
private final Ghost ghost;
protected GhostStrategy strategy;
protected final LevelManager levelManager;
protected int animation;
public AbstractGhostModeImpl(Ghost ghost, GhostStrategy strategy, LevelManager levelManager, int animation) {
this.ghost = ghost;
this.strategy = strategy;
this.levelManager = levelManager;
this.animation = animation;
}
/**
* Update method to be implemented by each mode
*/
public abstract void update(Ghost ghost, PacMan pacman, GameMap map);
public double getSpeed() {
return Ghost.BASE_SPEED * levelManager.getGhostSpeed();
}
@Override
public MyPoint getPosition() {
return ghost.getPosition();
}
@Override
public Direction getDirection() {
return ghost.getDirection();
}
@Override
public BufferedImage[] getAnimation() {
return SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(animation);
}
protected void updatePosition(Ghost ghost, PacMan pacman, GameMap map) {
if (map.isAligned(getPosition().asPoint())) {
ghost.setPrevDirection(getDirection());
ghost.setDirection(chooseDirection(
ghost,
prioritizeDirections(
ghost.getCollisionChecker().calculateDirectionAlternatives(ghost, getPosition())),
getStrategy().chooseTarget(ghost, pacman, map)));
log.debug("Ghost moving to {}", getPosition());
}
moveTo(ghost, getNewPosition(getSpeed()));
}
protected GhostStrategy getStrategy() {
return strategy;
}
private MyPoint getNewPosition(double speed) {
MyPoint position = ghost.getPosition();
Direction direction = ghost.getDirection();
return new MyPoint(
position.x + direction.dx * speed,
position.y + direction.dy * speed);
}
private void moveTo(Ghost ghost, MyPoint newPosition) {
MyPoint destination = ghost.getCollisionChecker().canMoveTo(ghost,
getDirection(), newPosition.x, newPosition.y);
if (destination != null) {
ghost.setPosition(destination);
}
}
private Map<Direction, Integer> prioritizeDirections(List<Direction> directions) {
return directions.stream()
.filter(d -> d != Direction.NONE)
.collect(Collectors.toMap(
d -> d,
d -> (ghost.getPrevDirection() != null &&
d == ghost.getPrevDirection().opposite()) ? 2 : 1
));
}
private Direction chooseDirection(Ghost ghost, Map<Direction, Integer> options, Point target) {
// Find the lowest priority
int lowestPriority = options.values().stream()
.mapToInt(Integer::intValue)
.min()
.orElse(Integer.MAX_VALUE);
// Collect all directions that have this priority
List<Direction> directions = options.entrySet().stream()
.filter(entry -> entry.getValue() == lowestPriority)
.map(Map.Entry::getKey)
.toList();
MyPoint position = getPosition();
// Create a record to hold direction and distance
record DirectionDistance(Direction direction, double distance) {
}
// Stream through directions and find the one with the minimum distance to the target
List<DirectionDistance> dd = directions.stream()
.map(d -> {
double nx = position.x + d.dx * GameMap.MAP_TILESIZE;
double ny = position.y + d.dy * GameMap.MAP_TILESIZE;
double dist = target.distance(nx, ny);
return new DirectionDistance(d, dist);
})
.toList();
log.debug("Target: {}, Position: {}", target, getPosition());
log.debug("Directions: {}", dd);
Direction best = dd.stream()
.min(Comparator.comparingDouble(DirectionDistance::distance))
.map(DirectionDistance::direction)
.orElse(directions.getFirst()); // Fallback to first direction if stream is empty
log.debug("Ghost coming from {}, choosing {}, from {}",
ghost.getPrevDirection(), best, directions);
return best;
}
public void resetPosition() {
ghost.setPosition(Ghost.getStartPosition());
}
}

View File

@ -0,0 +1,31 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.GhostStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
@Slf4j
public class ChaseGhostMode extends AbstractGhostModeImpl {
public ChaseGhostMode(Ghost ghost, GhostStrategy strategy) {
super(ghost, strategy, ghost.getLevelManager(), ghost.getAnimation());
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
// Use the common position update method with chase strategy
updatePosition(ghost, pacman, map);
}
// @Override
// public void enter(Ghost ghost) {
// }
@Override
public double getSpeed() {
return Ghost.BASE_SPEED * levelManager.getGhostSpeed();
}
}

View File

@ -0,0 +1,43 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.EatenStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.graphics.SpriteLocation;
import se.urmo.game.graphics.SpriteSheetManager;
import se.urmo.game.map.GameMap;
import java.awt.image.BufferedImage;
@Slf4j
public class EatenGhostMode extends AbstractGhostModeImpl {
// Eaten mode specific constants
private static final BufferedImage[] EATEN_ANIMATION = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(9);
private static final double EATEN_SPEED = 1.0; // Faster when eaten
public EatenGhostMode(Ghost ghost) {
super(ghost, new EatenStrategy(), ghost.getLevelManager(), 9);
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
if (getPosition().asPoint().distance(Ghost.getHouseEntrance().asPoint()) < 10) {
log.debug("Ghost reached home, returning to chase mode");
ghost.requestModeChange(GhostMode.CHASE);
return;
}
updatePosition(ghost, pacman, map);
}
@Override
public BufferedImage[] getAnimation() {
return EATEN_ANIMATION;
}
@Override
public double getSpeed() {
return EATEN_SPEED;
}
}

View File

@ -0,0 +1,87 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.FearStrategy;
import se.urmo.game.entities.pacman.PacMan;
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 java.awt.image.BufferedImage;
@Slf4j
public class FrightenedGhostMode extends AbstractGhostModeImpl {
// Frightened mode specific constants
private static final int WARNING_THRESHOLD = 180; // 3 seconds of warning
private static final BufferedImage[] FEAR_ANIMATION = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(8);
private static final int FRIGHTENED_DURATION_TICKS = 10 * Game.UPS_SET;
// Frightened mode specific state
private int frightenedTimer = FRIGHTENED_DURATION_TICKS;
private boolean isBlinking = false;
public FrightenedGhostMode(Ghost ghost) {
super(ghost, new FearStrategy(), ghost.getLevelManager(), ghost.getAnimation());
strategy = new FearStrategy();
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
updateFrightened(ghost);
updatePosition(ghost, pacman, map);
}
@Override
public BufferedImage[] getAnimation() {
return (!isBlinking ? FEAR_ANIMATION : super.getAnimation());
}
@Override
public double getSpeed() {
return 1.0;
}
/**
* Update the frightened state timer and handle blinking animation
*/
private void updateFrightened(Ghost ghost) {
frightenedTimer--;
// Handle blinking animation when timer is running low
if (frightenedTimer <= WARNING_THRESHOLD) {
isBlinking = (frightenedTimer / 25) % 2 == 0;
}
// Check if frightened mode should end
if (frightenedTimer <= 0) {
log.debug("Frightened mode ended");
ghost.requestModeChange(GhostMode.CHASE);
frightenedTimer = FRIGHTENED_DURATION_TICKS;
isBlinking = false;
}
}
// public void enter(Ghost ghost) {
// // Initialize frightened mode-specific state
// log.debug("Entering frightened mode");
// frightenedTimer = FRIGHTENED_DURATION_TICKS;
// isBlinking = false;
// }
/**
* Get the current time remaining in frightened mode (for external queries)
*/
public int getRemainingTime() {
return frightenedTimer;
}
/**
* Reset the frightened timer (for power pellet extensions)
*/
public void resetTimer() {
frightenedTimer = FRIGHTENED_DURATION_TICKS;
isBlinking = false;
}
}

View File

@ -0,0 +1,23 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
@Slf4j
public class FrozenGhostMode extends AbstractGhostModeImpl {
public FrozenGhostMode(Ghost ghost) {
super(ghost, null, ghost.getLevelManager(), ghost.getAnimation());
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
// Do not update position - frozen ghosts don't move
}
// @Override
// public void enter(Ghost ghost) {
// log.debug("Entering frozen mode");
// }
}

View File

@ -0,0 +1,12 @@
package se.urmo.game.entities.ghost.mode;
public enum GhostMode {
// Highest priority first
EATEN, // 0: Ghost was eaten - highest priority
FRIGHTENED,// 1: Ghost is frightened by power pellet
FROZEN, // 2: Game is paused/frozen
HOUSE, // 3: Ghost is in the house
SCATTER, // 4: Ghost is scattering to corners
CHASE // 5: Ghost is chasing Pacman - lowest priority
}

View File

@ -0,0 +1,19 @@
package se.urmo.game.entities.ghost.mode;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.awt.image.BufferedImage;
public interface GhostState {
void update(Ghost ghost, PacMan pacman, GameMap map);
MyPoint getPosition();
Direction getDirection();
BufferedImage[] getAnimation();
}

View File

@ -0,0 +1,23 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.HouseStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
@Slf4j
public class HouseGhostMode extends AbstractGhostModeImpl {
public HouseGhostMode(Ghost ghost) {
super(ghost, new HouseStrategy(), ghost.getLevelManager(), ghost.getAnimation());
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
if (getPosition().asPoint().distance(Ghost.getHouseEntrance().asPoint()) < 15) {
log.debug("Ghost left home, switching to chase mode");
ghost.requestModeChange(GhostMode.CHASE);
}
updatePosition(ghost, pacman, map);
}
}

View File

@ -0,0 +1,56 @@
package se.urmo.game.entities.ghost.mode;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.strategy.GhostStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
@Slf4j
public class ScatterGhostMode extends AbstractGhostModeImpl {
// Time in scatter mode before returning to chase
private static final int SCATTER_DURATION = 7 * 60; // 7 seconds at 60 ticks/second
private int scatterTimer = 0;
public ScatterGhostMode(Ghost ghost, GhostStrategy scaterStrategy) {
super(ghost, scaterStrategy, ghost.getLevelManager(), ghost.getAnimation());
}
@Override
public void update(Ghost ghost, PacMan pacman, GameMap map) {
// Update scatter timer
updateScatterTimer(ghost);
// Use common position update with scatter strategy
updatePosition(ghost, pacman, map);
}
@Override
public double getSpeed() {
return Ghost.BASE_SPEED * levelManager.getGhostSpeed();
}
private void updateScatterTimer(Ghost ghost) {
scatterTimer--;
if (scatterTimer <= 0) {
log.debug("Scatter mode timed out, returning to chase");
ghost.requestModeChange(GhostMode.CHASE);
scatterTimer = SCATTER_DURATION;
}
}
// @Override
// public void enter(Ghost ghost) {
// log.debug("Entering scatter mode");
//
// // Initialize scatter mode timer
// scatterTimer = SCATTER_DURATION;
// }
/**
* Reset the scatter timer (for extending scatter mode)
*/
public void resetTimer() {
scatterTimer = SCATTER_DURATION;
}
}

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;
@ -8,7 +10,7 @@ public class ClydeStrategy implements GhostStrategy {
@Override @Override
public Point chooseTarget(Ghost clyde, PacMan pacman, GameMap map) { public Point chooseTarget(Ghost clyde, PacMan pacman, GameMap map) {
Point pacTile = pacman.getPosition(); Point pacTile = pacman.getPosition();
Point clydeTile = clyde.getPosition(); // ghosts current tile Point clydeTile = clyde.getPosition().asPoint(); // ghosts current tile
double distance = pacTile.distance(clydeTile); double distance = pacTile.distance(clydeTile);

View File

@ -0,0 +1,14 @@
package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public class EatenStrategy implements GhostStrategy {
@Override
public Point chooseTarget(Ghost ghost, PacMan pacman, GameMap map) {
return Ghost.getHouseEntrance().asPoint();
}
}

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;
@ -14,17 +16,18 @@ public class FearStrategy implements GhostStrategy {
public Point chooseTarget(Ghost ghost, PacMan pacman, GameMap map) { public Point chooseTarget(Ghost ghost, PacMan pacman, GameMap map) {
// Frightened ghosts do not target Pacman. // Frightened ghosts do not target Pacman.
// Instead, they pick a random adjacent valid tile. // Instead, they pick a random adjacent valid tile.
List<Direction> neighbors = map.directionAlternatives(ghost.getPosition()); Point ghostPos = ghost.getPosition().asPoint();
List<Direction> neighbors = map.directionAlternatives(ghost, ghostPos.x, ghostPos.y);
if (neighbors.isEmpty()) { if (neighbors.isEmpty()) {
return ghost.getPosition(); // stuck return ghost.getPosition().asPoint(); // stuck
} }
//Transform directions to actual Points //Transform directions to actual Points
List<Point> potentialTargets = neighbors.stream() List<Point> potentialTargets = neighbors.stream()
.map(d -> new Point( .map(d -> new Point(
ghost.getPosition().x + d.dx * GameMap.MAP_TILESIZE, (int) (ghost.getPosition().x + d.dx * GameMap.MAP_TILESIZE),
ghost.getPosition().y + d.dy * GameMap.MAP_TILESIZE)).toList(); (int) (ghost.getPosition().y + d.dy * GameMap.MAP_TILESIZE))).toList();
// Pick a random valid neighbor // Pick a random valid neighbor
return potentialTargets.get(random.nextInt(neighbors.size())); return potentialTargets.get(random.nextInt(neighbors.size()));

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -0,0 +1,14 @@
package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
import java.awt.Point;
public class HouseStrategy implements GhostStrategy {
@Override
public Point chooseTarget(Ghost ghost, PacMan pacman, GameMap map) {
return new Point(232, 280);
}
}

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;
@ -25,7 +27,7 @@ public class InkyStrategy implements GhostStrategy {
}; };
// 2. Vector from blinky to that tile // 2. Vector from blinky to that tile
Point blinkyPos = blinky.getPosition(); Point blinkyPos = blinky.getPosition().asPoint();
int vx = ahead.x - blinkyPos.x; int vx = ahead.x - blinkyPos.x;
int vy = ahead.y - blinkyPos.y; int vy = ahead.y - blinkyPos.y;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -1,5 +1,7 @@
package se.urmo.game.entities; package se.urmo.game.entities.ghost.strategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import java.awt.Point; import java.awt.Point;

View File

@ -0,0 +1,202 @@
package se.urmo.game.entities.pacman;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.CollisionChecker;
import se.urmo.game.entities.BaseAnimated;
import se.urmo.game.graphics.SpriteLocation;
import se.urmo.game.graphics.SpriteSheetManager;
import se.urmo.game.main.LevelManager;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.LoadSave;
import se.urmo.game.util.MyPoint;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.stream.Stream;
@Slf4j
public class PacMan extends BaseAnimated {
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 static final int ANIMATION_UPDATE_FREQUENCY = 10;
private static final double BASE_SPEED = 0.40;
private static final long FRAME_NS = 80_000_000L; // 80 ms
public static final int PACMAN_SPRITE_FRAMES = 4;
private final MyPoint startPosition;
private final CollisionChecker collisionChecker;
private final LevelManager levelManager;
private final Sprites sprites;
private boolean moving = false;
private MyPoint position; //
@Setter
@Getter
private Direction direction = Direction.NONE;
private BufferedImage[] deathFrames; // working copy
private long lastChangeNs;
// animation state
private PacmanState state = PacmanState.ALIVE;
private int deathFrameIdx = 0;
private double speed;
public PacMan(CollisionChecker collisionChecker, LevelManager levelManager) {
super(ANIMATION_UPDATE_FREQUENCY, PACMAN_SPRITE_FRAMES);
this.collisionChecker = collisionChecker;
this.levelManager = levelManager;
this.position = new MyPoint(
26 * GameMap.MAP_TILESIZE + GameMap.OFFSET_X,
13 * GameMap.MAP_TILESIZE + GameMap.OFFSET_Y + ((double) GameMap.MAP_TILESIZE / 2));
this.startPosition = this.position;
this.sprites = loadAnimation();
this.speed = BASE_SPEED * levelManager.getPacmanLevelSpeed();
}
private Sprites loadAnimation() {
BufferedImage[][] spriteMap = new BufferedImage[6][PACMAN_SPRITE_FRAMES];
BufferedImage[] deathFrames;
BufferedImage[] animation = SpriteSheetManager.get(SpriteLocation.PACMAN).getAnimation(0);
spriteMap[Direction.RIGHT.ordinal()] = animation;
spriteMap[Direction.LEFT.ordinal()] = Arrays.stream(animation)
.map(i -> LoadSave.rotate(i, Direction.LEFT.angel))
.toArray(BufferedImage[]::new);
spriteMap[Direction.DOWN.ordinal()] = Arrays.stream(animation)
.map(i -> LoadSave.rotate(i, Direction.DOWN.angel))
.toArray(BufferedImage[]::new);
spriteMap[Direction.UP.ordinal()] = Arrays.stream(animation)
.map(i -> LoadSave.rotate(i, Direction.UP.angel))
.toArray(BufferedImage[]::new);
deathFrames = Stream.concat(
Arrays.stream(SpriteSheetManager.get(SpriteLocation.PACMAN).getAnimation(1)),
Arrays.stream(SpriteSheetManager.get(SpriteLocation.PACMAN).getAnimation(2)))
.toArray(BufferedImage[]::new);
return new Sprites(spriteMap, deathFrames);
}
public void draw(Graphics g) {
switch (state) {
case ALIVE -> drawAlive(g);
case DYING -> drawDead(g);
}
}
private void drawAlive(Graphics g) {
if (state != PacmanState.ALIVE) return; // ignore if not dying/dead
g.drawImage(
sprites.spriteSheets[direction == Direction.NONE ? 0 : direction.ordinal()][aniIndex],
(int) position.x - PACMAN_OFFSET,
(int) position.y - PACMAN_OFFSET,
PACMAN_SIZE,
PACMAN_SIZE, null);
}
private void drawDead(Graphics g) {
if (state == PacmanState.ALIVE) return; // ignore if not dying/dead
g.drawImage(
deathFrames[deathFrameIdx],
(int) position.x - PACMAN_OFFSET,
(int) position.y - PACMAN_OFFSET,
PACMAN_SIZE,
PACMAN_SIZE,
null
);
}
public void update() {
switch (state) {
case ALIVE -> updateAlive();
case DYING -> updateDead();
}
}
private void updateDead() {
if (state != PacmanState.DYING) return;
long now = System.nanoTime();
while (now - lastChangeNs >= FRAME_NS && deathFrameIdx < sprites.deathFrames.length - 1) { // FRAME_NS has passed and not all frames has been drawn
deathFrameIdx++;
lastChangeNs += FRAME_NS; // carry over exact cadence
}
}
private void updateAlive() {
if (state != PacmanState.ALIVE) return;
if (moving) {
MyPoint destination = collisionChecker.getValidDestination(direction, getNewPosition(), COLLISION_BOX_SIZE, COLLISION_BOX_SIZE);
if (destination != null) {
position = destination;
}
}
}
private MyPoint getNewPosition() {
return new MyPoint(position.x + direction.dx * getSpeed(), position.y + direction.dy * getSpeed());
}
private double getSpeed() {
return this.speed;
}
public double distanceTo(Point point) {
return new Point((int) position.x, (int) position.y).distance(point);
}
public void setState(PacmanState state) {
this.state = state;
switch (state) {
case ALIVE -> {
position = startPosition;
aniIndex = 0; // reset animation to start
deathFrameIdx = 0;
speed = BASE_SPEED * levelManager.getPacmanLevelSpeed(); // Recalculate
}
case DYING -> {
deathFrameIdx = 0;
lastChangeNs = System.nanoTime(); // reset stopwatch right now
deathFrames = Arrays.stream(sprites.deathFrames)
.map(img -> LoadSave.rotate(img, direction.angel))
.toArray(BufferedImage[]::new);
}
}
}
public Image getLifeIcon() {
return sprites.spriteSheets[0][1];
}
public Rectangle getBounds() {
return new Rectangle(
(int) (position.x - COLLISION_BOX_OFFSET),
(int) (position.y - COLLISION_BOX_OFFSET),
COLLISION_BOX_SIZE,
COLLISION_BOX_SIZE);
}
public Point getPosition() {
return new Point((int) position.x, (int) position.y);
}
public void setMoving(boolean b) {
moving = b;
paused = !b;
}
public enum PacmanState {
ALIVE, DYING, DEAD
}
record Sprites(BufferedImage[][] spriteSheets, BufferedImage[] deathFrames) {
}
}

View File

@ -1,7 +1,8 @@
package se.urmo.game.graphics; package se.urmo.game.graphics;
import lombok.Getter; import lombok.Getter;
import se.urmo.game.entities.Ghost; import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.LoadSave; import se.urmo.game.util.LoadSave;
@ -12,6 +13,7 @@ public enum SpriteLocation {
MAP("sprites/PacMan-custom-spritemap-0-3.png", 5, 11, GameMap.MAP_TILESIZE), MAP("sprites/PacMan-custom-spritemap-0-3.png", 5, 11, GameMap.MAP_TILESIZE),
ITEM("sprites/PacManAssets-Items.png", 2, 8, GameMap.MAP_TILESIZE), ITEM("sprites/PacManAssets-Items.png", 2, 8, GameMap.MAP_TILESIZE),
GHOST("sprites/PacManAssets-Ghosts.png", 11, 4, Ghost.GHOST_SIZE), GHOST("sprites/PacManAssets-Ghosts.png", 11, 4, Ghost.GHOST_SIZE),
PACMAN("sprites/PacManAssets-PacMan.png", 3, 4, PacMan.PACMAN_SIZE),
NONE("", 0, 0, 0) { // Special case for tiles without sprites NONE("", 0, 0, 0) { // Special case for tiles without sprites
@Override @Override
public BufferedImage[][] loadSprites(int tileSize) { public BufferedImage[][] loadSprites(int tileSize) {

View File

@ -1,7 +1,5 @@
package se.urmo.game.graphics; package se.urmo.game.graphics;
import se.urmo.game.map.GameMap;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.Map; import java.util.Map;
@ -11,11 +9,6 @@ public class SpriteSheetManager {
public static SpriteSheet get(SpriteLocation location) { public static SpriteSheet get(SpriteLocation location) {
return spriteSheets.computeIfAbsent(location, SpriteSheet::new); return spriteSheets.computeIfAbsent(location, SpriteSheet::new);
} }
// Optionally add methods like:
public static void reloadAll() {
spriteSheets.clear();
}
} }

View File

@ -1,6 +1,6 @@
package se.urmo.game.input; package se.urmo.game.input;
import se.urmo.game.state.GameStateManager; import se.urmo.game.main.GameStateManager;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.awt.event.KeyListener; import java.awt.event.KeyListener;

View File

@ -0,0 +1,23 @@
package se.urmo.game.main;
import se.urmo.game.entities.Animated;
import java.util.ArrayList;
import java.util.List;
public class AnimationManager {
private final List<Animated> animatedEntities = new ArrayList<>();
public void register(Animated animated) {
animatedEntities.add(animated);
}
public void unregister(Animated entity) {
animatedEntities.remove(entity);
}
public void updateAll() {
animatedEntities.forEach(Animated::updateAnimationTick);
}
}

View File

@ -1,8 +1,10 @@
package se.urmo.game.state; package se.urmo.game.main;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.Fruit; import se.urmo.game.entities.collectibles.Fruit;
import se.urmo.game.entities.PacMan; import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.state.PlayingState;
import se.urmo.game.util.FruitType;
import java.awt.Graphics; import java.awt.Graphics;
@ -10,16 +12,15 @@ import java.awt.Graphics;
public class FruitManager { public class FruitManager {
private final LevelManager levelManager; private final LevelManager levelManager;
private Fruit activeFruit; private Fruit activeFruit;
private int dotsEaten = 0;
public FruitManager(LevelManager levelManager) { public FruitManager(LevelManager levelManager) {
this.levelManager = levelManager; this.levelManager = levelManager;
} }
public void dotEaten() { public void dotEaten(int dotsEaten) {
dotsEaten++; dotsEaten++;
if (dotsEaten == 10 || dotsEaten == 170) { if (dotsEaten == 10 || dotsEaten == 170) {
spawnFruit(levelManager.getLevel()); spawnFruit(levelManager.getCurrentLevel().getLevel());
} }
} }
@ -48,4 +49,8 @@ public class FruitManager {
activeFruit.draw(g); activeFruit.draw(g);
} }
} }
public void reset() {
activeFruit = null;
}
} }

View File

@ -2,9 +2,8 @@ package se.urmo.game.main;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.state.GameStateManager;
import javax.swing.*; import javax.swing.JFrame;
@Slf4j @Slf4j
public class Game implements Runnable { public class Game implements Runnable {
@ -22,7 +21,7 @@ public class Game implements Runnable {
private final GamePanel gamePanel; private final GamePanel gamePanel;
public Game() { public Game() {
this.gameStateManager = new GameStateManager(this); this.gameStateManager = new GameStateManager();
this.gamePanel = new GamePanel(this, gameStateManager); this.gamePanel = new GamePanel(this, gameStateManager);
} }

View File

@ -1,10 +1,12 @@
package se.urmo.game.main; package se.urmo.game.main;
import se.urmo.game.input.KeyHandler; import se.urmo.game.input.KeyHandler;
import se.urmo.game.state.GameStateManager;
import javax.swing.*; import javax.swing.JPanel;
import java.awt.*; import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
public class GamePanel extends JPanel { public class GamePanel extends JPanel {
public static final int ORIGINAL_TILE_SIZE = 8; public static final int ORIGINAL_TILE_SIZE = 8;

View File

@ -0,0 +1,36 @@
package se.urmo.game.main;
import lombok.Getter;
import se.urmo.game.state.GameOverState;
import se.urmo.game.state.GameState;
import se.urmo.game.state.PlayingState;
import se.urmo.game.util.GameStateType;
import java.awt.Graphics2D;
import java.util.HashMap;
import java.util.Map;
public class GameStateManager {
private final Map<GameStateType, GameState> states = new HashMap<>();
@Getter
private GameState currentState;
public GameStateManager() {
GameOverState gameOverState = new GameOverState(this, new HighScoreManager());
states.put(GameStateType.PLAYING, new PlayingState(this, gameOverState));
states.put(GameStateType.GAME_OVER, gameOverState);
setState(GameStateType.PLAYING);
}
public void setState(GameStateType type) {
currentState = states.get(type);
}
public void update() {
currentState.update();
}
public void render(Graphics2D g) {
currentState.render(g);
}
}

View File

@ -1,30 +1,27 @@
package se.urmo.game.state; package se.urmo.game.main;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.GhostCollisionChecker; import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.BlinkyStrategy; import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ClydeStrategy; import se.urmo.game.entities.ghost.mode.GhostMode;
import se.urmo.game.entities.Ghost; import se.urmo.game.entities.ghost.strategy.BlinkyStrategy;
import se.urmo.game.entities.GhostMode; import se.urmo.game.entities.ghost.strategy.ClydeStrategy;
import se.urmo.game.entities.InkyStrategy; import se.urmo.game.entities.ghost.strategy.InkyStrategy;
import se.urmo.game.entities.PacMan; import se.urmo.game.entities.ghost.strategy.PinkyStrategy;
import se.urmo.game.entities.PinkyStrategy; import se.urmo.game.entities.ghost.strategy.ScatterToBottomLeft;
import se.urmo.game.entities.ScatterToBottomLeft; import se.urmo.game.entities.ghost.strategy.ScatterToBottomRight;
import se.urmo.game.entities.ScatterToBottomRight; import se.urmo.game.entities.ghost.strategy.ScatterToTopLeft;
import se.urmo.game.entities.ScatterToTopLeft; import se.urmo.game.entities.ghost.strategy.ScatterToTopRight;
import se.urmo.game.entities.ScatterToTopRight; import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.util.LoadSave;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@Slf4j @Slf4j
public class GhostManager { public class GhostManager {
public static final int SPRITE_SHEET_ROWS = 10;
public static final int MAX_SPRITE_FRAMES = 4; public static final int MAX_SPRITE_FRAMES = 4;
public static final int BLINKY_ANIMATION = 0; public static final int BLINKY_ANIMATION = 0;
public static final int PINKY_ANIMATION = 2; public static final int PINKY_ANIMATION = 2;
@ -43,19 +40,22 @@ public class GhostManager {
5000, 20000, // scatter 5s, chase 20s 5000, 20000, // scatter 5s, chase 20s
5000, Integer.MAX_VALUE // scatter 5s, then chase forever 5000, Integer.MAX_VALUE // scatter 5s, then chase forever
}; };
private boolean frozen;
public GhostManager(GhostCollisionChecker ghostCollisionChecker) { public GhostManager(GhostCollisionChecker ghostCollisionChecker, AnimationManager animationManager, LevelManager levelManager) {
// Create ghosts with their strategies // Create ghosts with their strategies
Ghost blinky = new Ghost(ghostCollisionChecker, new BlinkyStrategy(), new ScatterToTopRight(), BLINKY_ANIMATION); Ghost blinky = new Ghost(ghostCollisionChecker, new BlinkyStrategy(), new ScatterToTopRight(), BLINKY_ANIMATION, levelManager);
ghosts.add(blinky); ghosts.add(blinky);
// ghosts.add(new Ghost(ghostCollisionChecker, new PinkyStrategy(), new ScatterToTopLeft(), PINKY_ANIMATION)); ghosts.add(new Ghost(ghostCollisionChecker, new PinkyStrategy(), new ScatterToTopLeft(), PINKY_ANIMATION, levelManager));
// ghosts.add(new Ghost(ghostCollisionChecker,new InkyStrategy(blinky), new ScatterToBottomRight(), INKY_ANIMATION)); ghosts.add(new Ghost(ghostCollisionChecker, new InkyStrategy(blinky), new ScatterToBottomRight(), INKY_ANIMATION, levelManager));
// ghosts.add(new Ghost(ghostCollisionChecker, new ClydeStrategy(), new ScatterToBottomLeft(), CLYDE_ANIMATION)); ghosts.add(new Ghost(ghostCollisionChecker, new ClydeStrategy(), new ScatterToBottomLeft(), CLYDE_ANIMATION, levelManager));
setMode(GhostMode.CHASE);
ghosts.forEach(animationManager::register);
setMode(GhostMode.HOUSE);
} }
public void setMode(GhostMode mode) { public void setMode(GhostMode mode) {
log.debug("Mode changed to {}", mode);
for (Ghost g : ghosts) { for (Ghost g : ghosts) {
g.setMode(mode); g.setMode(mode);
} }
@ -87,6 +87,20 @@ public class GhostManager {
} }
public void setFrightMode() { public void setFrightMode() {
this.setMode(GhostMode.FRIGHTENED); setMode(GhostMode.FRIGHTENED);
}
public void reset() {
phaseIndex = 0;
setMode(GhostMode.SCATTER);
ghosts.forEach(Ghost::reset);
}
public void setFrozen(boolean frozen) {
this.ghosts.forEach(ghost -> ghost.setMode(GhostMode.FROZEN));
}
public int isFrightened() {
return (int) ghosts.stream().filter(Ghost::isFrightened).count();
} }
} }

View File

@ -0,0 +1,42 @@
package se.urmo.game.main;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class HighScoreManager {
private final Path file = Path.of("highscores.dat");
private final int maxEntries = 10;
private final List<Integer> scores = new ArrayList<>();
public HighScoreManager() { load(); }
public void submit(int score) {
scores.add(score);
scores.sort(Comparator.reverseOrder());
if (scores.size() > maxEntries) scores.remove(scores.size() - 1);
save();
}
public List<Integer> top() { return List.copyOf(scores); }
private void load() {
try {
if (!Files.exists(file)) return;
for (String line : Files.readAllLines(file)) {
scores.add(Integer.parseInt(line.trim()));
}
scores.sort(Comparator.reverseOrder());
} catch (Exception ignored) {}
}
private void save() {
try {
var lines = scores.stream().map(String::valueOf).toList();
Files.write(file, lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (Exception ignored) {}
}
}

View File

@ -0,0 +1,59 @@
package se.urmo.game.main;
import lombok.Getter;
import se.urmo.game.util.FruitType;
public class LevelManager {
public void nextLevel() {
currentLevel = Level.forLevel(currentLevel.getLevel() + 1);
}
@Getter
public enum Level {
LEVEL1(1,6000, 0.75, 0.8, FruitType.CHERRY),
LEVEL2(2, 6000, 0.75, 0.8, FruitType.CHERRY),
LEVEL3(3, 6000, 0.75, 0.8, FruitType.CHERRY),
LEVEL4(4, 6000, 0.75, 0.8, FruitType.CHERRY),
LEVEL5(5, 3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL6(6, 3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL7(7, 3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL8(8, 3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL9(9, 3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL10(10, 1000, 0.95, 0.9, FruitType.CHERRY),
LEVEL11(11, 1000, 0.95, 0.9, FruitType.CHERRY),
LEVEL12(12, 1000, 0.95, 0.9, FruitType.CHERRY),
LEVEL13(13, 1000, 0.95, 0.9, FruitType.CHERRY),
LEVEL14(14, 6000, 0.95, 0.9, FruitType.CHERRY),
LEVEL15(15,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL16(16,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL17(17,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL18(18,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL19(19,6000, 0.75, 0.85, FruitType.CHERRY);
private final int frightendedDuration;
private final double ghostSpeed;
private final double pacmanSpeed;
private final FruitType fruitType;
private final int level;
Level(int level, int frightendedDuration, double ghostSpeed, double pacmanSpeed, FruitType fruitType) {
this.level = level;
this.frightendedDuration = frightendedDuration;
this.ghostSpeed = ghostSpeed;
this.pacmanSpeed = pacmanSpeed;
this.fruitType = fruitType;
}
public static Level forLevel(int i) {
return Level.values()[i];
}
}
@Getter
private Level currentLevel = Level.LEVEL1;
public double getPacmanLevelSpeed() {
return currentLevel.pacmanSpeed;
}
public double getGhostSpeed() {
return currentLevel.ghostSpeed;
}
}

View File

@ -0,0 +1,41 @@
package se.urmo.game.main;
import se.urmo.game.entities.ScorePopup;
import java.awt.Font;
import java.awt.Graphics2D;
import java.util.ArrayList;
import java.util.List;
public final class ScorePopupManager {
private final List<ScorePopup> popups = new ArrayList<>();
// convenience defaults
private static final long LIFE_NS = 1_000_000_000L; // 1s
private static final double RISE_PX = 16.0;
private static final double worldX = GamePanel.SCREEN_WIDTH / 2.0 - 15;
private static final double worldY = 230;
public void spawn(String text) {
popups.add(new ScorePopup(worldX, worldY, text, LIFE_NS, RISE_PX));
}
public void spawn(int score) {
spawn(String.valueOf(score));
}
public void update() {
// time-based; no per-tick math needed, just prune dead ones
popups.removeIf(p -> !p.isAlive());
}
public void draw(Graphics2D g, int offsetX, int offsetY, Font font) {
for (ScorePopup p : popups) {
p.draw(g, offsetX, offsetY, font);
}
}
public void clear() {
popups.clear();
}
}

View File

@ -1,12 +1,19 @@
package se.urmo.game.map; package se.urmo.game.map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.graphics.SpriteSheetManager; import se.urmo.game.entities.BaseAnimated;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.mode.GhostMode;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;
import java.awt.*; import java.awt.Graphics;
import java.awt.Point;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -18,15 +25,36 @@ public class GameMap {
public static final int MAP_ROW_SIZE = 30; public static final int MAP_ROW_SIZE = 30;
public static final int OFFSET_Y = 7 * MAP_TILESIZE; // 160px from top public static final int OFFSET_Y = 7 * MAP_TILESIZE; // 160px from top
public static final int OFFSET_X = MAP_TILESIZE; // 16px from left public static final int OFFSET_X = MAP_TILESIZE; // 16px from left
private final MapTile[][] mapData; private MapTile[][] mapData;
private final int[][] csvData;
private final long numberOfDots;
public GameMap(String mapFilePath) { public GameMap(String mapFilePath) {
this.mapData = loadMap(mapFilePath, MAP_ROW_SIZE, MAP_COL_SIZE); this.csvData = readCSV(mapFilePath, MAP_ROW_SIZE, MAP_COL_SIZE);
this.mapData = createMap(csvData);
this.numberOfDots = Arrays.stream(mapData)
.flatMap(Arrays::stream)
.filter(tile -> tile != null &&
(tile.getTileType() == TileType.SMALL_PELLET ||
tile.getTileType() == TileType.LARGE_PELLET))
.count();
} }
private MapTile[][] loadMap(String path, int mapRowSize, int mapColSize) { private static MapTile[][] createMap(int[][] data2) {
return Arrays.stream(data2)
.map(a -> Arrays.stream(a)
.mapToObj(i -> MapTile.byType(TileType.fromValue(i)))
.toArray(MapTile[]::new))
.toArray(MapTile[][]::new);
}
private int[][] readCSV(String path, int mapRowSize, int mapColSize) {
MapTile[][] data = new MapTile[mapRowSize][mapColSize]; MapTile[][] data = new MapTile[mapRowSize][mapColSize];
int[][] data2 = new int[mapRowSize][mapColSize];
try (InputStream is = getClass().getClassLoader().getResourceAsStream(path); try (InputStream is = getClass().getClassLoader().getResourceAsStream(path);
BufferedReader br = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is)))) { BufferedReader br = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is)))) {
@ -42,11 +70,10 @@ public class GameMap {
); );
} }
for (int col = 0; col < mapColSize; col++) { data2[row++] = Arrays.stream(tokens)
int value = Integer.parseInt(tokens[col].trim()); .map(String::trim)
data[row][col] = MapTile.byType(TileType.fromValue(value)); .mapToInt(Integer::parseInt)
} .toArray();
row++;
} }
if (row != mapRowSize) { if (row != mapRowSize) {
@ -60,7 +87,7 @@ public class GameMap {
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to read resource: {}", e.getMessage(), e); log.error("Failed to read resource: {}", e.getMessage(), e);
} }
return data; return data2;
} }
public void draw(Graphics g) { public void draw(Graphics g) {
@ -77,35 +104,48 @@ public class GameMap {
} }
} }
public boolean isPassable(List<Point> list){
return list.stream().allMatch(p -> isPassable(p.x, p.y));
}
public boolean isPassable(int screenX, int screenY) { public boolean isPassable(int screenX, int screenY) {
int row = screenToRow(screenY); int row = screenToRow(screenY);
int col = screenToCol(screenX); int col = screenToCol(screenX);
int tileY = (screenY - OFFSET_Y) % MAP_TILESIZE; int tileY = (screenY - OFFSET_Y) % MAP_TILESIZE;
int tileX = (screenX - OFFSET_X) % MAP_TILESIZE; int tileX = (screenX - OFFSET_X) % MAP_TILESIZE;
log.trace("Point[x={},y={}] is row={}, col={} with reminder x={},y={}", screenX, screenY, row, col, tileX, tileY); //log.trace("Point[x={},y={}] is row={}, col={} with reminder x={},y={}", screenX, screenY, row, col, tileX, tileY);
boolean[][] mask = mapData[row][col].getCollisionMask(); boolean[][] mask = mapData[row][col].getCollisionMask();
boolean b = mask == null || !mask[tileY][tileX]; boolean b = mask == null || !mask[tileY][tileX];
log.trace(b?" - passable":" - not passable"); //log.trace(b ? " - passable" : " - not passable");
return b; return b;
} }
public boolean isSolid(Point pos) { public boolean isSolidXY(Ghost ghost, int screenX, int screenY) {
return isSolid(screenToRow(pos), screenToCol(pos)); return isSolid(ghost, screenToRow(screenY), screenToCol(screenX));
} }
public boolean isSolid(int row, int col) { public boolean isSolid(BaseAnimated entity, int row, int col) {
// Check for out of bounds
if (col >= columns() || col < 0) return true; if (col >= columns() || col < 0) return true;
if (row >= rows() || row < 0) return true; if (row >= rows() || row < 0) return true;
// Get the tile information
MapTile mapTile = mapData[row][col]; MapTile mapTile = mapData[row][col];
boolean solid = mapTile.isSolid(); boolean solid = mapTile.isSolid();
log.debug("[{}][{}] {}", row, col, mapTile.getTileType());
// Special case: If the entity is a Ghost, it can pass through certain solid tiles
// This allows ghosts to move through areas that would be solid for other entities
if (entity instanceof Ghost) {
// For Ghost entities, check if this is a ghost-passable tile
// You might want to refine this logic based on specific tile types or ghost states
// Example: Allow ghosts to pass through ghost house door IF in house mode
if (mapTile.getTileType() == TileType.DOOR && ((Ghost) entity).currentMode() == GhostMode.HOUSE) {
return false; // Not solid for ghosts
}
}
//log.debug("[{}][{}] {}", row, col, mapTile.getTileType());
return solid; return solid;
} }
public boolean removeTileImage(Point screen) { public boolean removeTileImage(Point screen) {
@ -124,7 +164,7 @@ public class GameMap {
TileType type = tile.getTileType(); TileType type = tile.getTileType();
if (type.isRemovable() && tile.getImage() != null) { if (type.isRemovable() && tile.getImage() != null) {
log.debug("Removing tile {}", tile); // log.debug("Removing tile {}", tile);
tile.setImage(null); tile.setImage(null);
return true; return true;
} }
@ -143,6 +183,7 @@ public class GameMap {
public int columns() { public int columns() {
return MAP_COL_SIZE; return MAP_COL_SIZE;
} }
public int rows() { public int rows() {
return MAP_ROW_SIZE; return MAP_ROW_SIZE;
} }
@ -186,6 +227,7 @@ public class GameMap {
public static int colToScreen(int col) { public static int colToScreen(int col) {
return col * MAP_TILESIZE + OFFSET_X; return col * MAP_TILESIZE + OFFSET_X;
} }
public static int rowToScreen(int row) { public static int rowToScreen(int row) {
return row * MAP_TILESIZE + OFFSET_Y; return row * MAP_TILESIZE + OFFSET_Y;
} }
@ -198,12 +240,13 @@ public class GameMap {
} }
public List<Direction> directionAlternatives(Point screen) { public List<Direction> directionAlternatives(BaseAnimated entity, int screenX, int screenY) {
int row = (screen.y - GameMap.OFFSET_Y) / GameMap.MAP_TILESIZE; int row = (screenY - GameMap.OFFSET_Y) / GameMap.MAP_TILESIZE;
int col = (screen.x - GameMap.OFFSET_X) / GameMap.MAP_TILESIZE; int col = (screenX - GameMap.OFFSET_X) / GameMap.MAP_TILESIZE;
record DirectionCheck(int rowOffset, int colOffset, Direction direction) {} record DirectionCheck(int rowOffset, int colOffset, Direction direction) {
log.debug("At [{}][{}]", row, col); }
log.debug("At ({},{}), [{}][{}]", screenX, screenY, row, col);
return Stream.of( return Stream.of(
new DirectionCheck(0, 1, Direction.RIGHT), new DirectionCheck(0, 1, Direction.RIGHT),
new DirectionCheck(0, -1, Direction.LEFT), new DirectionCheck(0, -1, Direction.LEFT),
@ -213,7 +256,7 @@ public class GameMap {
.filter(dc -> { .filter(dc -> {
int r = row + dc.rowOffset; int r = row + dc.rowOffset;
int c = col + dc.colOffset; int c = col + dc.colOffset;
boolean solid = isSolid(r, c); boolean solid = isSolid(entity, r, c);
log.debug("[{}][{}] {} is {}", r, c, dc.direction, solid ? "solid" : " not solid"); log.debug("[{}][{}] {} is {}", r, c, dc.direction, solid ? "solid" : " not solid");
return !solid; return !solid;
}) })
@ -226,4 +269,13 @@ public class GameMap {
int col = pos.y % GameMap.MAP_TILESIZE; int col = pos.y % GameMap.MAP_TILESIZE;
return row == GameMap.MAP_TILESIZE / 2 && col == GameMap.MAP_TILESIZE / 2; return row == GameMap.MAP_TILESIZE / 2 && col == GameMap.MAP_TILESIZE / 2;
} }
public long numberOfDots() {
return this.numberOfDots;
//return 50;
}
public void reset() {
mapData = createMap(csvData);
}
} }

View File

@ -43,7 +43,7 @@ public enum TileType {
TILE_35(35, true, SpriteLocation.MAP, 3, 1, false ,0), TILE_35(35, true, SpriteLocation.MAP, 3, 1, false ,0),
TILE_36(36, true, SpriteLocation.MAP, 3, 2, false ,0), TILE_36(36, true, SpriteLocation.MAP, 3, 2, false ,0),
TILE_37(37, true, SpriteLocation.MAP, 3, 3, false ,0), TILE_37(37, true, SpriteLocation.MAP, 3, 3, false ,0),
TILE_38(38, true, SpriteLocation.MAP, 3, 4, false ,0), DOOR(38, true, SpriteLocation.MAP, 3, 4, false, 0),
TILE_39(39, true, SpriteLocation.MAP, 3, 5, false ,0), TILE_39(39, true, SpriteLocation.MAP, 3, 5, false ,0),
TILE_40(40, true, SpriteLocation.MAP, 3, 6, false ,0), TILE_40(40, true, SpriteLocation.MAP, 3, 6, false ,0),
TILE_41(41, true, SpriteLocation.MAP, 3, 7, false ,0), TILE_41(41, true, SpriteLocation.MAP, 3, 7, false ,0),
@ -62,6 +62,7 @@ public enum TileType {
TILE_54(54, true, SpriteLocation.MAP, 4, 9, false ,0), TILE_54(54, true, SpriteLocation.MAP, 4, 9, false ,0),
TILE_55(55, true, SpriteLocation.MAP, 4, 10, false ,0), TILE_55(55, true, SpriteLocation.MAP, 4, 10, false ,0),
LARGE_PELLET(56, false, SpriteLocation.ITEM, 1 ,1,true, 50), LARGE_PELLET(56, false, SpriteLocation.ITEM, 1 ,1,true, 50),
TUNNEL(57, false, SpriteLocation.ITEM, 1, 0, true, 10),
EMPTY(99, false, SpriteLocation.NONE, 0, 0, false, 0); // No sprite associated with empty tiles EMPTY(99, false, SpriteLocation.NONE, 0, 0, false, 0); // No sprite associated with empty tiles
private final int value; private final int value;

View File

@ -0,0 +1,5 @@
package se.urmo.game.sound;
public enum SoundEffect {
START, SIREN, MUNCH1, MUNCH2, FRUIT, GHOST_EATEN, EXTRA_LIFE, DEATH
}

View File

@ -0,0 +1,62 @@
package se.urmo.game.sound;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import java.util.EnumMap;
import java.util.Map;
public class SoundManager {
private final Map<SoundEffect, Clip> clips = new EnumMap<>(SoundEffect.class);
private SoundEffect lastMunch = SoundEffect.MUNCH1;
public SoundManager() {
load(SoundEffect.START, "/sounds/start.wav");
load(SoundEffect.SIREN, "/sounds/siren0.wav");
load(SoundEffect.MUNCH1, "/sounds/eat_dot_0_fast.wav");
load(SoundEffect.MUNCH2, "/sounds/eat_dot_1_fast.wav");
load(SoundEffect.FRUIT, "/sounds/eat_fruit.wav");
load(SoundEffect.GHOST_EATEN, "/sounds/eat_ghost.wav");
load(SoundEffect.EXTRA_LIFE, "/sounds/extend.wav");
load(SoundEffect.DEATH, "/sounds/death_0.wav");
}
private void load(SoundEffect id, String path) {
try (AudioInputStream ais = AudioSystem.getAudioInputStream(getClass().getResource(path))) {
Clip clip = AudioSystem.getClip();
clip.open(ais);
clips.put(id, clip);
} catch (Exception e) {
throw new RuntimeException("Failed to load sound: " + path, e);
}
}
public void play(SoundEffect id) {
Clip clip = clips.get(id);
if (clip == null) return;
if (clip.isRunning()) clip.stop();
clip.setFramePosition(0);
clip.start();
}
public void loop(SoundEffect id) {
Clip clip = clips.get(id);
if (clip == null) return;
if (!clip.isRunning()) {
clip.setFramePosition(0);
clip.loop(Clip.LOOP_CONTINUOUSLY);
}
}
public void stop(SoundEffect id) {
Clip clip = clips.get(id);
if (clip != null && clip.isRunning()) clip.stop();
}
// For dot munch alternation
public void playMunch() {
lastMunch = (lastMunch == SoundEffect.MUNCH1 ? SoundEffect.MUNCH2 : SoundEffect.MUNCH1);
play(lastMunch);
}
}

View File

@ -0,0 +1,99 @@
package se.urmo.game.state;
import lombok.Getter;
import se.urmo.game.main.GamePanel;
import se.urmo.game.main.GameStateManager;
import se.urmo.game.main.HighScoreManager;
import se.urmo.game.util.GameFonts;
import se.urmo.game.util.GameStateType;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.event.KeyEvent;
public class GameOverState implements GameState {
private final GameStateManager gsm;
private int finalScore = 0;
private int finalLevel = 0;
@Getter
private final HighScoreManager highScores; // optional
private boolean saved = false;
private long startMs = System.currentTimeMillis();
public GameOverState(GameStateManager gsm, HighScoreManager highScores) {
this.gsm = gsm;
this.highScores = highScores;
}
@Override
public void update() {
if (!saved) {
if (highScores != null) highScores.submit(finalScore);
saved = true;
}
// could auto-return to menu after N seconds if you like
}
@Override
public void render(Graphics2D g) {
// Clear / dim background
g.setColor(new Color(0,0,0,200));
g.fillRect(0,0, GamePanel.SCREEN_WIDTH, GamePanel.SCREEN_HEIGHT);
// Title
g.setColor(Color.RED);
g.setFont(GameFonts.arcade(32f));
drawCentered(g, "GAME OVER", GamePanel.SCREEN_HEIGHT/2 - 40);
// Stats
g.setColor(Color.WHITE);
g.setFont(GameFonts.arcade(18f));
drawCentered(g, "SCORE: " + finalScore, GamePanel.SCREEN_HEIGHT/2);
drawCentered(g, "LEVEL: " + finalLevel, GamePanel.SCREEN_HEIGHT/2 + 24);
// Prompt
g.setFont(GameFonts.arcade(12f));
drawCentered(g, "Press..", GamePanel.SCREEN_HEIGHT/2 + 64);
drawCentered(g, "ENTER to restart • ESC for menu", GamePanel.SCREEN_HEIGHT/2 + 78);
}
private void drawCentered(Graphics2D g, String text, int y) {
var fm = g.getFontMetrics();
int x = (GamePanel.SCREEN_WIDTH - fm.stringWidth(text)) / 2;
g.drawString(text, x, y);
}
@Override
public void keyPressed(KeyEvent e) {
switch (e.getKeyCode()) {
case KeyEvent.VK_ENTER -> restartNewGame();
case KeyEvent.VK_ESCAPE -> goToMenu();
}
}
@Override
public void keyReleased(KeyEvent e) {
}
private void restartNewGame() {
// Build a fresh PlayingState with everything reset
gsm.setState(GameStateType.PLAYING);
}
private void goToMenu() {
// MenuState menu = new MenuState(gsm);
// gsm.register(StateId.MENU, menu);
// gsm.set(StateId.MENU);
}
public void setScore(int score) {
this.finalScore = score;
}
public void setLevel(int level) {
this.finalLevel = level;
}
}

View File

@ -1,33 +0,0 @@
package se.urmo.game.state;
import lombok.Getter;
import se.urmo.game.main.Game;
import java.awt.*;
import java.util.HashMap;
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) {
this.game = game;
states.put(GameStateType.PLAYING, new PlayingState(game, this));
setState(GameStateType.PLAYING);
}
private void setState(GameStateType type) {
currentState = states.get(type);
}
public void update() {
currentState.update();
}
public void render(Graphics2D g) {
currentState.render(g);
}
}

View File

@ -1,5 +0,0 @@
package se.urmo.game.state;
public enum GameStateType {
PLAYING,
}

View File

@ -1,8 +0,0 @@
package se.urmo.game.state;
import lombok.Getter;
public class LevelManager {
@Getter
private int level = 1;
}

View File

@ -4,52 +4,137 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.CollisionChecker; import se.urmo.game.collision.CollisionChecker;
import se.urmo.game.collision.GhostCollisionChecker; import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.Ghost; import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.GhostMode; import se.urmo.game.entities.ghost.mode.GhostMode;
import se.urmo.game.entities.PacMan; import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.main.Game; import se.urmo.game.main.AnimationManager;
import se.urmo.game.main.FruitManager;
import se.urmo.game.main.GameStateManager;
import se.urmo.game.main.GhostManager;
import se.urmo.game.main.LevelManager;
import se.urmo.game.main.ScorePopupManager;
import se.urmo.game.map.GameMap; import se.urmo.game.map.GameMap;
import se.urmo.game.map.MapTile; import se.urmo.game.map.MapTile;
import se.urmo.game.map.TileType; import se.urmo.game.map.TileType;
import se.urmo.game.sound.SoundEffect;
import se.urmo.game.sound.SoundManager;
import se.urmo.game.util.Direction; import se.urmo.game.util.Direction;
import se.urmo.game.util.GameFonts;
import se.urmo.game.util.GameStateType;
import java.awt.*; import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.io.InputStream;
@Slf4j @Slf4j
public class PlayingState implements GameState { public class PlayingState implements GameState {
private final Game game;
private final GameStateManager gameStateManager; private final GameStateManager gameStateManager;
private final GameOverState gameOverState;
// Core components
private final GhostManager ghostManager; private final GhostManager ghostManager;
private final Font arcadeFont;
private final FruitManager fruitManager; private final FruitManager fruitManager;
private final LevelManager levelManager; private final LevelManager levelManager;
private PacMan pacman; private final AnimationManager animationManager;
private final PacMan pacman;
@Getter @Getter
private GameMap map; private final GameMap map;
private int score;
private int lives = 3;
public PlayingState(Game game, GameStateManager gameStateManager) { // Durations (tune to taste)
this.game = game; private static final int READY_MS = 3000;
private static final int LEVEL_COMPLETE_MS = 1500;
private static final int LIFE_LOST_MS = 2000;
// Score/Lives
private int score = 0;
private int lives = 3;
private int dotsEaten = 0;
// Phase + timers
private RoundPhase phase = RoundPhase.READY;
private long phaseStartMs = System.currentTimeMillis();
private boolean deathInProgress;
private final ScorePopupManager scorePopups = new ScorePopupManager();
private final Font scorePopupFont = GameFonts.arcade(16F);
private final SoundManager sound = new SoundManager();
public PlayingState(GameStateManager gameStateManager, GameOverState gameOverState) {
this.gameStateManager = gameStateManager; this.gameStateManager = gameStateManager;
this.gameOverState = gameOverState;
this.map = new GameMap("maps/map1.csv"); this.map = new GameMap("maps/map1.csv");
this.pacman = new PacMan(game, new CollisionChecker(map)); this.animationManager = new AnimationManager();
this.ghostManager = new GhostManager(new GhostCollisionChecker(map));
this.levelManager = new LevelManager(); this.levelManager = new LevelManager();
this.pacman = new PacMan(new CollisionChecker(map), levelManager);
this.animationManager.register(pacman);
this.ghostManager = new GhostManager(new GhostCollisionChecker(map), animationManager, levelManager);
this.fruitManager = new FruitManager(levelManager); this.fruitManager = new FruitManager(levelManager);
this.arcadeFont = loadArcadeFont(); sound.play(SoundEffect.START);
} }
@Override @Override
public void update() { public void update() {
switch (phase) {
case READY -> {
// Freeze everything during READY
if (phaseElapsed() >= READY_MS) {
ghostManager.setMode(GhostMode.CHASE);
setPhase(RoundPhase.PLAYING);
}
}
case PLAYING -> {
sound.loop(SoundEffect.SIREN);
animationManager.updateAll();
pacman.update(); pacman.update();
ghostManager.update(pacman, map); ghostManager.update(pacman, map);
fruitManager.update(pacman, this); fruitManager.update(pacman, this);
checkCollisions(); checkCollisions();
handleDots(); handleDots();
} }
case LEVEL_COMPLETE -> {
// Freeze, then advance level
if (phaseElapsed() >= LEVEL_COMPLETE_MS) {
advanceLevel();
setPhase(RoundPhase.READY);
}
}
case LIFE_LOST -> {
// Freeze, then reset round (keep dot state)
if (phaseElapsed() >= LIFE_LOST_MS) {
pacman.setState(PacMan.PacmanState.ALIVE);
deathInProgress = false;
ghostManager.getGhosts().forEach(ghost -> ghost.requestModeChange(GhostMode.CHASE));
setPhase(RoundPhase.READY);
if (lives <= 0) {
endGame();
}
}
pacman.update();
}
}
scorePopups.update();
}
private void advanceLevel() {
levelManager.nextLevel();
map.reset();
ghostManager.reset();
fruitManager.reset();
pacman.setState(PacMan.PacmanState.ALIVE);
dotsEaten = 0;
}
private void setPhase(RoundPhase next) {
phase = next;
phaseStartMs = System.currentTimeMillis();
}
private long phaseElapsed() {
return System.currentTimeMillis() - phaseStartMs;
}
private void handleDots() { private void handleDots() {
Point pacmanScreenPos = pacman.getPosition(); Point pacmanScreenPos = pacman.getPosition();
@ -59,28 +144,56 @@ public class PlayingState implements GameState {
ghostManager.setFrightMode(); ghostManager.setFrightMode();
} }
if (wasRemoved) { if (wasRemoved) {
fruitManager.dotEaten(); sound.playMunch();
dotsEaten++;
fruitManager.dotEaten(dotsEaten);
score += tile.getTileType().getScore(); score += tile.getTileType().getScore();
if (dotsEaten == map.numberOfDots()) {
setPhase(RoundPhase.LEVEL_COMPLETE);
}
} }
} }
@Override @Override
public void render(Graphics2D g) { public void render(Graphics2D g) {
map.draw(g); map.draw(g);
pacman.draw(g);
ghostManager.draw(g); ghostManager.draw(g);
pacman.draw(g);
fruitManager.draw(g); fruitManager.draw(g);
drawUI(g); drawUI(g);
scorePopups.draw(g, GameMap.OFFSET_X, GameMap.OFFSET_Y, scorePopupFont);
// Phase overlays
switch (phase) {
case READY -> drawCenterText(g, "READY!");
case LEVEL_COMPLETE ->
drawCenterText(g, "LEVEL " + levelManager.getCurrentLevel().getLevel() + " COMPLETE!");
case LIFE_LOST -> drawCenterText(g, "LIFE LOST");
default -> { /* no overlay */ }
}
}
private void drawCenterText(Graphics2D g, String text) {
g.setFont(GameFonts.arcade(18F));
g.setColor(Color.YELLOW);
var fm = g.getFontMetrics();
int cx = GameMap.OFFSET_X + map.columns() * GameMap.MAP_TILESIZE / 2;
int cy = GameMap.OFFSET_Y + map.rows() * GameMap.MAP_TILESIZE / 2;
g.drawString(text, cx - fm.stringWidth(text) / 2, cy);
} }
private void drawUI(Graphics2D g) { private void drawUI(Graphics2D g) {
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setFont(arcadeFont); g.setFont(GameFonts.arcade(18F));
// Score (above map, left) // Score (above map, left)
g.drawString("Your Score", 48, 48); g.drawString("Your Score", 48, 48);
g.drawString("" + score, 48, 72); g.drawString("" + score, 48, 72);
// High Score (above map, right)
g.drawString("High Score", 248, 48);
g.drawString("" + gameOverState.getHighScores().top().stream().findFirst().orElse(0), 248, 72);
// Lives (below map, left) // Lives (below map, left)
for (int i = 1; i < lives; i++) { for (int i = 1; i < lives; i++) {
g.drawImage(pacman.getLifeIcon(), g.drawImage(pacman.getLifeIcon(),
@ -92,6 +205,7 @@ public class PlayingState implements GameState {
@Override @Override
public void keyPressed(KeyEvent e) { public void keyPressed(KeyEvent e) {
if (phase != RoundPhase.PLAYING) return;
switch (e.getKeyCode()) { switch (e.getKeyCode()) {
case KeyEvent.VK_W -> move(Direction.UP); case KeyEvent.VK_W -> move(Direction.UP);
case KeyEvent.VK_S -> move(Direction.DOWN); case KeyEvent.VK_S -> move(Direction.DOWN);
@ -112,32 +226,48 @@ public class PlayingState implements GameState {
private void checkCollisions() { private void checkCollisions() {
for (Ghost ghost : ghostManager.getGhosts()) { for (Ghost ghost : ghostManager.getGhosts()) {
double dist = pacman.distanceTo(ghost.getPosition()); if (deathInProgress) return; // guard
if (dist < GameMap.MAP_TILESIZE / 2.0) { if (overlaps(pacman, ghost)) {
//double dist = pacman.distanceTo(ghost.getPosition().asPoint());
//if (dist < GameMap.MAP_TILESIZE / 2.0) {
if (ghost.isEaten()) return;
if (ghost.isFrightened()) { if (ghost.isFrightened()) {
// Pac-Man eats ghost log.debug("Pacman eats ghost");
score += 200; sound.play(SoundEffect.GHOST_EATEN);
ghost.resetPosition(); int pts = 200 * (1 << (ghostManager.getGhosts().size() - ghostManager.isFrightened()));
ghost.setMode(GhostMode.CHASE); // end frightend score += pts;
scorePopups.spawn(pts);
ghost.setMode(GhostMode.EATEN);
} else { } else {
log.debug("Pacman loses a life");
sound.play(SoundEffect.DEATH);
ghostManager.setFrozen(true);
pacman.setState(PacMan.PacmanState.DYING);
deathInProgress = true;
// Pac-Man loses a life // Pac-Man loses a life
lives--; lives--;
if(lives == 0)System.exit(1); setPhase(RoundPhase.LIFE_LOST);
else pacman.resetPosition();
} }
} }
} }
} }
private Font loadArcadeFont() { private boolean overlaps(PacMan p, Ghost g) {
try (InputStream is = getClass().getResourceAsStream("/fonts/PressStart2P-Regular.ttf")) { // center-distance or AABB; center distance keeps the arcade feel
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(16f); double dx = p.getPosition().x - g.getPosition().x;
} catch (Exception e) { double dy = p.getPosition().y - g.getPosition().y;
return new Font("Monospaced", Font.BOLD, 16); double r = GameMap.MAP_TILESIZE * 1.0; // tune threshold
return (dx * dx + dy * dy) <= r * r;
} }
private void endGame() {
gameOverState.setScore(score);
gameOverState.setLevel(levelManager.getCurrentLevel().getLevel());
gameStateManager.setState(GameStateType.GAME_OVER);
} }
public void setScore(int score) { public void setScore(int score) {
scorePopups.spawn(score);
this.score += score; this.score += score;
} }
} }

View File

@ -0,0 +1,8 @@
package se.urmo.game.state;
public enum RoundPhase {
READY, // "READY!" shown briefly before play starts
PLAYING, // normal gameplay
LEVEL_COMPLETE, // all dots eaten; freeze briefly then advance level
LIFE_LOST // Pac-Man hit; freeze briefly then reset positions}
}

View File

@ -1,18 +1,20 @@
package se.urmo.game.util; package se.urmo.game.util;
public enum Direction { public enum Direction {
RIGHT(1, 0), RIGHT(1, 0, 0),
LEFT(-1, 0), LEFT(-1, 0 , 180),
DOWN(0, 1), DOWN(0, 1, 90),
UP(0, -1), UP(0, -1, 270),
NONE(0, 0); NONE(0, 0, 0);
public final int dx; public final int dx;
public final int dy; public final int dy;
public final int angel;
Direction(int dx, int dy) { Direction(int dx, int dy, int angel) {
this.dx = dx; this.dx = dx;
this.dy = dy; this.dy = dy;
this.angel = angel;
} }
public Direction opposite() { public Direction opposite() {

View File

@ -1,4 +1,4 @@
package se.urmo.game.state; package se.urmo.game.util;
import lombok.Getter; import lombok.Getter;
import se.urmo.game.graphics.SpriteLocation; import se.urmo.game.graphics.SpriteLocation;
@ -9,7 +9,9 @@ import java.util.Arrays;
@Getter @Getter
public enum FruitType { public enum FruitType {
CHERRY(1, SpriteSheetManager.get(SpriteLocation.ITEM).getSprite(0,0), 100); CHERRY(1, SpriteSheetManager.get(SpriteLocation.ITEM).getSprite(0,0), 100),
CHERRY2(2, SpriteSheetManager.get(SpriteLocation.ITEM).getSprite(0,0), 100),
CHERRY3(3, SpriteSheetManager.get(SpriteLocation.ITEM).getSprite(0,0), 100);
private final int level; private final int level;
private final BufferedImage sprite; private final BufferedImage sprite;

View File

@ -0,0 +1,26 @@
package se.urmo.game.util;
import java.awt.Font;
import java.io.InputStream;
public enum GameFonts {
ARCADE_FONT("/fonts/PressStart2P-Regular.ttf");
private final Font font;
GameFonts(String s) {
font = loadArcadeFont(s);
}
private static Font loadArcadeFont(String name) {
try (InputStream is = GameFonts.class.getResourceAsStream(name)) {
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(16f);
} catch (Exception e) {
return new Font("Monospaced", Font.BOLD, 16);
}
}
public static Font arcade(float textSize) {
return ARCADE_FONT.font.deriveFont(textSize);
}
}

View File

@ -0,0 +1,5 @@
package se.urmo.game.util;
public enum GameStateType {
PLAYING, GAME_OVER,
}

View File

@ -1,4 +1,6 @@
package se.urmo.game.entities; package se.urmo.game.util;
import java.awt.Point;
public class MyPoint { public class MyPoint {
public final double x; public final double x;
@ -9,6 +11,10 @@ public class MyPoint {
this.y = y; this.y = y;
} }
public Point asPoint() {
return new Point((int) x, (int) y);
}
@Override @Override
public String toString() { public String toString() {
return "MyPoint{" + return "MyPoint{" +

View File

@ -5,7 +5,14 @@
</encoder> </encoder>
</appender> </appender>
<root level="DEBUG"> <!--root level="DEBUG">
<appender-ref ref="STDOUT" /> <appender-ref ref="STDOUT" />
</root> </root -->
<root level="OFF"/>
<!-- Enable entire package -->
<logger name="se.urmo.myapp" level="DEBUG" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
</configuration> </configuration>

View File

@ -11,7 +11,7 @@
99,99,99,99,99,22, 0,15,17,99,99,99,99,99,99,99,99,99,99,15,17, 0,20,99,99,99,99,99 99,99,99,99,99,22, 0,15,17,99,99,99,99,99,99,99,99,99,99,15,17, 0,20,99,99,99,99,99
99,99,99,99,99,22, 0,15,17,99,34,35,36,37,38,35,35,39,99,15,17, 0,20,99,99,99,99,99 99,99,99,99,99,22, 0,15,17,99,34,35,36,37,38,35,35,39,99,15,17, 0,20,99,99,99,99,99
32,32,32,32,32,33, 0,26,28,99,40,99,99,99,99,99,99,41,99,26,28, 0,31,32,32,32,32,32 32,32,32,32,32,33, 0,26,28,99,40,99,99,99,99,99,99,41,99,26,28, 0,31,32,32,32,32,32
0, 0, 0, 0, 0, 0, 0, 0, 0,99,40,99,99,99,99,99,99,41,99, 0, 0, 0, 0, 0, 0, 0, 0, 0 57,57,57,57,57,57, 0, 0, 0,99,40,99,99,99,99,99,99,41,99, 0, 0,57,57,57,57,57,57,57
10,10,10,10,10,11, 0, 4, 6,99,40,99,99,99,99,99,99,41,99, 4, 6, 0, 9,10,10,10,10,10 10,10,10,10,10,11, 0, 4, 6,99,40,99,99,99,99,99,99,41,99, 4, 6, 0, 9,10,10,10,10,10
99,99,99,99,99,22, 0,15,17,99,42,43,43,43,43,43,43,44,99,15,17, 0,20,99,99,99,99,99 99,99,99,99,99,22, 0,15,17,99,42,43,43,43,43,43,43,44,99,15,17, 0,20,99,99,99,99,99
99,99,99,99,99,22, 0,15,17,99,99,99,99,99,99,99,99,99,99,15,17, 0,20,99,99,99,99,99 99,99,99,99,99,22, 0,15,17,99,99,99,99,99,99,99,99,99,99,15,17, 0,20,99,99,99,99,99
@ -25,6 +25,6 @@
49,27,28, 0,26,28, 0,15,17, 0,26,27,27, 7, 8,27,27,28, 0,15,17, 0,26,28, 0,26,27,50 49,27,28, 0,26,28, 0,15,17, 0,26,27,27, 7, 8,27,27,28, 0,15,17, 0,26,28, 0,26,27,50
12, 0, 0, 0, 0, 0, 0,15,17, 0, 0, 0, 0,15,17, 0, 0, 0, 0,15,17, 0, 0, 0, 0, 0, 0,14 12, 0, 0, 0, 0, 0, 0,15,17, 0, 0, 0, 0,15,17, 0, 0, 0, 0,15,17, 0, 0, 0, 0, 0, 0,14
12, 0, 4, 5, 5, 5, 5,18,19, 5, 5, 6, 0,15,17, 0, 4, 5, 5,18,19, 5, 5, 5, 5, 6, 0,14 12, 0, 4, 5, 5, 5, 5,18,19, 5, 5, 6, 0,15,17, 0, 4, 5, 5,18,19, 5, 5, 5, 5, 6, 0,14
12, 0,26,27,27,27,27,27,27,27,27,28, 0,26,28, 0,26,27,27,27,27,27,27,27,27,28, 0,14 12,56,26,27,27,27,27,27,27,27,27,28, 0,26,28, 0,26,27,27,27,27,27,27,27,27,28,56,14
12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,14 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,14
23,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,25 23,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,25
1 1 2 2 2 2 2 2 2 2 2 2 2 2 45 46 2 2 2 2 2 2 2 2 2 2 2 2 3
11 99 99 99 99 99 22 0 15 17 99 99 99 99 99 99 99 99 99 99 15 17 0 20 99 99 99 99 99
12 99 99 99 99 99 22 0 15 17 99 34 35 36 37 38 35 35 39 99 15 17 0 20 99 99 99 99 99
13 32 32 32 32 32 33 0 26 28 99 40 99 99 99 99 99 99 41 99 26 28 0 31 32 32 32 32 32
14 0 57 0 57 0 57 0 57 0 57 0 57 0 0 0 99 40 99 99 99 99 99 99 41 99 0 0 0 57 0 57 0 57 0 57 0 57 0 57 0 57
15 10 10 10 10 10 11 0 4 6 99 40 99 99 99 99 99 99 41 99 4 6 0 9 10 10 10 10 10
16 99 99 99 99 99 22 0 15 17 99 42 43 43 43 43 43 43 44 99 15 17 0 20 99 99 99 99 99
17 99 99 99 99 99 22 0 15 17 99 99 99 99 99 99 99 99 99 99 15 17 0 20 99 99 99 99 99
25 49 27 28 0 26 28 0 15 17 0 26 27 27 7 8 27 27 28 0 15 17 0 26 28 0 26 27 50
26 12 0 0 0 0 0 0 15 17 0 0 0 0 15 17 0 0 0 0 15 17 0 0 0 0 0 0 14
27 12 0 4 5 5 5 5 18 19 5 5 6 0 15 17 0 4 5 5 18 19 5 5 5 5 6 0 14
28 12 0 56 26 27 27 27 27 27 27 27 27 28 0 26 28 0 26 27 27 27 27 27 27 27 27 28 0 56 14
29 12 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 14
30 23 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 24 25

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.