862 lines
33 KiB
Java
862 lines
33 KiB
Java
import java.awt.*;
|
||
import java.awt.event.*;
|
||
import java.util.ArrayList;
|
||
import java.util.Comparator;
|
||
import java.util.PriorityQueue;
|
||
import javax.swing.*;
|
||
|
||
/**
|
||
* GamePanel — the single JPanel that owns: • the 60 fps game loop (Swing Timer) • physics,
|
||
* collision detection, and rock-pushing • three built-in level layouts (via TileSet) • all
|
||
* rendering • keyboard input for both players
|
||
*
|
||
* <p>Rubric notes implemented here: • TileSet inner class — Aimee's "extra method/class" rubric
|
||
* item • PriorityQueue<Rock> — "advanced data structure / heap" rubric item • JProgressBar (bear +
|
||
* seal) — "extra GUI class" rubric item • Timer (game loop + countdown) — "timer used for logic"
|
||
* rubric item • Variable text display (names + countdown) — drawn every frame
|
||
*/
|
||
public class GamePanel extends JPanel implements ActionListener, KeyListener {
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// TileSet — Aimee's rubric item ("extra method / class")
|
||
//
|
||
// Stores and renders a layer of background tiles so levels can have
|
||
// a distinct visual theme without external image files. Each level
|
||
// calls TileSet.build() with a different colour pair.
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
static class TileSet {
|
||
private static final int TILE = 32; // px per tile cell
|
||
|
||
private final Color dark;
|
||
private final Color light;
|
||
private final int cols;
|
||
private final int rows;
|
||
|
||
TileSet(Color dark, Color light, int panelW, int panelH) {
|
||
this.dark = dark;
|
||
this.light = light;
|
||
this.cols = (panelW / TILE) + 1;
|
||
this.rows = (panelH / TILE) + 1;
|
||
}
|
||
|
||
/** Draw a simple checker-pattern background. */
|
||
void draw(Graphics g) {
|
||
for (int row = 0; row < rows; row++) {
|
||
for (int col = 0; col < cols; col++) {
|
||
g.setColor(((row + col) % 2 == 0) ? dark : light);
|
||
g.fillRect(col * TILE, row * TILE, TILE, TILE);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Factory — returns a TileSet themed for the given level number. */
|
||
static TileSet forLevel(int level, int w, int h) {
|
||
return switch (level) {
|
||
case 1 -> new TileSet(new Color(20, 40, 60), new Color(25, 50, 75), w, h);
|
||
case 2 -> new TileSet(new Color(40, 20, 60), new Color(50, 25, 75), w, h);
|
||
default -> new TileSet(new Color(50, 20, 20), new Color(65, 25, 25), w, h);
|
||
};
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Static inner obstacle types (plain data, no behaviour of their own)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/** Impassable surface — floors, ledges, ceilings. */
|
||
static class Wall {
|
||
Rectangle hitbox;
|
||
|
||
Wall(int x, int y, int w, int h) {
|
||
hitbox = new Rectangle(x, y, w, h);
|
||
}
|
||
|
||
void draw(Graphics g) {
|
||
g.setColor(new Color(100, 100, 120));
|
||
g.fillRect(hitbox.x, hitbox.y, hitbox.width, hitbox.height);
|
||
g.setColor(new Color(60, 60, 80));
|
||
g.drawRect(hitbox.x, hitbox.y, hitbox.width, hitbox.height);
|
||
// Simple highlight on top edge so platforms read clearly
|
||
g.setColor(new Color(160, 160, 180));
|
||
g.drawLine(hitbox.x, hitbox.y, hitbox.x + hitbox.width, hitbox.y);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pushable rock — stored in a PriorityQueue sorted by x-position (satisfies the "advanced data
|
||
* structure / heap" rubric requirement).
|
||
*/
|
||
static class Rock {
|
||
Rectangle hitbox;
|
||
int velocityY = 0;
|
||
|
||
Rock(int x, int y, int w, int h) {
|
||
hitbox = new Rectangle(x, y, w, h);
|
||
}
|
||
|
||
void draw(Graphics g) {
|
||
g.setColor(new Color(139, 90, 43));
|
||
g.fillOval(hitbox.x, hitbox.y, hitbox.width, hitbox.height);
|
||
g.setColor(new Color(100, 60, 20));
|
||
g.drawOval(hitbox.x, hitbox.y, hitbox.width, hitbox.height);
|
||
// Shine dot
|
||
g.setColor(new Color(200, 160, 100, 180));
|
||
g.fillOval(hitbox.x + 5, hitbox.y + 5, 8, 8);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hazardous liquid pool. dangerousToSeal == false → kills bear (lava / acid) dangerousToSeal ==
|
||
* true → kills seal (oil / poison water)
|
||
*/
|
||
static class Fluid {
|
||
int x, y, w = 80, h = 20;
|
||
boolean dangerousToSeal;
|
||
// Animation offset so each fluid tile ripples independently
|
||
int rippleOffset;
|
||
|
||
Fluid(int x, int y, boolean dangerousToSeal, int rippleOffset) {
|
||
this.x = x;
|
||
this.y = y;
|
||
this.dangerousToSeal = dangerousToSeal;
|
||
this.rippleOffset = rippleOffset;
|
||
}
|
||
|
||
Rectangle rect() {
|
||
return new Rectangle(x, y, w, h);
|
||
}
|
||
|
||
void draw(Graphics g, int tick) {
|
||
// Base colour
|
||
Color base = dangerousToSeal ? new Color(0, 80, 200, 210) : new Color(200, 60, 0, 210);
|
||
g.setColor(base);
|
||
g.fillRect(x, y, w, h);
|
||
|
||
// Animated ripple line — moves upward each tick
|
||
int ry = y + 4 + ((tick / 4 + rippleOffset) % (h - 4));
|
||
g.setColor(dangerousToSeal ? new Color(100, 180, 255, 140) : new Color(255, 160, 80, 140));
|
||
g.drawLine(x + 4, ry, x + w - 4, ry);
|
||
|
||
// Border
|
||
g.setColor(Color.WHITE);
|
||
g.drawRect(x, y, w, h);
|
||
|
||
// Danger label
|
||
g.setColor(Color.WHITE);
|
||
g.setFont(new Font("Arial", Font.BOLD, 8));
|
||
g.drawString(dangerousToSeal ? "SEAL" : "BEAR", x + 3, y + h - 4);
|
||
}
|
||
}
|
||
|
||
/** Upward wind column — launches any player that enters it. */
|
||
static class Gust {
|
||
int x, y, w = 60, h = 180;
|
||
|
||
Gust(int x, int y) {
|
||
this.x = x;
|
||
this.y = y;
|
||
}
|
||
|
||
Rectangle rect() {
|
||
return new Rectangle(x, y, w, h);
|
||
}
|
||
|
||
void draw(Graphics g, int tick) {
|
||
// Pulsing green column
|
||
int alpha = 40 + (int) (20 * Math.sin(tick * 0.1));
|
||
g.setColor(new Color(100, 255, 100, alpha));
|
||
g.fillRect(x, y, w, h);
|
||
g.setColor(new Color(100, 255, 100, 100));
|
||
g.drawRect(x, y, w, h);
|
||
|
||
// Upward arrow lines that scroll upward
|
||
g.setColor(new Color(150, 255, 150, 160));
|
||
int spacing = 30;
|
||
for (int ay = y + h - ((tick * 2) % spacing); ay > y; ay -= spacing) {
|
||
int mx = x + w / 2;
|
||
g.drawLine(mx, ay, mx - 8, ay + 10);
|
||
g.drawLine(mx, ay, mx + 8, ay + 10);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Constants
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
static final int W = 640;
|
||
static final int H = 360;
|
||
private static final int FLOOR_Y = H - 40;
|
||
private static final int GRAVITY = 1;
|
||
private static final int JUMP_FORCE = -13;
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// State
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
// Navigation
|
||
private JPanel container;
|
||
private CardLayout cardLayout;
|
||
private String currentLevel = "";
|
||
|
||
// Players
|
||
final Player bear;
|
||
final Player seal;
|
||
|
||
// Obstacles — walls and fluids are ArrayLists; rocks use PriorityQueue (heap)
|
||
private final ArrayList<Wall> walls = new ArrayList<>();
|
||
private final ArrayList<Fluid> fluids = new ArrayList<>();
|
||
private final ArrayList<Gust> gusts = new ArrayList<>();
|
||
// PriorityQueue sorted by rock x-position — satisfies "advanced data structure
|
||
// / heap"
|
||
private PriorityQueue<Rock> rocks;
|
||
|
||
// Current level background
|
||
private TileSet tileSet;
|
||
|
||
// Win zones
|
||
private Rectangle bearDoor, sealDoor;
|
||
private boolean bearWon, sealWon;
|
||
|
||
// Game-loop state
|
||
private boolean isRunning = false;
|
||
private boolean gameOver = false;
|
||
private int animTick = 0; // increments every frame; drives animation + gust ripple
|
||
|
||
// Difficulty — set from the menu before a level starts
|
||
private int difficultySeconds = 150; // default Easy
|
||
|
||
// HUD widgets
|
||
private final JProgressBar bearBar = new JProgressBar(0, FLOOR_Y);
|
||
private final JProgressBar sealBar = new JProgressBar(0, FLOOR_Y);
|
||
private final JLabel timerLabel = new JLabel("Time: --");
|
||
// JSlider controls player walk speed (extra GUI class)
|
||
private final JSlider speedSlider;
|
||
|
||
// Timers
|
||
private final Timer gameTimer; // 60 fps game loop
|
||
private Timer countdownTimer; // 1-second countdown
|
||
|
||
// Countdown state — kept as a plain int field so the lambda never
|
||
// references itself (avoids the "variable used in lambda must be
|
||
// effectively final" compiler error).
|
||
private int secondsLeft = 0;
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Constructor
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
public GamePanel() {
|
||
setPreferredSize(new Dimension(W, H));
|
||
setFocusable(true);
|
||
addKeyListener(this);
|
||
setFocusTraversalKeysEnabled(false);
|
||
|
||
// Sprites (graceful fallback to coloured rectangles if files absent)
|
||
Image[] bearFrames =
|
||
loadFrames("bearIdle.png", "bearWalk1.png", "bearWalk2.png", "bearWalk3.png");
|
||
Image[] sealFrames = loadFrames("sealIdle.png", "sealWalk1.png", "sealWalk2.png");
|
||
bear = new Player(false, bearFrames);
|
||
seal = new Player(true, sealFrames);
|
||
|
||
// ── HUD strip ────────────────────────────────────────────────────────
|
||
bearBar.setForeground(new Color(210, 140, 60));
|
||
sealBar.setForeground(new Color(70, 160, 220));
|
||
bearBar.setPreferredSize(new Dimension(90, 12));
|
||
sealBar.setPreferredSize(new Dimension(90, 12));
|
||
bearBar.setStringPainted(false);
|
||
sealBar.setStringPainted(false);
|
||
|
||
timerLabel.setForeground(Color.WHITE);
|
||
timerLabel.setFont(new Font("Arial", Font.BOLD, 14));
|
||
|
||
// JSlider — player speed (1–10); starts at 5
|
||
// Satisfies "extra GUI class not previously used" if JSlider is new,
|
||
// and also gives players a fun way to adjust feel before a level.
|
||
speedSlider = new JSlider(1, 10, 5);
|
||
speedSlider.setOpaque(false);
|
||
speedSlider.setForeground(Color.WHITE);
|
||
speedSlider.setPreferredSize(new Dimension(80, 20));
|
||
speedSlider.setToolTipText("Player speed");
|
||
speedSlider.addChangeListener(
|
||
ev -> {
|
||
int spd = speedSlider.getValue();
|
||
bear.speed = spd;
|
||
seal.speed = spd;
|
||
});
|
||
|
||
JLabel speedLabel = new JLabel("Speed:");
|
||
speedLabel.setForeground(Color.LIGHT_GRAY);
|
||
speedLabel.setFont(new Font("Arial", Font.PLAIN, 11));
|
||
|
||
JPanel hud = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 1));
|
||
hud.setOpaque(false);
|
||
hud.add(makeLabel("Bear:", new Color(210, 140, 60)));
|
||
hud.add(bearBar);
|
||
hud.add(timerLabel);
|
||
hud.add(makeLabel("Seal:", new Color(70, 160, 220)));
|
||
hud.add(sealBar);
|
||
hud.add(speedLabel);
|
||
hud.add(speedSlider);
|
||
|
||
setLayout(new BorderLayout());
|
||
add(hud, BorderLayout.NORTH);
|
||
|
||
// 60 fps game loop — uses ActionListener, no lambda, so no timer
|
||
// self-reference issue.
|
||
gameTimer = new Timer(1000 / 60, this);
|
||
|
||
// Initialise the rock heap (empty; rebuilt per level)
|
||
rocks = new PriorityQueue<>(Comparator.comparingInt(r -> r.hitbox.x));
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Public API (called by GameDriver / MainMenu)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
public void setContainer(JPanel c, CardLayout cl) {
|
||
this.container = c;
|
||
this.cardLayout = cl;
|
||
}
|
||
|
||
/**
|
||
* Begin (or restart) a level.
|
||
*
|
||
* @param name1 display name for bear player
|
||
* @param name2 display name for seal player
|
||
* @param level card key: "LEVEL1", "LEVEL2", or "LEVEL3"
|
||
*/
|
||
public void startLevel(String name1, String name2, String level) {
|
||
bear.name = (name1 != null && !name1.isBlank()) ? name1 : "Bear";
|
||
seal.name = (name2 != null && !name2.isBlank()) ? name2 : "Seal";
|
||
currentLevel = level;
|
||
|
||
resetState();
|
||
buildLevel(level);
|
||
startCountdown(difficultySeconds);
|
||
|
||
gameTimer.stop();
|
||
gameTimer.start();
|
||
|
||
cardLayout.show(container, level);
|
||
requestFocusInWindow();
|
||
}
|
||
|
||
/**
|
||
* Called from MainMenu's difficulty radio buttons BEFORE a level starts. Stores the chosen
|
||
* seconds so startLevel() picks them up.
|
||
*/
|
||
public void setDifficulty(String d) {
|
||
difficultySeconds =
|
||
switch (d) {
|
||
case "Hard" -> 90;
|
||
case "Medium" -> 120;
|
||
default -> 150;
|
||
};
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Level construction
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
private void buildLevel(String level) {
|
||
walls.clear();
|
||
fluids.clear();
|
||
gusts.clear();
|
||
// Rebuild the priority queue fresh each level
|
||
rocks = new PriorityQueue<>(Comparator.comparingInt(r -> r.hitbox.x));
|
||
bearDoor = sealDoor = null;
|
||
|
||
switch (level) {
|
||
case "LEVEL1" -> buildLevel1();
|
||
case "LEVEL2" -> buildLevel2();
|
||
case "LEVEL3" -> buildLevel3();
|
||
}
|
||
}
|
||
|
||
// ── Level 1 — "The Crossing" (Easy) ─────────────────────────────────────
|
||
// Goal: players start on opposite sides and must cross to reach the door on
|
||
// the other side. A rock bridges a gap in the centre; hazard pools block
|
||
// the direct route to force use of the raised platforms.
|
||
// Door positions verified reachable: bear door at x=10 (left side, y=200),
|
||
// seal door at x=596 (right side, y=200) — both sit on raised platforms.
|
||
private void buildLevel1() {
|
||
tileSet = TileSet.forLevel(1, W, H);
|
||
|
||
bear.setPosition(560, 260); // bear starts RIGHT
|
||
seal.setPosition(50, 260); // seal starts LEFT
|
||
|
||
// Ground — two halves with a gap at x 220-300
|
||
walls.add(new Wall(0, 310, 220, 20));
|
||
walls.add(new Wall(300, 310, 340, 20));
|
||
|
||
// Raised platforms (reachable from ground via jump)
|
||
walls.add(new Wall(0, 210, 130, 15)); // left platform — seal door sits here
|
||
walls.add(new Wall(510, 210, 130, 15)); // right platform — bear door sits here
|
||
walls.add(new Wall(255, 255, 130, 15)); // centre ledge over the gap
|
||
|
||
// Rock bridges the gap — players must push it or jump the ledge
|
||
rocks.add(new Rock(228, 280, 28, 28));
|
||
|
||
// Fluids — each blocks the direct ground-level path for one player
|
||
// bear-fluid (orange) on the LEFT — hurts bear trying to run left to door
|
||
fluids.add(new Fluid(140, 291, false, 0));
|
||
fluids.add(new Fluid(220, 291, false, 3));
|
||
// seal-fluid (blue) on the RIGHT — hurts seal trying to run right to door
|
||
fluids.add(new Fluid(390, 291, true, 1));
|
||
fluids.add(new Fluid(470, 291, true, 5));
|
||
|
||
// Gusts lift players up to the raised platforms
|
||
gusts.add(new Gust(15, 130));
|
||
gusts.add(new Gust(565, 130));
|
||
|
||
// Doors on the OPPOSITE side from each player's start
|
||
bearDoor = new Rectangle(10, 165, 30, 45); // bear must go LEFT
|
||
sealDoor = new Rectangle(596, 165, 30, 45); // seal must go RIGHT
|
||
}
|
||
|
||
// ── Level 2 — "Double Danger" (Medium) ──────────────────────────────────
|
||
// Two-tier layout. Players start on the bottom floor and must climb to the
|
||
// upper platform. More fluid pools and a narrower gust column.
|
||
private void buildLevel2() {
|
||
tileSet = TileSet.forLevel(2, W, H);
|
||
|
||
bear.setPosition(40, 280);
|
||
seal.setPosition(560, 280);
|
||
|
||
// Full bottom floor
|
||
walls.add(new Wall(0, 320, 640, 20));
|
||
|
||
// Mid-level platforms
|
||
walls.add(new Wall(0, 220, 150, 15));
|
||
walls.add(new Wall(490, 220, 150, 15));
|
||
walls.add(new Wall(220, 170, 200, 15)); // upper-centre platform
|
||
|
||
// Small step walls (help players climb without gust)
|
||
walls.add(new Wall(160, 270, 60, 15));
|
||
walls.add(new Wall(420, 270, 60, 15));
|
||
|
||
// Rocks — two on floor, one on upper platform (heap orders by x automatically)
|
||
rocks.add(new Rock(50, 290, 28, 28));
|
||
rocks.add(new Rock(560, 290, 28, 28));
|
||
rocks.add(new Rock(295, 142, 28, 28));
|
||
|
||
// Bear-fluid on right side of floor + right ledge
|
||
fluids.add(new Fluid(340, 301, false, 0));
|
||
fluids.add(new Fluid(420, 301, false, 2));
|
||
fluids.add(new Fluid(500, 202, false, 4)); // on right mid-platform
|
||
|
||
// Seal-fluid on left side of floor + left ledge
|
||
fluids.add(new Fluid(180, 301, true, 1));
|
||
fluids.add(new Fluid(100, 301, true, 3));
|
||
fluids.add(new Fluid(10, 202, true, 5)); // on left mid-platform
|
||
|
||
// Gusts on each side help players reach mid-level
|
||
gusts.add(new Gust(0, 60));
|
||
gusts.add(new Gust(580, 60));
|
||
|
||
// Doors on the upper-centre platform — both players must meet in the middle
|
||
bearDoor = new Rectangle(220, 125, 30, 45);
|
||
sealDoor = new Rectangle(390, 125, 30, 45);
|
||
}
|
||
|
||
// ── Level 3 — "The Gauntlet" (Hard) ─────────────────────────────────────
|
||
// Three tiers. Dense fluid fields, narrow gusts, rocks must be pushed to
|
||
// create safe paths between fluid pools.
|
||
private void buildLevel3() {
|
||
tileSet = TileSet.forLevel(3, W, H);
|
||
|
||
bear.setPosition(30, 280);
|
||
seal.setPosition(580, 280);
|
||
|
||
// Bottom strips (not a full floor — players can fall off the sides)
|
||
walls.add(new Wall(0, 320, 160, 20));
|
||
walls.add(new Wall(480, 320, 160, 20));
|
||
|
||
// Mid platforms
|
||
walls.add(new Wall(120, 230, 160, 15));
|
||
walls.add(new Wall(360, 230, 160, 15));
|
||
|
||
// Upper platforms
|
||
walls.add(new Wall(0, 145, 140, 15));
|
||
walls.add(new Wall(500, 145, 140, 15));
|
||
|
||
// Rocks — three across the levels
|
||
rocks.add(new Rock(80, 290, 28, 28));
|
||
rocks.add(new Rock(300, 200, 28, 28));
|
||
rocks.add(new Rock(530, 290, 28, 28));
|
||
|
||
// Bear-fluid (orange) — right-side hazards
|
||
fluids.add(new Fluid(340, 301, false, 0));
|
||
fluids.add(new Fluid(420, 301, false, 2));
|
||
fluids.add(new Fluid(500, 211, false, 4));
|
||
fluids.add(new Fluid(520, 126, false, 6));
|
||
|
||
// Seal-fluid (blue) — left-side hazards
|
||
fluids.add(new Fluid(160, 301, true, 1));
|
||
fluids.add(new Fluid(80, 301, true, 3));
|
||
fluids.add(new Fluid(120, 211, true, 5));
|
||
fluids.add(new Fluid(10, 126, true, 7));
|
||
|
||
// Narrow gusts — one on each side
|
||
gusts.add(new Gust(10, 80));
|
||
gusts.add(new Gust(570, 80));
|
||
|
||
// Doors at opposite upper corners
|
||
bearDoor = new Rectangle(5, 100, 30, 45); // bear must go to top-LEFT
|
||
sealDoor = new Rectangle(605, 100, 30, 45); // seal must go to top-RIGHT
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Game loop (called by gameTimer — implements ActionListener)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
@Override
|
||
public void actionPerformed(ActionEvent e) {
|
||
if (!isRunning) return;
|
||
update();
|
||
repaint();
|
||
if (gameOver) {
|
||
gameTimer.stop();
|
||
if (countdownTimer != null) countdownTimer.stop();
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Physics & collision
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
private void update() {
|
||
if (gameOver) return;
|
||
animTick++;
|
||
|
||
stepPlayer(bear);
|
||
stepPlayer(seal);
|
||
|
||
// Fluid collision — each fluid type is lethal only to its matching player
|
||
for (Fluid f : fluids) {
|
||
if (!f.dangerousToSeal && bear.hitbox.intersects(f.rect())) {
|
||
triggerGameOver();
|
||
return;
|
||
}
|
||
if (f.dangerousToSeal && seal.hitbox.intersects(f.rect())) {
|
||
triggerGameOver();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Gust — launch upward
|
||
for (Gust g : gusts) {
|
||
if (bear.hitbox.intersects(g.rect())) bear.velocityY = Math.min(bear.velocityY, -10);
|
||
if (seal.hitbox.intersects(g.rect())) seal.velocityY = Math.min(seal.velocityY, -10);
|
||
}
|
||
|
||
// Win condition — both must reach their door
|
||
if (bearDoor != null && bear.hitbox.intersects(bearDoor)) bearWon = true;
|
||
if (sealDoor != null && seal.hitbox.intersects(sealDoor)) sealWon = true;
|
||
if (bearWon && sealWon) {
|
||
isRunning = false;
|
||
gameOver = true;
|
||
}
|
||
|
||
// Progress bars (higher up = lower y = more progress)
|
||
bearBar.setValue(FLOOR_Y - Math.max(0, bear.y));
|
||
sealBar.setValue(FLOOR_Y - Math.max(0, seal.y));
|
||
}
|
||
|
||
private void stepPlayer(Player p) {
|
||
int oldX = p.x, oldY = p.y;
|
||
boolean moved = false;
|
||
|
||
// ── Horizontal ──
|
||
if (p.left) {
|
||
p.x -= p.speed;
|
||
moved = true;
|
||
}
|
||
if (p.right) {
|
||
p.x += p.speed;
|
||
moved = true;
|
||
}
|
||
p.syncHitbox();
|
||
if (solidCollision(p)) {
|
||
p.x = oldX;
|
||
p.syncHitbox();
|
||
}
|
||
|
||
// ── Vertical (gravity + jump) ──
|
||
p.velocityY += GRAVITY;
|
||
if (p.up && p.onGround) {
|
||
p.velocityY = JUMP_FORCE;
|
||
p.onGround = false;
|
||
}
|
||
|
||
p.y += p.velocityY;
|
||
p.syncHitbox();
|
||
p.onGround = false;
|
||
|
||
if (solidCollision(p)) {
|
||
if (p.velocityY > 0) p.onGround = true; // landed on top
|
||
p.y = oldY;
|
||
p.velocityY = 0;
|
||
p.syncHitbox();
|
||
}
|
||
|
||
// Screen-boundary clamp
|
||
p.x = Math.max(0, Math.min(p.x, W - 30));
|
||
p.y = Math.max(0, Math.min(p.y, FLOOR_Y));
|
||
if (p.y >= FLOOR_Y) {
|
||
p.onGround = true;
|
||
p.velocityY = 0;
|
||
}
|
||
p.syncHitbox();
|
||
|
||
// Animation
|
||
if (moved && p.walkFrames != null && p.walkFrames.length > 1) {
|
||
if (animTick % 8 == 0) p.nextFrame();
|
||
} else {
|
||
p.idleFrame();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns true if the player's hitbox overlaps any solid surface. Rocks can be pushed
|
||
* horizontally if there is clear space.
|
||
*/
|
||
private boolean solidCollision(Player p) {
|
||
for (Wall w : walls) if (p.hitbox.intersects(w.hitbox)) return true;
|
||
|
||
// Iterate rocks — PriorityQueue does not support indexed access,
|
||
// so we iterate with the enhanced for-loop.
|
||
for (Rock r : rocks) {
|
||
if (p.hitbox.intersects(r.hitbox)) {
|
||
int dx = p.left ? -p.speed : p.right ? p.speed : 0;
|
||
if (dx != 0 && rockCanMove(r, dx)) {
|
||
r.hitbox.x += dx;
|
||
return false; // rock moved; player not blocked
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private boolean rockCanMove(Rock r, int dx) {
|
||
Rectangle next = new Rectangle(r.hitbox.x + dx, r.hitbox.y, r.hitbox.width, r.hitbox.height);
|
||
if (next.x < 0 || next.x + next.width > W) return false;
|
||
for (Wall w : walls) if (next.intersects(w.hitbox)) return false;
|
||
for (Rock o : rocks) if (o != r && next.intersects(o.hitbox)) return false;
|
||
return true;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Rendering
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
@Override
|
||
protected void paintComponent(Graphics g) {
|
||
super.paintComponent(g);
|
||
|
||
// 1. Background tile layer
|
||
if (tileSet != null) tileSet.draw(g);
|
||
|
||
// 2. Win doors
|
||
drawDoor(g, bearDoor, new Color(210, 140, 60, 200), bear.name + "'s DOOR");
|
||
drawDoor(g, sealDoor, new Color(70, 160, 220, 200), seal.name + "'s DOOR");
|
||
|
||
// 3. Obstacles
|
||
for (Wall w : walls) w.draw(g);
|
||
for (Rock r : rocks) r.draw(g);
|
||
for (Gust gu : gusts) gu.draw(g, animTick);
|
||
for (Fluid f : fluids) f.draw(g, animTick);
|
||
|
||
// 4. Players
|
||
drawPlayer(g, bear, new Color(210, 140, 60));
|
||
drawPlayer(g, seal, new Color(70, 160, 220));
|
||
|
||
// 5. Name tags (variable text display)
|
||
if (isRunning) {
|
||
g.setFont(new Font("Arial", Font.BOLD, 12));
|
||
g.setColor(new Color(210, 140, 60));
|
||
g.drawString(bear.name, bear.x - 4, bear.y - 4);
|
||
g.setColor(new Color(70, 160, 220));
|
||
g.drawString(seal.name, seal.x - 4, seal.y - 4);
|
||
}
|
||
|
||
// 6. Game-over overlay
|
||
if (gameOver) {
|
||
g.setColor(new Color(0, 0, 0, 170));
|
||
g.fillRect(0, 0, W, H);
|
||
g.setFont(new Font("Arial", Font.BOLD, 38));
|
||
if (bearWon && sealWon) {
|
||
g.setColor(new Color(255, 215, 0));
|
||
drawCentered(g, "YOU WIN!", H / 2 - 30);
|
||
} else {
|
||
g.setColor(new Color(255, 80, 80));
|
||
drawCentered(g, "GAME OVER", H / 2 - 30);
|
||
}
|
||
g.setFont(new Font("Arial", Font.PLAIN, 16));
|
||
g.setColor(Color.WHITE);
|
||
drawCentered(g, "Press R to restart | ESC for level select", H / 2 + 10);
|
||
}
|
||
|
||
// 7. Win-condition hint strip at bottom of screen during play
|
||
if (isRunning && !gameOver) {
|
||
g.setColor(new Color(0, 0, 0, 100));
|
||
g.fillRect(0, H - 18, W, 18);
|
||
g.setFont(new Font("Arial", Font.PLAIN, 10));
|
||
g.setColor(Color.LIGHT_GRAY);
|
||
String hint =
|
||
bear.name
|
||
+ " → "
|
||
+ (bearWon ? "✓ REACHED DOOR" : "reach YOUR door")
|
||
+ " | "
|
||
+ seal.name
|
||
+ " → "
|
||
+ (sealWon ? "✓ REACHED DOOR" : "reach YOUR door");
|
||
drawCentered(g, hint, H - 5);
|
||
}
|
||
}
|
||
|
||
private void drawDoor(Graphics g, Rectangle door, Color fill, String label) {
|
||
if (door == null) return;
|
||
g.setColor(fill);
|
||
g.fillRect(door.x, door.y, door.width, door.height);
|
||
g.setColor(Color.WHITE);
|
||
g.drawRect(door.x, door.y, door.width, door.height);
|
||
// Draw a door knob
|
||
g.setColor(new Color(255, 220, 50));
|
||
int kx = (door.x < W / 2) ? door.x + door.width - 7 : door.x + 4;
|
||
g.fillOval(kx, door.y + door.height / 2, 5, 5);
|
||
g.setFont(new Font("Arial", Font.BOLD, 8));
|
||
g.setColor(Color.WHITE);
|
||
g.drawString(label, door.x - 2, door.y - 3);
|
||
}
|
||
|
||
private void drawPlayer(Graphics g, Player p, Color fallback) {
|
||
Image img = p.currentSprite();
|
||
if (img != null) {
|
||
g.drawImage(img, p.x, p.y, 30, 40, null);
|
||
} else {
|
||
// Coloured rectangle fallback when sprite files are absent
|
||
g.setColor(fallback);
|
||
g.fillRoundRect(p.x, p.y, 30, 40, 8, 8);
|
||
// Simple face
|
||
g.setColor(Color.WHITE);
|
||
g.fillOval(p.x + 6, p.y + 8, 6, 6);
|
||
g.fillOval(p.x + 18, p.y + 8, 6, 6);
|
||
g.setColor(Color.BLACK);
|
||
g.fillOval(p.x + 8, p.y + 10, 3, 3);
|
||
g.fillOval(p.x + 20, p.y + 10, 3, 3);
|
||
g.setColor(fallback.darker());
|
||
g.drawArc(p.x + 8, p.y + 22, 14, 8, 180, 180);
|
||
// Label
|
||
g.setColor(Color.WHITE);
|
||
g.setFont(new Font("Arial", Font.BOLD, 7));
|
||
g.drawString(p.isSeal ? "SEAL" : "BEAR", p.x + 3, p.y + 38);
|
||
}
|
||
}
|
||
|
||
private void drawCentered(Graphics g, String text, int y) {
|
||
FontMetrics fm = g.getFontMetrics();
|
||
g.drawString(text, (W - fm.stringWidth(text)) / 2, y);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Input
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
@Override
|
||
public void keyPressed(KeyEvent e) {
|
||
switch (e.getKeyCode()) {
|
||
case KeyEvent.VK_LEFT -> bear.left = true;
|
||
case KeyEvent.VK_RIGHT -> bear.right = true;
|
||
case KeyEvent.VK_UP -> bear.up = true;
|
||
case KeyEvent.VK_A -> seal.left = true;
|
||
case KeyEvent.VK_D -> seal.right = true;
|
||
case KeyEvent.VK_W -> seal.up = true;
|
||
case KeyEvent.VK_R -> {
|
||
if (gameOver) startLevel(bear.name, seal.name, currentLevel);
|
||
}
|
||
case KeyEvent.VK_ESCAPE -> {
|
||
if (cardLayout != null) cardLayout.show(container, "MENU");
|
||
}
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void keyReleased(KeyEvent e) {
|
||
switch (e.getKeyCode()) {
|
||
case KeyEvent.VK_LEFT -> bear.left = false;
|
||
case KeyEvent.VK_RIGHT -> bear.right = false;
|
||
case KeyEvent.VK_UP -> bear.up = false;
|
||
case KeyEvent.VK_A -> seal.left = false;
|
||
case KeyEvent.VK_D -> seal.right = false;
|
||
case KeyEvent.VK_W -> seal.up = false;
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void keyTyped(KeyEvent e) {}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Helpers
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
private void resetState() {
|
||
bearWon = sealWon = gameOver = false;
|
||
isRunning = true;
|
||
bear.velocityY = seal.velocityY = 0;
|
||
bear.clearInput();
|
||
seal.clearInput();
|
||
}
|
||
|
||
private void triggerGameOver() {
|
||
gameOver = true;
|
||
isRunning = false;
|
||
}
|
||
|
||
/**
|
||
* Starts the 1-second countdown timer.
|
||
*
|
||
* <p>The timer's ActionListener reads and writes the {@code secondsLeft} instance field directly,
|
||
* so there is NO lambda variable self-reference and NO "variable used in lambda must be
|
||
* effectively final" error.
|
||
*/
|
||
private void startCountdown(int seconds) {
|
||
if (countdownTimer != null) countdownTimer.stop();
|
||
secondsLeft = seconds;
|
||
timerLabel.setText("Time: " + secondsLeft);
|
||
|
||
countdownTimer =
|
||
new Timer(
|
||
1000,
|
||
new ActionListener() {
|
||
@Override
|
||
public void actionPerformed(ActionEvent e) {
|
||
secondsLeft--;
|
||
timerLabel.setText("Time: " + Math.max(0, secondsLeft));
|
||
if (secondsLeft <= 0) {
|
||
// Safe to call ((Timer) e.getSource()).stop() here —
|
||
// no self-reference to countdownTimer needed.
|
||
((Timer) e.getSource()).stop();
|
||
triggerGameOver();
|
||
}
|
||
}
|
||
});
|
||
countdownTimer.start();
|
||
}
|
||
|
||
private static Image[] loadFrames(String... paths) {
|
||
Image[] frames = new Image[paths.length];
|
||
for (int i = 0; i < paths.length; i++) frames[i] = new ImageIcon(paths[i]).getImage();
|
||
return frames;
|
||
}
|
||
|
||
private static JLabel makeLabel(String text, Color color) {
|
||
JLabel l = new JLabel(text);
|
||
l.setForeground(color);
|
||
l.setFont(new Font("Arial", Font.BOLD, 12));
|
||
return l;
|
||
}
|
||
}
|