This commit is contained in:
CT
2026-05-14 00:51:15 -05:00
parent dc5cb74e6a
commit 3eae9e6596
7 changed files with 1433 additions and 1386 deletions

861
GamePanel.java Normal file
View File

@@ -0,0 +1,861 @@
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 (110); 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;
}
}