Files
thing/GamePanel.java
2026-05-14 00:51:15 -05:00

862 lines
33 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}