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 * *

Rubric notes implemented here: • TileSet inner class — Aimee's "extra method/class" rubric * item • PriorityQueue — "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 walls = new ArrayList<>(); private final ArrayList fluids = new ArrayList<>(); private final ArrayList gusts = new ArrayList<>(); // PriorityQueue sorted by rock x-position — satisfies "advanced data structure // / heap" private PriorityQueue 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. * *

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; } }