Compare commits

..

30 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
72 changed files with 1150 additions and 444 deletions

View File

@ -2,105 +2,93 @@ package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.util.MyPoint;
import se.urmo.game.main.GamePanel;
import se.urmo.game.util.Direction;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.util.Collections;
import java.util.List;
@Slf4j
public class CollisionChecker {
private GameMap map;
private final GameMap map;
public CollisionChecker(GameMap map) {
this.map = map;
}
// public Point getValidDestination(Direction direction, Point position, int agent_width, int agent_height) {
// List<Point> boundaries = switch (direction) {
// 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
// }
public MyPoint getValidDestination(Direction direction, MyPoint position, int agentWidth, int agentHeight) {
List<MyPoint> boundaries = getBoundariesForDirection(direction, position, agentWidth / 2, agentHeight / 2);
// /**
// * Applies specific rules to movement
// * This, for instance, makes sure the tunnel left/right works.
// *
// * @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);
// }
return canMoveInDirection(agentWidth, boundaries)
? normalizePosition(position, agentWidth)
: null; // Blocked
}
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 RIGHT, LEFT -> List.of(
new MyPoint(position.x + (direction.dx * agent_width/2), position.y - agent_height/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),
new MyPoint(position.x + ((double) (direction.dx * horizontalOffset) / 2), position.y + (double) verticalOffset / 2)
);
case UP, DOWN -> List.of(
new MyPoint(position.x - agent_width/2, position.y + (direction.dy * agent_height/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)),
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;
double y1 = y;
int width = GamePanel.SCREEN_WIDTH;
int height = GamePanel.SCREEN_HEIGHT;
// tunnel
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
return new MyPoint(x1, y1);
/**
* Normalizes the position of an agent when it reaches screen boundaries,
* implementing tunnel-like behavior when crossing horizontal boundaries.
*
* @param position The current position to normalize
* @param agentWidth The width of the agent
* @return Normalized position as MyPoint
*/
private MyPoint normalizePosition(MyPoint position, int agentWidth) {
if (isLeftBoundary(position.x)) {
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,9 +1,10 @@
package se.urmo.game.collision;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.util.MyPoint;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.map.GameMap;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MyPoint;
import java.util.List;
@ -15,19 +16,16 @@ public class GhostCollisionChecker {
this.map = map;
}
public List<Direction> calculateDirectionAlternatives(MyPoint position) {
List<Direction> intersection = map.directionAlternatives((int) position.x, (int) position.y);
public List<Direction> calculateDirectionAlternatives(Ghost ghost, MyPoint position) {
List<Direction> intersection = map.directionAlternatives(ghost, (int) position.x, (int) position.y);
log.info("Possible travel directions: {}", intersection);
return intersection;
}
public MyPoint canMoveTo(Direction dir, double x, double y) {
public MyPoint canMoveTo(Ghost ghost, Direction dir, double x, double y) {
// -1 is because else we endup in next tile
//Point pp = new Point((int) (x + dir.dx * (GameMap.MAP_TILESIZE/2 - 1)), (int) (y + dir.dy * (GameMap.MAP_TILESIZE/2 -1)));
return ! map.isSolidXY(
return !map.isSolidXY(ghost,
(int) (x) + dir.dx * (GameMap.MAP_TILESIZE/2 - 1),
(int) (y) + dir.dy * (GameMap.MAP_TILESIZE/2 - 1)) ? new MyPoint(x,y) : null;
}

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

@ -4,7 +4,7 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.pacman.PacMan;
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.Point;

View File

@ -1,226 +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.strategy.FearStrategy;
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.graphics.SpriteLocation;
import se.urmo.game.graphics.SpriteSheetManager;
import se.urmo.game.main.Game;
import se.urmo.game.main.GhostManager;
import se.urmo.game.main.LevelManager;
import se.urmo.game.map.GameMap;
import se.urmo.game.state.GhostManager;
import se.urmo.game.state.LevelManager;
import se.urmo.game.util.Direction;
import se.urmo.game.util.MiscUtil;
import se.urmo.game.util.MyPoint;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.util.List;
import java.util.EnumMap;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
public class Ghost extends BaseAnimated {
private static final double BASE_SPEED = 0.40;
private static final int WARNING_THRESHOLD = 180; // 3 seconds of warning
public static final double BASE_SPEED = 0.40;
public static final int GHOST_SIZE = 32;
private static final int ANIMATION_UPDATE_FREQUENCY = 25;
private static final int FRIGHTENED_DURATION_TICKS = 10 * Game.UPS_SET;
@Getter
private final GhostCollisionChecker collisionChecker;
private final GhostStrategy chaseStrategy;
private final MyPoint startPos;
private final BufferedImage[] fearAnimation;
private final BufferedImage[] baseAnimation;
@Getter
private final int animation;
@Getter
private final LevelManager levelManager;
private MyPoint position;
@Getter
private final GhostStrategy scaterStrategy;
private GhostStrategy currentStrategy;
private BufferedImage[] animation;
private Direction direction;
private Direction prevDirection;
private GhostMode mode;
private final GhostStrategy fearStrategy = new FearStrategy();
private int frightenedTimer = 0;
private boolean isBlinking = false;
private GhostState currentState;
private final Map<GhostMode, GhostState> states = new EnumMap<>(GhostMode.class);
public Ghost(GhostCollisionChecker collisionChecker, GhostStrategy strategy, GhostStrategy scaterStrategy, int animation, LevelManager levelManager) {
// 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.chaseStrategy = strategy;
this.scaterStrategy = scaterStrategy;
this.baseAnimation = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(animation);
this.fearAnimation = SpriteSheetManager.get(SpriteLocation.GHOST).getAnimation(8);
position = new MyPoint(
13 * GameMap.MAP_TILESIZE + GameMap.OFFSET_X + ((double) GameMap.MAP_TILESIZE / 2),
4 * GameMap.MAP_TILESIZE + GameMap.OFFSET_Y + ((double) GameMap.MAP_TILESIZE / 2));
startPos = position;
this.currentStrategy = chaseStrategy;
this.animation = baseAnimation;
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(
animation[aniIndex],
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) {
//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) {
if (map.isAligned(new Point((int) position.x, (int) position.y))) {
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());
}
/**
* Given a position and a direction - calculate the new position
* Moves one pixel in the given direction
*
* @return new position
*/
private MyPoint getNewPosition() {
return new MyPoint(
position.x + direction.dx * getSpeed(),
position.y + direction.dy * getSpeed());
}
private double getSpeed() {
return BASE_SPEED * levelManager.getGhostSpeed();
}
private void moveTo(MyPoint newPosition) {
MyPoint destination = collisionChecker.canMoveTo(direction, newPosition.x, newPosition.y);
if (destination != null) {
position = destination;
}
}
/**
* Creates a map of directions and their associated priority values based on the given list of directions.
* Directions with a value of {@code Direction.NONE} are excluded. If a direction is opposite to the
* previous direction, it is given a higher priority value.
*
* @param directions a list of potential movement directions
* @return a map where keys are valid directions and values are their associated priority
*/
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
));
}
/**
* Selects the best movement direction for the ghost based on priority and distance to the target.
* The method evaluates the directions with the lowest priority value and chooses the one that
* minimizes the distance to the target point.
*
* @param options a map where keys represent potential movement directions and values
* represent their associated priority levels
* @param target the target point towards which the direction is evaluated
* @return the direction that has the lowest priority and minimizes the distance to the target
*/
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);
// Collect all directions that have this priority
List<Direction> directions = options.entrySet().stream()
.filter(entry -> entry.getValue() == lowestPriority)
.map(Map.Entry::getKey)
.toList();
// Calculate the direction that has the lowest distance to the target
Direction best = directions.getFirst();
double bestDist = Double.MAX_VALUE;
for (Direction d : directions) {
double nx = position.x + d.dx * GameMap.MAP_TILESIZE;
double ny = position.y + d.dy * GameMap.MAP_TILESIZE;
double dist = target.distance(nx, ny);
if (dist < bestDist) {
bestDist = dist;
best = d;
}
}
return best;
currentState.update(this, pacman, map);
}
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;
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 mode == GhostMode.FRIGHTENED;
return states.get(GhostMode.FRIGHTENED) == currentState;
}
public void resetPosition() {
position = startPos;
public boolean isEaten() {
return states.get(GhostMode.EATEN) == currentState;
}
public Point getPosition() {
return new Point((int) position.x, (int) position.y);
public GhostMode currentMode() {
return states.entrySet().stream()
.filter(s -> s.getValue() == currentState)
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
public void resetModes() {
mode = GhostMode.CHASE;
public void reset() {
currentState = states.get(GhostMode.CHASE);
((ChaseGhostMode) currentState).resetPosition();
}
}

View File

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

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

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

View File

@ -5,15 +5,21 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.CollisionChecker;
import se.urmo.game.entities.BaseAnimated;
import se.urmo.game.state.LevelManager;
import se.urmo.game.util.Direction;
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.*;
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
@ -22,74 +28,112 @@ public class PacMan extends BaseAnimated {
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 MyPoint startPosition;
private static final int ANIMATION_UPDATE_FREQUENCY = 10;
private static final double BASE_SPEED = 0.40;
private boolean moving = false;
private final BufferedImage[][] spriteSheets;
private MyPoint position;
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 double pacmanLevelSpeed;
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, 4);
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.spriteSheets = loadAnimation();
this.pacmanLevelSpeed = this.levelManager.getPacmanLevelSpeed();
this.sprites = loadAnimation();
this.speed = BASE_SPEED * levelManager.getPacmanLevelSpeed();
}
private BufferedImage[][] loadAnimation() {
BufferedImage[][] image = new BufferedImage[3][4];
BufferedImage[][] spriteMap = new BufferedImage[4][4];;
private Sprites loadAnimation() {
BufferedImage[][] spriteMap = new BufferedImage[6][PACMAN_SPRITE_FRAMES];
BufferedImage[] deathFrames;
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);
}
}
spriteMap[Direction.RIGHT.ordinal()] = image[0];
spriteMap[Direction.LEFT.ordinal()] = Arrays.stream(image[0])
.map(i -> LoadSave.rotate(i, 180))
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(image[0])
.map(i -> LoadSave.rotate(i, 90))
spriteMap[Direction.DOWN.ordinal()] = Arrays.stream(animation)
.map(i -> LoadSave.rotate(i, Direction.DOWN.angel))
.toArray(BufferedImage[]::new);
spriteMap[Direction.UP.ordinal()] = Arrays.stream(image[0])
.map(i -> LoadSave.rotate(i, 270))
spriteMap[Direction.UP.ordinal()] = Arrays.stream(animation)
.map(i -> LoadSave.rotate(i, Direction.UP.angel))
.toArray(BufferedImage[]::new);
return spriteMap;
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(
spriteSheets[direction==Direction.NONE ? 0 : direction.ordinal()][aniIndex],
sprites.spriteSheets[direction == Direction.NONE ? 0 : direction.ordinal()][aniIndex],
(int) position.x - PACMAN_OFFSET,
(int) position.y - PACMAN_OFFSET,
PACMAN_SIZE,
PACMAN_SIZE, null);
}
public void update() {
if(moving) {
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);
};
private void drawDead(Graphics g) {
if (state == PacmanState.ALIVE) return; // ignore if not dying/dead
MyPoint destination = collisionChecker.getValidDestination(direction, mpoint, COLLISION_BOX_SIZE, COLLISION_BOX_SIZE);
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;
@ -97,25 +141,39 @@ public class PacMan extends BaseAnimated {
}
}
private MyPoint getNewPosition() {
return new MyPoint(position.x + direction.dx * getSpeed(), position.y + direction.dy * getSpeed());
}
private double getSpeed() {
return BASE_SPEED * pacmanLevelSpeed;
return this.speed;
}
public double distanceTo(Point point) {
return new Point((int) position.x, (int) position.y).distance(point);
}
public void resetPosition() {
public void setState(PacmanState state) {
this.state = state;
switch (state) {
case ALIVE -> {
position = startPosition;
}
public void reset() {
resetPosition();
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 spriteSheets[0][1];
return sprites.spriteSheets[0][1];
}
public Rectangle getBounds() {
@ -134,4 +192,11 @@ public class PacMan extends BaseAnimated {
moving = b;
paused = !b;
}
public enum PacmanState {
ALIVE, DYING, DEAD
}
record Sprites(BufferedImage[][] spriteSheets, BufferedImage[] deathFrames) {
}
}

View File

@ -2,6 +2,7 @@ package se.urmo.game.graphics;
import lombok.Getter;
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.LoadSave;
@ -12,6 +13,7 @@ public enum SpriteLocation {
MAP("sprites/PacMan-custom-spritemap-0-3.png", 5, 11, GameMap.MAP_TILESIZE),
ITEM("sprites/PacManAssets-Items.png", 2, 8, GameMap.MAP_TILESIZE),
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
@Override
public BufferedImage[][] loadSprites(int tileSize) {

View File

@ -1,7 +1,5 @@
package se.urmo.game.graphics;
import se.urmo.game.map.GameMap;
import java.util.EnumMap;
import java.util.Map;
@ -11,11 +9,6 @@ public class SpriteSheetManager {
public static SpriteSheet get(SpriteLocation location) {
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;
import se.urmo.game.state.GameStateManager;
import se.urmo.game.main.GameStateManager;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

View File

@ -1,4 +1,4 @@
package se.urmo.game.state;
package se.urmo.game.main;
import se.urmo.game.entities.Animated;

View File

@ -1,8 +1,10 @@
package se.urmo.game.state;
package se.urmo.game.main;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.entities.collectibles.Fruit;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.state.PlayingState;
import se.urmo.game.util.FruitType;
import java.awt.Graphics;

View File

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

View File

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

View File

@ -1,24 +1,25 @@
package se.urmo.game.state;
package se.urmo.game.main;
import lombok.Getter;
import se.urmo.game.main.Game;
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.*;
import java.awt.Graphics2D;
import java.util.HashMap;
import java.util.Map;
public class GameStateManager {
private final Game game;
private Map<GameStateType, GameState> states = new HashMap<>();
private final Map<GameStateType, GameState> states = new HashMap<>();
@Getter
private GameState currentState;
public GameStateManager(Game game) {
this.game = game;
public GameStateManager() {
GameOverState gameOverState = new GameOverState(this, new HighScoreManager());
states.put(GameStateType.PLAYING, new PlayingState(game, this, gameOverState));
states.put(GameStateType.PLAYING, new PlayingState(this, gameOverState));
states.put(GameStateType.GAME_OVER, gameOverState);
setState(GameStateType.GAME_OVER);
setState(GameStateType.PLAYING);
}
public void setState(GameStateType type) {

View File

@ -1,19 +1,19 @@
package se.urmo.game.state;
package se.urmo.game.main;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.mode.GhostMode;
import se.urmo.game.entities.ghost.strategy.BlinkyStrategy;
import se.urmo.game.entities.ghost.strategy.ClydeStrategy;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.GhostMode;
import se.urmo.game.entities.ghost.strategy.InkyStrategy;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.entities.ghost.strategy.PinkyStrategy;
import se.urmo.game.entities.ghost.strategy.ScatterToBottomLeft;
import se.urmo.game.entities.ghost.strategy.ScatterToBottomRight;
import se.urmo.game.entities.ghost.strategy.ScatterToTopLeft;
import se.urmo.game.entities.ghost.strategy.ScatterToTopRight;
import se.urmo.game.entities.pacman.PacMan;
import se.urmo.game.map.GameMap;
import java.awt.Graphics2D;
@ -29,7 +29,6 @@ public class GhostManager {
public static final int CLYDE_ANIMATION = 3;
@Getter
private final List<Ghost> ghosts = new ArrayList<>();
private final LevelManager levelManager;
private long lastModeSwitchTime;
private int phaseIndex = 0;
@ -41,23 +40,22 @@ public class GhostManager {
5000, 20000, // scatter 5s, chase 20s
5000, Integer.MAX_VALUE // scatter 5s, then chase forever
};
private boolean frozen;
public GhostManager(GhostCollisionChecker ghostCollisionChecker, AnimationManager animationManager, LevelManager levelManager) {
this.levelManager = levelManager;
// Create ghosts with their strategies
Ghost blinky = new Ghost(ghostCollisionChecker, new BlinkyStrategy(), new ScatterToTopRight(), BLINKY_ANIMATION, levelManager);
ghosts.add(blinky);
ghosts.add(new Ghost(ghostCollisionChecker, new PinkyStrategy(), new ScatterToTopLeft(), PINKY_ANIMATION, levelManager));
ghosts.add(new Ghost(ghostCollisionChecker,new InkyStrategy(blinky), new ScatterToBottomRight(), INKY_ANIMATION, levelManager));
ghosts.add(new Ghost(ghostCollisionChecker, new InkyStrategy(blinky), new ScatterToBottomRight(), INKY_ANIMATION, levelManager));
ghosts.add(new Ghost(ghostCollisionChecker, new ClydeStrategy(), new ScatterToBottomLeft(), CLYDE_ANIMATION, levelManager));
ghosts.forEach(animationManager::register);
setMode(GhostMode.CHASE);
setMode(GhostMode.HOUSE);
}
public void setMode(GhostMode mode) {
log.debug("Mode changed to {}", mode);
for (Ghost g : ghosts) {
g.setMode(mode);
}
@ -95,6 +93,14 @@ public class GhostManager {
public void reset() {
phaseIndex = 0;
setMode(GhostMode.SCATTER);
ghosts.forEach(Ghost::resetPosition);
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

@ -1,6 +1,7 @@
package se.urmo.game.state;
package se.urmo.game.main;
import lombok.Getter;
import se.urmo.game.util.FruitType;
public class LevelManager {
@ -11,19 +12,19 @@ public class LevelManager {
@Getter
public enum Level {
LEVEL1(1,6000, 0.75, 0.8, FruitType.CHERRY),
LEVEL2(2,3000, 0.85, 0.85, FruitType.CHERRY),
LEVEL3(3,1000, 0.95, 0.9, FruitType.CHERRY),
LEVEL4(4,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL5(5,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL6(6,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL7(7,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL8(8,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL9(9,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL10(10,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL11(11,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL12(12,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL13(13,6000, 0.75, 0.85, FruitType.CHERRY),
LEVEL14(14,6000, 0.75, 0.85, 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),

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,11 +1,18 @@
package se.urmo.game.map;
import lombok.extern.slf4j.Slf4j;
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 java.awt.*;
import java.awt.Graphics;
import java.awt.Point;
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.Objects;
@ -20,7 +27,7 @@ public class GameMap {
public static final int OFFSET_X = MAP_TILESIZE; // 16px from left
private MapTile[][] mapData;
private final int[][] csvData;
private long numberOfDots;
private final long numberOfDots;
public GameMap(String mapFilePath) {
@ -67,11 +74,6 @@ public class GameMap {
.map(String::trim)
.mapToInt(Integer::parseInt)
.toArray();
// for (int col = 0; col < mapColSize; col++) {
// int value = Integer.parseInt(tokens[col].trim());
// data[row][col] = MapTile.byType(TileType.fromValue(value));
// }
}
if (row != mapRowSize) {
@ -107,26 +109,43 @@ public class GameMap {
int col = screenToCol(screenX);
int tileY = (screenY - OFFSET_Y) % 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 b = mask == null || !mask[tileY][tileX];
log.trace(b ? " - passable" : " - not passable");
//log.trace(b ? " - passable" : " - not passable");
return b;
}
public boolean isSolidXY(int screenX, int screenY) {
return isSolid(screenToRow(screenY), screenToCol(screenX));
public boolean isSolidXY(Ghost ghost, int screenX, int screenY) {
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 (row >= rows() || row < 0) return true;
// Get the tile information
MapTile mapTile = mapData[row][col];
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;
}
public boolean removeTileImage(Point screen) {
@ -145,7 +164,7 @@ public class GameMap {
TileType type = tile.getTileType();
if (type.isRemovable() && tile.getImage() != null) {
log.debug("Removing tile {}", tile);
// log.debug("Removing tile {}", tile);
tile.setImage(null);
return true;
}
@ -221,13 +240,13 @@ public class GameMap {
}
public List<Direction> directionAlternatives(int screenX, int screenY) {
public List<Direction> directionAlternatives(BaseAnimated entity, int screenX, int screenY) {
int row = (screenY - GameMap.OFFSET_Y) / GameMap.MAP_TILESIZE;
int col = (screenX - GameMap.OFFSET_X) / GameMap.MAP_TILESIZE;
record DirectionCheck(int rowOffset, int colOffset, Direction direction) {
}
log.debug("At [{}][{}]", row, col);
log.debug("At ({},{}), [{}][{}]", screenX, screenY, row, col);
return Stream.of(
new DirectionCheck(0, 1, Direction.RIGHT),
new DirectionCheck(0, -1, Direction.LEFT),
@ -237,7 +256,7 @@ public class GameMap {
.filter(dc -> {
int r = row + dc.rowOffset;
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");
return !solid;
})
@ -252,8 +271,8 @@ public class GameMap {
}
public long numberOfDots() {
//return this.numberOfDots;
return 50;
return this.numberOfDots;
//return 50;
}
public void reset() {

View File

@ -43,7 +43,7 @@ public enum TileType {
TILE_35(35, true, SpriteLocation.MAP, 3, 1, false ,0),
TILE_36(36, true, SpriteLocation.MAP, 3, 2, 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_40(40, true, SpriteLocation.MAP, 3, 6, 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_55(55, true, SpriteLocation.MAP, 4, 10, false ,0),
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
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

@ -1,7 +1,11 @@
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;
@ -11,6 +15,7 @@ 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;

View File

@ -1,7 +0,0 @@
package se.urmo.game.state;
public class HighScoreManager {
public void submit(int finalScore) {
}
}

View File

@ -5,22 +5,31 @@ import lombok.extern.slf4j.Slf4j;
import se.urmo.game.collision.CollisionChecker;
import se.urmo.game.collision.GhostCollisionChecker;
import se.urmo.game.entities.ghost.Ghost;
import se.urmo.game.entities.ghost.GhostMode;
import se.urmo.game.entities.ghost.mode.GhostMode;
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.MapTile;
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.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;
@Slf4j
public class PlayingState implements GameState {
public static final int REMAINING_LIVES = 0;
private final Game game;
private final GameStateManager gameStateManager;
private final GameOverState gameOverState;
@ -29,12 +38,12 @@ public class PlayingState implements GameState {
private final FruitManager fruitManager;
private final LevelManager levelManager;
private final AnimationManager animationManager;
private PacMan pacman;
private final PacMan pacman;
@Getter
private GameMap map;
private final GameMap map;
// Durations (tune to taste)
private static final int READY_MS = 1500;
private static final int READY_MS = 3000;
private static final int LEVEL_COMPLETE_MS = 1500;
private static final int LIFE_LOST_MS = 2000;
@ -44,12 +53,16 @@ public class PlayingState implements GameState {
private int dotsEaten = 0;
// Phase + timers
private RoundPhase phase = RoundPhase.PLAYING;
private RoundPhase phase = RoundPhase.READY;
private long phaseStartMs = System.currentTimeMillis();
private boolean deathInProgress;
public PlayingState(Game game, GameStateManager gameStateManager, GameOverState gameOverState) {
this.game = game;
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.gameOverState = gameOverState;
this.map = new GameMap("maps/map1.csv");
@ -59,6 +72,7 @@ public class PlayingState implements GameState {
this.animationManager.register(pacman);
this.ghostManager = new GhostManager(new GhostCollisionChecker(map), animationManager, levelManager);
this.fruitManager = new FruitManager(levelManager);
sound.play(SoundEffect.START);
}
@Override
@ -67,10 +81,12 @@ public class PlayingState implements GameState {
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();
ghostManager.update(pacman, map);
@ -88,20 +104,18 @@ public class PlayingState implements GameState {
case LIFE_LOST -> {
// Freeze, then reset round (keep dot state)
if (phaseElapsed() >= LIFE_LOST_MS) {
pacman.setState(PacMan.PacmanState.ALIVE);
deathInProgress = false;
resetAfterLifeLost();
ghostManager.getGhosts().forEach(ghost -> ghost.requestModeChange(GhostMode.CHASE));
setPhase(RoundPhase.READY);
if (lives <= 0) {
endGame();
}
}
pacman.update();
}
}
}
private void resetAfterLifeLost() {
pacman.reset(); // to start tile, direction stopped
ghostManager.reset(); // to house
scorePopups.update();
}
private void advanceLevel() {
@ -109,7 +123,7 @@ public class PlayingState implements GameState {
map.reset();
ghostManager.reset();
fruitManager.reset();
pacman.reset();
pacman.setState(PacMan.PacmanState.ALIVE);
dotsEaten = 0;
}
@ -130,6 +144,7 @@ public class PlayingState implements GameState {
ghostManager.setFrightMode();
}
if (wasRemoved) {
sound.playMunch();
dotsEaten++;
fruitManager.dotEaten(dotsEaten);
score += tile.getTileType().getScore();
@ -142,15 +157,17 @@ public class PlayingState implements GameState {
@Override
public void render(Graphics2D g) {
map.draw(g);
pacman.draw(g);
ghostManager.draw(g);
pacman.draw(g);
fruitManager.draw(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 COMPLETE!");
case LEVEL_COMPLETE ->
drawCenterText(g, "LEVEL " + levelManager.getCurrentLevel().getLevel() + " COMPLETE!");
case LIFE_LOST -> drawCenterText(g, "LIFE LOST");
default -> { /* no overlay */ }
}
@ -173,6 +190,10 @@ public class PlayingState implements GameState {
g.drawString("Your Score", 48, 48);
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)
for (int i = 1; i < lives; i++) {
g.drawImage(pacman.getLifeIcon(),
@ -206,15 +227,22 @@ public class PlayingState implements GameState {
private void checkCollisions() {
for (Ghost ghost : ghostManager.getGhosts()) {
if (deathInProgress) return; // guard
//if(overlap(pacman, ghost)
double dist = pacman.distanceTo(ghost.getPosition());
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()) {
// Pac-Man eats ghost
score += 200;
ghost.resetPosition();
ghost.setMode(GhostMode.CHASE); // end frightend
log.debug("Pacman eats ghost");
sound.play(SoundEffect.GHOST_EATEN);
int pts = 200 * (1 << (ghostManager.getGhosts().size() - ghostManager.isFrightened()));
score += pts;
scorePopups.spawn(pts);
ghost.setMode(GhostMode.EATEN);
} 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
lives--;
@ -224,13 +252,13 @@ public class PlayingState implements GameState {
}
}
// private boolean overlaps(PacMan p, Ghost g) {
// // center-distance or AABB; center distance keeps the arcade feel
// double dx = p.getCenterX() - g.getCenterX();
// double dy = p.getCenterY() - g.getCenterY();
// double r = map.getTileSize() * 0.45; // tune threshold
// return (dx*dx + dy*dy) <= r*r;
// }
private boolean overlaps(PacMan p, Ghost g) {
// center-distance or AABB; center distance keeps the arcade feel
double dx = p.getPosition().x - g.getPosition().x;
double dy = p.getPosition().y - g.getPosition().y;
double r = GameMap.MAP_TILESIZE * 1.0; // tune threshold
return (dx * dx + dy * dy) <= r * r;
}
private void endGame() {
gameOverState.setScore(score);
@ -239,6 +267,7 @@ public class PlayingState implements GameState {
}
public void setScore(int score) {
scorePopups.spawn(score);
this.score += score;
}
}

View File

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

View File

@ -1,4 +1,4 @@
package se.urmo.game.state;
package se.urmo.game.util;
import lombok.Getter;
import se.urmo.game.graphics.SpriteLocation;

View File

@ -1,4 +1,4 @@
package se.urmo.game.state;
package se.urmo.game.util;
public enum GameStateType {
PLAYING, GAME_OVER,

View File

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

View File

@ -5,7 +5,14 @@
</encoder>
</appender>
<root level="DEBUG">
<!--root level="DEBUG">
<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>

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,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
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
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
@ -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
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,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
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.