Code Sketch


Bixuu
By: Mhalsakant School
Category: Programming

import java.awt.{AlphaComposite, BasicStroke, BorderLayout, CardLayout, Color, Component, Dimension, Font, Graphics, Graphics2D, GradientPaint, GridBagConstraints, GridBagLayout, Insets, RenderingHints}
import java.awt.event.{ActionEvent, ActionListener, KeyAdapter, KeyEvent, MouseAdapter, MouseEvent}
import java.io.{BufferedReader, InputStreamReader, PrintWriter}
import java.net.{InetAddress, InetSocketAddress, ServerSocket, Socket}
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.{BorderFactory, Box, BoxLayout, JButton, JComboBox, JComponent, JLabel, JOptionPane, JPanel, JPasswordField, JScrollPane, JTextArea, JTextField, SwingConstants, SwingUtilities, Timer, WindowConstants, JFrame}
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer

object BixuuSpringSeasonMultiplayer {

  val APP_TITLE = "BIXUU - SPRING SEASON"
  val ADMIN_NAME = "Bixuu"
  val ADMIN_PIN = "BIXUU-2026"
  val DEFAULT_PORT = 5050

  val WORLD_W = 1800
  val WORLD_H = 1250
  val SCREEN_W = 1180
  val SCREEN_H = 760

  val BG_TOP = new Color(8, 15, 43)
  val BG_BOTTOM = new Color(13, 62, 69)
  val WHITE = new Color(244, 251, 255)
  val MUTED = new Color(177, 203, 216)
  val CYAN = new Color(73, 239, 235)
  val MINT = new Color(137, 255, 195)
  val PINK = new Color(255, 122, 202)
  val PURPLE = new Color(174, 119, 255)
  val GOLD = new Color(255, 224, 130)

  final class Crystal(val x: Double, val y: Double, val hue: Int, val label: String) {
    var collected = false
  }

  final class Tree(val x: Double, val y: Double, val scale: Double, val hue: Int)
  final class Flower(val x: Double, val y: Double, val size: Double, val hue: Int, val phase: Double)
  final class Star(val x: Double, val y: Double, val size: Int, val phase: Double)
  final class Particle(var x: Double, var y: Double, var vx: Double, var vy: Double, val size: Double, val hue: Int, var life: Double)

  final class Villain(val baseX: Double, val baseY: Double, val range: Double, val speed: Double, val phase: Double, val hue: Int) {
    var x = baseX
    var y = baseY
  }

  final case class LevelInfo(name: String, subtitle: String, crystalCount: Int, villainCount: Int, skyHue: Int)

  val levels = Map(
    1 -> LevelInfo("WHISPERING WOODS", "Find the lost nature seeds", 5, 1, 155),
    2 -> LevelInfo("AURORA FALLS", "Restore the cosmic waterfall", 6, 2, 190),
    3 -> LevelInfo("CELESTIAL WATERFALL TEMPLE", "Unlock the ancient spring portal", 7, 3, 270)
  )

  val completedLevels = mutable.Set[Int]()
  val extraEvents = ArrayBuffer[String]()
  var activeEventTitle = "AURORA FALLS: THE LOST SPRING"

  var currentUser = "Player"
  var isAdmin = false
  var activeLevel = 1
  var multiplayerMode = "SOLO"

  val crystals = ArrayBuffer[Crystal]()
  val trees = ArrayBuffer[Tree]()
  val flowers = ArrayBuffer[Flower]()
  val stars = ArrayBuffer[Star]()
  val particles = ArrayBuffer[Particle]()
  val villains = ArrayBuffer[Villain]()

  val random = new java.util.Random(20261031L)
  val keys = mutable.Set[Int]()

  var playerX = 150.0
  var playerY = 1080.0
  var facingX = 0.0
  var facingY = -1.0
  var cameraX = 0.0
  var cameraY = 0.0
  var frames = 0L
  var remainingSeconds = 360.0
  var health = 3
  var started = false
  var paused = false
  var won = false
  var lost = false
  var glowMode = true
  var showMap = true
  var dashTime = 0.0
  var dashCooldown = 0.0
  var hitCooldown = 0.0
  var toast = "Explore the world and find the magical crystals."
  var toastTime = 4.0

  val portalX = 1630.0
  val portalY = 190.0
  val spawnX = 150.0
  val spawnY = 1080.0

  var frame: JFrame = null
  var root: JPanel = null
  var cards: CardLayout = null
  var gamePanel: GamePanel = null
  var gameTimer: Timer = null

  var nameField: JTextField = null
  var pinField: JPasswordField = null
  var adminButton: JButton = null
  var levelButtons = ArrayBuffer[JButton]()
  var hostIpLabel: JLabel = null
  var multiplayerStatus: JLabel = null
  var eventTitleField: JTextField = null
  var eventListArea: JTextArea = null
  var welcomeLabel: JLabel = null

  var lanServer: Option[LanServer] = None
  var lanClient: Option[LanClient] = None

  // ============================================================
  // COMMON HELPERS
  // ============================================================

  def clamp(v: Double, lo: Double, hi: Double): Double =
    math.max(lo, math.min(hi, v))

  def dist(x1: Double, y1: Double, x2: Double, y2: Double): Double = {
    val dx = x1 - x2
    val dy = y1 - y2
    math.sqrt(dx * dx + dy * dy)
  }

  def hueColor(h: Int, s: Float = 0.78f, b: Float = 1.0f): Color =
    Color.getHSBColor((h.toDouble / 360.0).toFloat, s, b)

  def safeName(s: String): String = {
    val cleaned = Option(s).getOrElse("").trim.replaceAll("[,;|\\r\\n]", "_")
    if (cleaned.isEmpty) "Player" else cleaned.take(18)
  }

  def showToast(s: String, seconds: Double = 2.8): Unit = {
    toast = s
    toastTime = seconds
  }

  def collectedCount: Int = crystals.count(_.collected)
  def sx(x: Double): Int = math.round(x - cameraX).toInt
  def sy(y: Double): Int = math.round(y - cameraY).toInt

  def styledButton(text: String): JButton = {
    val b = new JButton(text)
    b.setFont(new Font("SansSerif", Font.BOLD, 15))
    b.setForeground(WHITE)
    b.setBackground(new Color(24, 99, 91))
    b.setFocusPainted(false)
    b.setBorder(BorderFactory.createCompoundBorder(
      BorderFactory.createLineBorder(new Color(82, 220, 208), 1),
      BorderFactory.createEmptyBorder(10, 18, 10, 18)
    ))
    b.setAlignmentX(Component.CENTER_ALIGNMENT)
    b
  }

  def heading(text: String, size: Int, color: Color): JLabel = {
    val l = new JLabel(text, SwingConstants.CENTER)
    l.setFont(new Font("SansSerif", Font.BOLD, size))
    l.setForeground(color)
    l.setAlignmentX(Component.CENTER_ALIGNMENT)
    l
  }

  def bodyLabel(text: String): JLabel = {
    val l = new JLabel(text, SwingConstants.CENTER)
    l.setFont(new Font("SansSerif", Font.PLAIN, 14))
    l.setForeground(MUTED)
    l.setAlignmentX(Component.CENTER_ALIGNMENT)
    l
  }

  def verticalPanel(): JPanel = {
    val p = new JPanel()
    p.setOpaque(true)
    p.setBackground(new Color(9, 20, 42))
    p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS))
    p.setBorder(BorderFactory.createEmptyBorder(24, 35, 24, 35))
    p
  }

  // FIX 1: JComponent, not java.awt.Component.
  def addCentered(p: JPanel, c: JComponent, gap: Int = 10): Unit = {
    p.add(Box.createVerticalStrut(gap))
    c.setAlignmentX(Component.CENTER_ALIGNMENT)
    p.add(c)
  }

  def showCard(name: String): Unit = {
    cards.show(root, name)
    root.revalidate()
    root.repaint()
  }

  def addAction(b: JButton)(action: => Unit): Unit = {
    b.addActionListener(new ActionListener {
      override def actionPerformed(e: ActionEvent): Unit = action
    })
  }

  // ============================================================
  // LAN MULTIPLAYER SERVER
  // ============================================================

  final class LanServer(port: Int, hostName: String) {

    private val serverSocket = new ServerSocket()
    private val running = new java.util.concurrent.atomic.AtomicBoolean(true)
    private val counter = new AtomicInteger(0)

    private val positions = new ConcurrentHashMap[String, String]()
    private val writers = new ConcurrentHashMap[String, PrintWriter]()
    private val sharedCrystals = new ConcurrentHashMap[String, String]()

    serverSocket.setReuseAddress(true)
    serverSocket.bind(new InetSocketAddress(port))

    def start(): Unit = {
      positions.put("HOST-" + safeName(hostName), "150.0,1080.0")

      val t = new Thread(new Runnable {
        override def run(): Unit = {
          while (running.get()) {
            try {
              val socket = serverSocket.accept()
              socket.setTcpNoDelay(true)

              val worker = new Thread(new Runnable {
                override def run(): Unit = handleClient(socket)
              })

              worker.setDaemon(true)
              worker.start()

            } catch {
              case _: java.net.SocketException => ()
              case _: Throwable => ()
            }
          }
        }
      })

      t.setDaemon(true)
      t.start()
    }

    private def handleClient(socket: Socket): Unit = {

      var id = ""
      var out: PrintWriter = null

      try {
        val in = new BufferedReader(
          new InputStreamReader(socket.getInputStream, "UTF-8")
        )

        out = new PrintWriter(socket.getOutputStream, true)

        val first = in.readLine()

        if (first != null && first.startsWith("JOIN|")) {

          val requested = safeName(first.substring(5))
          id = requested + "-" + counter.incrementAndGet()

          writers.put(id, out)
          positions.put(id, "150.0,1080.0")

          var line = in.readLine()

          while (line != null && running.get()) {

            if (line.startsWith("POS|")) {

              val coords = line.substring(4).split(",", 2)

              if (coords.length == 2) {
                try {
                  val px = clamp(coords(0).toDouble, 0.0, WORLD_W.toDouble)
                  val py = clamp(coords(1).toDouble, 0.0, WORLD_H.toDouble)
                  positions.put(id, px.toString + "," + py.toString)
                } catch {
                  case _: NumberFormatException => ()
                }
              }

            } else if (line.startsWith("COLLECT|")) {

              try {
                val index = line.substring(8).trim.toInt
                if (index >= 0 && index < 10) {
                  sharedCrystals.put(index.toString, "1")
                }
              } catch {
                case _: NumberFormatException => ()
              }
            }

            line = in.readLine()
          }
        }

      } catch {
        case _: Throwable => ()
      } finally {

        if (id.nonEmpty) {
          writers.remove(id)
          positions.remove(id)
        }

        try socket.close() catch {
          case _: Throwable => ()
        }
      }
    }

    def snapshot(): Seq[(String, String)] = {
      val result = ArrayBuffer[(String, String)]()
      val it = positions.entrySet().iterator()

      while (it.hasNext) {
        val e = it.next()
        result += ((e.getKey, e.getValue))
      }

      result.toSeq
    }

    def markCrystalCollected(index: Int): Unit = {
      if (index >= 0 && index < 10) {
        sharedCrystals.put(index.toString, "1")
      }
    }

    def collectedSnapshot(): Set[Int] = {
      val result = mutable.Set[Int]()
      val it = sharedCrystals.keySet().iterator()

      while (it.hasNext) {
        try result += it.next().toInt
        catch {
          case _: NumberFormatException => ()
        }
      }

      result.toSet
    }

    def publishHostPosition(x: Double, y: Double): Unit = {

      positions.put(
        "HOST-" + safeName(hostName),
        x.toString + "," + y.toString
      )

      val sb = new StringBuilder("STATE|")
      val it = positions.entrySet().iterator()
      var first = true

      while (it.hasNext) {
        val e = it.next()

        if (!first) sb.append(";")
        first = false

        sb.append(e.getKey).append(",").append(e.getValue)
      }

      sb.append("|")

      val gemIt = sharedCrystals.keySet().iterator()
      var firstGem = true

      while (gemIt.hasNext) {
        if (!firstGem) sb.append(",")
        firstGem = false
        sb.append(gemIt.next())
      }

      val message = sb.toString
      val wi = writers.values().iterator()

      while (wi.hasNext) {
        try wi.next().println(message)
        catch {
          case _: Throwable => ()
        }
      }
    }

    def close(): Unit = {
      running.set(false)
      try serverSocket.close()
      catch {
        case _: Throwable => ()
      }

      val wi = writers.values().iterator()

      while (wi.hasNext) {
        try wi.next().close()
        catch {
          case _: Throwable => ()
        }
      }

      writers.clear()
      positions.clear()
      sharedCrystals.clear()
    }
  }

  // ============================================================
  // LAN MULTIPLAYER CLIENT
  // ============================================================

  final class LanClient(host: String, port: Int, playerName: String) {

    private val socket = new Socket()
    socket.connect(new InetSocketAddress(host, port), 3500)
    socket.setTcpNoDelay(true)

    private val out = new PrintWriter(socket.getOutputStream, true)

    private val in = new BufferedReader(
      new InputStreamReader(socket.getInputStream, "UTF-8")
    )

    private val received = new ConcurrentHashMap[String, String]()
    private val receivedCrystals = new ConcurrentHashMap[String, String]()

    @volatile private var connected = true

    out.println("JOIN|" + safeName(playerName))

    private val readerThread = new Thread(new Runnable {
      override def run(): Unit = {
        try {
          var line = in.readLine()

          while (line != null && connected) {

            if (line.startsWith("STATE|")) {

              val sections = line.split("\\|", 3)

              if (sections.length >= 2) {

                val entries = sections(1).split(";")

                received.clear()

                entries.foreach { entry =>
                  val parts = entry.split(",", 3)

                  if (parts.length == 3) {
                    received.put(
                      parts(0),
                      parts(1) + "," + parts(2)
                    )
                  }
                }

                receivedCrystals.clear()

                if (sections.length == 3 && sections(2).nonEmpty) {
                  sections(2).split(",").foreach { i =>
                    receivedCrystals.put(i, "1")
                  }
                }
              }
            }

            line = in.readLine()
          }

        } catch {
          case _: Throwable => ()
        }

        connected = false
      }
    })

    readerThread.setDaemon(true)
    readerThread.start()

    def sendPosition(x: Double, y: Double): Unit = {
      if (connected) {
        out.println("POS|" + x.toString + "," + y.toString)
      }
    }

    def collectCrystal(index: Int): Unit = {
      if (connected && index >= 0 && index < 10) {
        out.println("COLLECT|" + index)
      }
    }

    def collectedSnapshot(): Set[Int] = {
      val result = mutable.Set[Int]()
      val it = receivedCrystals.keySet().iterator()

      while (it.hasNext) {
        try result += it.next().toInt
        catch {
          case _: NumberFormatException => ()
        }
      }

      result.toSet
    }

    def snapshot(): Seq[(String, String)] = {
      val b = ArrayBuffer[(String, String)]()
      val it = received.entrySet().iterator()

      while (it.hasNext) {
        val e = it.next()
        b += ((e.getKey, e.getValue))
      }

      b.toSeq
    }

    def isConnected: Boolean = connected

    def close(): Unit = {
      connected = false
      try socket.close()
      catch {
        case _: Throwable => ()
      }
    }
  }

  // ============================================================
  // GAME WORLD
  // ============================================================

  def setupLevel(level: Int): Unit = {

    activeLevel = level

    crystals.clear()
    trees.clear()
    flowers.clear()
    stars.clear()
    particles.clear()
    villains.clear()
    keys.clear()

    val crystalData = level match {

      case 1 =>
        Array(
          (350.0, 900.0, 150, "MINT SEED"),
          (620.0, 680.0, 48, "SUN PETAL"),
          (940.0, 940.0, 190, "STAR BLOOM"),
          (1250.0, 650.0, 285, "MOON GEM"),
          (1510.0, 360.0, 315, "SPRING ORB")
        )

      case 2 =>
        Array(
          (300.0, 920.0, 155, "MINT SEED"),
          (580.0, 740.0, 190, "STAR PETAL"),
          (900.0, 950.0, 285, "MOON BLOOM"),
          (1260.0, 900.0, 48, "SUN CRYSTAL"),
          (1510.0, 650.0, 195, "WATER GEM"),
          (1050.0, 350.0, 315, "AURORA ORB")
        )

      case _ =>
        Array(
          (280.0, 900.0, 150, "FOREST KEY"),
          (540.0, 650.0, 190, "MOON PETAL"),
          (850.0, 1000.0, 285, "DREAM GEM"),
          (1150.0, 830.0, 48, "SUN KEY"),
          (1480.0, 700.0, 195, "WATER HEART"),
          (1280.0, 350.0, 315, "STAR ORB"),
          (750.0, 300.0, 265, "TEMPLE SEED")
        )
    }

    crystalData.foreach { c =>
      crystals += new Crystal(c._1, c._2, c._3, c._4)
    }

    val treeData = Array(
      (100.0, 260.0, 1.1, 150),
      (330.0, 180.0, 0.9, 160),
      (660.0, 220.0, 1.0, 145),
      (1010.0, 190.0, 0.9, 155),
      (1740.0, 230.0, 1.1, 165),
      (1770.0, 600.0, 1.0, 150),
      (1700.0, 1050.0, 1.1, 160),
      (1320.0, 1150.0, 0.9, 145),
      (850.0, 1160.0, 1.0, 165),
      (390.0, 1170.0, 1.1, 155),
      (100.0, 820.0, 0.9, 160),
      (480.0, 470.0, 0.8, 150),
      (960.0, 700.0, 0.9, 165),
      (1320.0, 330.0, 0.8, 150),
      (650.0, 1000.0, 0.85, 145)
    )

    treeData.foreach { t =>
      trees += new Tree(t._1, t._2, t._3, t._4)
    }

    val flowerZones = Array(
      (80.0, 120.0, 600.0, 340.0),
      (1150.0, 110.0, 570.0, 340.0),
      (80.0, 930.0, 620.0, 260.0),
      (1120.0, 950.0, 600.0, 250.0),
      (600.0, 400.0, 320.0, 220.0)
    )

    var i = 0

    while (i < 210) {
      val z = flowerZones(random.nextInt(flowerZones.length))

      flowers += new Flower(
        z._1 + random.nextDouble() * z._3,
        z._2 + random.nextDouble() * z._4,
        4.0 + random.nextDouble() * 8.0,
        Array(145, 164, 188, 282, 315, 48)(random.nextInt(6)),
        random.nextDouble() * 6.28
      )

      i += 1
    }

    i = 0

    while (i < 170) {
      stars += new Star(
        random.nextDouble() * WORLD_W,
        random.nextDouble() * WORLD_H,
        1 + random.nextInt(3),
        random.nextDouble() * 6.28
      )
      i += 1
    }

    i = 0

    while (i < 100) {
      particles += new Particle(
        random.nextDouble() * WORLD_W,
        random.nextDouble() * WORLD_H,
        (random.nextDouble() - 0.5) * 8.0,
        -5.0 - random.nextDouble() * 18.0,
        2.0 + random.nextDouble() * 3.0,
        Array(150, 165, 188, 285, 315, 48)(random.nextInt(6)),
        4.0 + random.nextDouble() * 4.0
      )
      i += 1
    }

    val count = levels(level).villainCount
    i = 0

    while (i < count) {
      val vx = 540.0 + i * 390.0
      val vy = 520.0 + (i % 2) * 300.0

      villains += new Villain(
        vx,
        vy,
        90.0 + i * 18.0,
        0.8 + i * 0.25,
        i * 1.7,
        if (i % 2 == 0) 330 else 275
      )

      i += 1
    }

    playerX = spawnX
    playerY = spawnY

    facingX = 0.0
    facingY = -1.0

    cameraX = 0.0
    cameraY = 0.0

    frames = 0L
    remainingSeconds = 360.0
    health = 3

    started = true
    paused = false
    won = false
    lost = false

    dashTime = 0.0
    dashCooldown = 0.0
    hitCooldown = 0.0

    glowMode = true
    showMap = true

    showToast(
      "Level " + level + " - " + levels(level).name +
      ". Find every crystal!",
      4.0
    )
  }

  def startGame(level: Int, mode: String): Unit = {

    multiplayerMode = mode

    setupLevel(level)

    if (gameTimer != null) gameTimer.stop()

    if (gamePanel != null) {
      root.remove(gamePanel)
    }

    gamePanel = new GamePanel()

    root.add(gamePanel, "game")

    showCard("game")

    gameTimer = new Timer(
      16,
      new ActionListener {
        override def actionPerformed(e: ActionEvent): Unit = {
          updateGame(0.016)
          if (gamePanel != null) gamePanel.repaint()
        }
      }
    )

    gameTimer.start()
    gamePanel.requestFocusInWindow()
  }

  def returnToLevels(): Unit = {
    if (gameTimer != null) gameTimer.stop()
    closeNetwork()
    showCard("levels")
    refreshLevelButtons()
  }

  def restartCurrentLevel(): Unit = {
    setupLevel(activeLevel)
    if (gamePanel != null) gamePanel.requestFocusInWindow()
  }

  def closeNetwork(): Unit = {
    lanClient.foreach(_.close())
    lanClient = None

    lanServer.foreach(_.close())
    lanServer = None
  }

  // ============================================================
  // COLLECTION AND GAMEPLAY
  // ============================================================

  def collectOrUse(): Unit = {

    if (!started || paused || won || lost) return

    val nearest = crystals
      .filter(!_.collected)
      .sortBy(c => dist(playerX, playerY, c.x, c.y))
      .headOption

    nearest match {

      case Some(c) if dist(playerX, playerY, c.x, c.y) < 92.0 =>

        val crystalIndex = crystals.indexOf(c)

        c.collected = true

        if (multiplayerMode == "HOST") {
          lanServer.foreach(_.markCrystalCollected(crystalIndex))
        }

        if (multiplayerMode == "JOIN") {
          lanClient.foreach(_.collectCrystal(crystalIndex))
        }

        showToast(
          "CRYSTAL FOUND: " + c.label +
          "  [" + collectedCount + "/" + crystals.length + "]",
          2.6
        )

        spawnBurst(c.x, c.y, c.hue, 24)

        if (collectedCount == crystals.length) {
          showToast("ALL CRYSTALS FOUND! Reach the glowing portal.", 4.0)
        }

      case _ =>

        if (dist(playerX, playerY, portalX, portalY) < 120.0) {

          if (collectedCount == crystals.length) {

            won = true
            completedLevels += activeLevel

            showToast(
              "LEVEL COMPLETE! Great adventure, " +
              currentUser + "!",
              8.0
            )

            spawnBurst(portalX, portalY, 165, 85)

          } else {
            showToast(
              "PORTAL LOCKED - collect all " +
              crystals.length + " crystals first.",
              2.8
            )
          }

        } else {
          showToast(
            "Move closer to a crystal or the portal, then press E.",
            2.4
          )
        }
    }
  }

  def spawnBurst(
    x: Double,
    y: Double,
    hue: Int,
    amount: Int
  ): Unit = {

    var i = 0

    while (i < amount) {

      val a = random.nextDouble() * math.Pi * 2.0
      val speed = 25.0 + random.nextDouble() * 125.0

      particles += new Particle(
        x,
        y,
        math.cos(a) * speed,
        math.sin(a) * speed,
        2.0 + random.nextDouble() * 3.5,
        hue,
        0.8 + random.nextDouble() * 1.0
      )

      i += 1
    }
  }

  def updateGame(dt: Double): Unit = {

    frames += 1

    if (toastTime > 0.0) {
      toastTime = math.max(0.0, toastTime - dt)
    }

    if (dashTime > 0.0) {
      dashTime = math.max(0.0, dashTime - dt)
    }

    if (dashCooldown > 0.0) {
      dashCooldown = math.max(0.0, dashCooldown - dt)
    }

    if (hitCooldown > 0.0) {
      hitCooldown = math.max(0.0, hitCooldown - dt)
    }

    particles.foreach { p =>
      p.x += p.vx * dt
      p.y += p.vy * dt
      p.life -= dt

      if (p.y < 0) p.y = WORLD_H
      if (p.x < 0) p.x = WORLD_W
      if (p.x > WORLD_W) p.x = 0
    }

    val expired = particles.filter(_.life <= 0.0)
    expired.foreach(p => particles -= p)

    while (particles.length < 90) {

      particles += new Particle(
        random.nextDouble() * WORLD_W,
        WORLD_H - 5,
        (random.nextDouble() - 0.5) * 12.0,
        -6.0 - random.nextDouble() * 17.0,
        2.0 + random.nextDouble() * 3.0,
        Array(150, 165, 188, 285, 315, 48)(random.nextInt(6)),
        4.0
      )
    }

    villains.foreach { v =>
      v.x = v.baseX + math.sin(frames * 0.025 * v.speed + v.phase) * v.range
      v.y = v.baseY + math.cos(frames * 0.018 + v.phase) * 13.0
    }

    if (!started || paused || won || lost) return

    remainingSeconds = math.max(0.0, remainingSeconds - dt)

    if (remainingSeconds <= 0.0) {
      lost = true
      showToast("TIME OVER! Press R to retry this level.", 8.0)
      return
    }

    var mx = 0.0
    var my = 0.0

    if (keys.contains(KeyEvent.VK_W) || keys.contains(KeyEvent.VK_UP)) my -= 1.0
    if (keys.contains(KeyEvent.VK_S) || keys.contains(KeyEvent.VK_DOWN)) my += 1.0
    if (keys.contains(KeyEvent.VK_A) || keys.contains(KeyEvent.VK_LEFT)) mx -= 1.0
    if (keys.contains(KeyEvent.VK_D) || keys.contains(KeyEvent.VK_RIGHT)) mx += 1.0

    val len = math.sqrt(mx * mx + my * my)

    if (len > 0.0) {
      mx /= len
      my /= len

      facingX = mx
      facingY = my

      val speed =
        if (dashTime > 0.0) 410.0
        else 205.0

      playerX = clamp(
        playerX + mx * speed * dt,
        40.0,
        WORLD_W - 40.0
      )

      playerY = clamp(
        playerY + my * speed * dt,
        40.0,
        WORLD_H - 40.0
      )
    }

    villains.foreach { v =>

      if (
        hitCooldown <= 0.0 &&
        dist(playerX, playerY, v.x, v.y) < 39.0 &&
        dashTime <= 0.0
      ) {

        health -= 1
        hitCooldown = 1.5

        playerX = clamp(
          playerX - facingX * 65.0,
          40.0,
          WORLD_W - 40.0
        )

        playerY = clamp(
          playerY - facingY * 65.0,
          40.0,
          WORLD_H - 40.0
        )

        showToast(
          "A garden shadow caught you! Hearts left: " + health,
          2.0
        )

        spawnBurst(playerX, playerY, 330, 12)

        if (health <= 0) {
          lost = true
          showToast("ADVENTURE OVER! Press R to retry.", 8.0)
        }
      }
    }

    multiplayerMode match {

      case "HOST" =>
        lanServer.foreach { server =>

          server.collectedSnapshot().foreach { index =>
            if (index >= 0 && index < crystals.length) {
              crystals(index).collected = true
            }
          }

          server.publishHostPosition(playerX, playerY)
        }

      case "JOIN" =>
        lanClient.foreach { client =>

          client.collectedSnapshot().foreach { index =>
            if (index >= 0 && index < crystals.length) {
              crystals(index).collected = true
            }
          }

          client.sendPosition(playerX, playerY)
        }

      case _ => ()
    }
  }

  // ============================================================
  // DRAWING
  // ============================================================

  def drawGlow(
    g: Graphics2D,
    x: Int,
    y: Int,
    r: Int,
    color: Color,
    alpha: Float
  ): Unit = {

    val old = g.getComposite

    g.setComposite(
      AlphaComposite.SrcOver.derive(alpha)
    )

    g.setColor(color)
    g.fillOval(x - r, y - r, r * 2, r * 2)

    g.setComposite(old)
  }

  def drawCentered(
    g: Graphics2D,
    text: String,
    x: Int,
    y: Int
  ): Unit = {
    g.drawString(
      text,
      x - g.getFontMetrics.stringWidth(text) / 2,
      y
    )
  }

  def drawBackground(
    g: Graphics2D,
    w: Int,
    h: Int
  ): Unit = {

    val hue = levels(activeLevel).skyHue
    val top = hueColor(hue, 0.70f, 0.20f)
    val bottom = new Color(14, 72, 66)

    g.setPaint(
      new GradientPaint(0f, 0f, top, 0f, h.toFloat, bottom)
    )

    g.fillRect(0, 0, w, h)

    stars.foreach { s =>

      val x = sx(s.x)
      val y = sy(s.y)

      if (x >= 0 && y >= 0 && x < w && y < h) {

        val a =
          110 + math.abs(
            (math.sin(frames * 0.08 + s.phase) * 130).toInt
          )

        g.setColor(new Color(255, 255, 255, a))
        g.fillOval(x, y, s.size, s.size)
      }
    }

    val moonX = sx(1500)
    val moonY = sy(140)

    drawGlow(g, moonX, moonY, 105, PURPLE, 0.12f)

    g.setColor(new Color(255, 236, 185))
    g.fillOval(moonX - 38, moonY - 38, 76, 76)
  }

  def drawGround(
    g: Graphics2D,
    w: Int,
    h: Int
  ): Unit = {

    val groundTop = (h * 0.30).toInt

    g.setPaint(
      new GradientPaint(
        0f,
        groundTop.toFloat,
        new Color(47, 145, 91),
        0f,
        h.toFloat,
        new Color(8, 48, 46)
      )
    )

    g.fillRect(0, groundTop, w, h - groundTop)

    val old = g.getComposite

    g.setComposite(
      AlphaComposite.SrcOver.derive(0.08f)
    )

    g.setColor(CYAN)

    var x = 0
    while (x < w) {
      g.drawLine(x, groundTop, x, h)
      x += 85
    }

    var y = groundTop
    while (y < h) {
      g.drawLine(0, y, w, y)
      y += 85
    }

    g.setComposite(old)
  }

  def drawTree(g: Graphics2D, t: Tree): Unit = {

    val x = sx(t.x)
    val y = sy(t.y)
    val s = t.scale

    if (x < -130 || y < -180 || x > SCREEN_W + 130 || y > SCREEN_H + 130) return

    g.setColor(new Color(79, 53, 37))

    g.fillRoundRect(
      (x - 8 * s).toInt,
      (y - 75 * s).toInt,
      (16 * s).toInt,
      (80 * s).toInt,
      8,
      8
    )

    g.setColor(hueColor(t.hue, 0.65f, 0.43f))

    g.fillOval(
      (x - 42 * s).toInt,
      (y - 144 * s).toInt,
      (84 * s).toInt,
      (86 * s).toInt
    )

    g.setColor(hueColor(t.hue, 0.58f, 0.64f))

    g.fillOval(
      (x - 31 * s).toInt,
      (y - 158 * s).toInt,
      (65 * s).toInt,
      (65 * s).toInt
    )

    g.setColor(new Color(180, 255, 213, 110))

    g.drawArc(
      (x - 26 * s).toInt,
      (y - 151 * s).toInt,
      (54 * s).toInt,
      (49 * s).toInt,
      35,
      120
    )
  }

  def drawFlower(g: Graphics2D, f: Flower): Unit = {

    val x = sx(f.x)
    val y = sy(f.y)

    if (x < -25 || y < -25 || x > SCREEN_W + 25 || y > SCREEN_H + 25) return

    val r = math.max(
      3,
      (f.size * (0.86 + 0.14 * math.sin(frames * 0.06 + f.phase))).toInt
    )

    g.setColor(new Color(35, 117, 70))
    g.drawLine(x, y + 2, x, y + r * 3)

    g.setColor(hueColor(f.hue))

    var i = 0

    while (i < 5) {
      val a = i * math.Pi * 2.0 / 5.0
      g.fillOval(
        x + (math.cos(a) * r * 0.7).toInt - r / 2,
        y + (math.sin(a) * r * 0.7).toInt - r / 2,
        r,
        r
      )
      i += 1
    }

    g.setColor(GOLD)
    g.fillOval(x - 3, y - 3, 6, 6)
  }

  def drawWaterfall(g: Graphics2D): Unit = {

    val x = sx(1510)
    val y = sy(170)

    g.setColor(new Color(70, 91, 91))
    g.fillRoundRect(x - 120, y - 25, 240, 440, 30, 30)

    g.setPaint(
      new GradientPaint(
        x - 45,
        y.toFloat,
        new Color(192, 250, 255),
        x + 55,
        (y + 390).toFloat,
        new Color(17, 116, 219)
      )
    )

    g.fillRoundRect(x - 52, y + 25, 104, 360, 22, 22)

    for (i <- 0 until 15) {

      val wx = x - 42 + i * 6
      val wy = y + 35 + ((frames * 6 + i * 27) % 320).toInt

      g.setColor(new Color(238, 255, 255, 190))
      g.fillRoundRect(wx, wy, 3, 29, 5, 5)
    }

    g.setColor(new Color(30, 166, 220, 230))
    g.fillOval(x - 150, y + 350, 300, 88)

    g.setColor(new Color(180, 250, 255, 150))
    g.drawOval(x - 90, y + 370, 180, 35)
    g.drawOval(x - 120, y + 382, 240, 45)
  }

  def drawCrystal(g: Graphics2D, c: Crystal): Unit = {

    if (c.collected) return

    val x = sx(c.x)
    val y = sy(c.y)

    if (x < -90 || y < -90 || x > SCREEN_W + 90 || y > SCREEN_H + 90) return

    val color = hueColor(c.hue)
    val r = (16 + 3 * math.sin(frames * 0.08 + c.hue)).toInt

    if (glowMode) {
      drawGlow(g, x, y, r * 4, color, 0.13f)
    }

    val px = Array(x, x + r, x, x - r)
    val py = Array(y - r, y, y + r, y)

    g.setColor(color)
    g.fillPolygon(px, py, 4)

    g.setColor(WHITE)
    g.drawPolygon(px, py, 4)

    g.setFont(new Font("SansSerif", Font.BOLD, 10))
    drawCentered(g, c.label, x, y - r - 19)

    g.setColor(CYAN)
    drawCentered(g, "E - COLLECT", x, y + r + 18)
  }

  def drawVillain(g: Graphics2D, v: Villain): Unit = {

    val x = sx(v.x)
    val y = sy(v.y)

    if (x < -70 || y < -70 || x > SCREEN_W + 70 || y > SCREEN_H + 70) return

    drawGlow(g, x, y, 34, hueColor(v.hue), 0.12f)

    g.setColor(new Color(31, 23, 60))
    g.fillOval(x - 24, y - 22, 48, 44)

    g.setColor(hueColor(v.hue, 0.76f, 0.95f))
    g.fillOval(x - 20, y - 20, 40, 37)

    g.setColor(new Color(255, 245, 220))
    g.fillOval(x - 11, y - 5, 7, 9)
    g.fillOval(x + 4, y - 5, 7, 9)

    g.setColor(new Color(30, 24, 48))
    g.fillOval(x - 9, y - 2, 3, 4)
    g.fillOval(x + 6, y - 2, 3, 4)
    g.drawArc(x - 8, y + 3, 16, 9, 190, 160)

    g.setColor(WHITE)
    g.setFont(new Font("SansSerif", Font.BOLD, 9))
    drawCentered(g, "SHADOW", x, y - 30)
  }

  def drawPortal(g: Graphics2D): Unit = {

    val x = sx(portalX)
    val y = sy(portalY)

    val ready = collectedCount == crystals.length
    val c = if (ready) MINT else PURPLE

    val pulse = 1.0 + math.sin(frames * 0.05) * 0.06
    val w = (116 * pulse).toInt
    val h = (148 * pulse).toInt

    if (glowMode) drawGlow(g, x, y, 105, c, 0.12f)

    g.setColor(new Color(8, 14, 35, 235))
    g.fillOval(x - w / 2, y - h / 2, w, h)

    g.setColor(c)
    g.setStroke(new BasicStroke(5.0f))
    g.drawOval(x - w / 2, y - h / 2, w, h)

    g.setColor(new Color(80, 230, 245, 140))
    g.setStroke(new BasicStroke(2.0f))
    g.drawOval(x - w / 2 + 12, y - h / 2 + 13, w - 24, h - 26)

    for (i <- 0 until 7) {
      val a = frames * 0.03 + i * math.Pi * 2.0 / 7.0
      val px = x + (math.cos(a) * 43).toInt
      val py = y + (math.sin(a) * 56).toInt
      g.setColor(if (i % 2 == 0) CYAN else PINK)
      g.fillOval(px - 3, py - 3, 6, 6)
    }

    g.setFont(new Font("SansSerif", Font.BOLD, 11))
    g.setColor(c)
    drawCentered(g, if (ready) "PORTAL READY" else "PORTAL LOCKED", x, y + h / 2 + 20)
  }

  def drawPlayer(g: Graphics2D, xWorld: Double, yWorld: Double, isLocal: Boolean): Unit = {

    val x = sx(xWorld)
    val y = sy(yWorld)

    val bob =
      if (isLocal) math.sin(frames * 0.2) * 2.0
      else 0.0

    if (isLocal && glowMode) drawGlow(g, x, y, 39, CYAN, 0.10f)

    g.setColor(new Color(0, 0, 0, 80))
    g.fillOval(x - 18, y + 14, 36, 10)

    g.setColor(new Color(30, 45, 78))
    g.fillRoundRect(x - 12, y + bob.toInt, 10, 20, 5, 5)
    g.fillRoundRect(x + 3, y - bob.toInt, 10, 20, 5, 5)

    g.setColor(CYAN)
    g.fillRoundRect(x - 13, y + 15, 12, 6, 3, 3)
    g.fillRoundRect(x + 2, y + 15, 12, 6, 3, 3)

    g.setColor(new Color(34, 80, 137))
    g.fillRoundRect(x - 17, y - 25 + bob.toInt, 34, 34, 10, 10)

    g.setColor(new Color(57, 190, 194))
    g.fillRoundRect(x - 13, y - 21 + bob.toInt, 26, 25, 8, 8)

    g.setColor(new Color(239, 207, 177))
    g.fillOval(x - 11, y - 47 + bob.toInt, 22, 23)

    g.setColor(new Color(38, 30, 48))
    g.fillArc(x - 12, y - 53 + bob.toInt, 24, 25, 0, 180)

    g.setColor(new Color(13, 42, 70))
    g.fillRoundRect(x - 9, y - 38 + bob.toInt, 18, 7, 4, 4)

    g.setColor(CYAN)
    g.drawLine(
      x - 5, y - 35 + bob.toInt,
      x + 5, y - 35 + bob.toInt
    )

    if (isLocal) {
      g.setFont(new Font("SansSerif", Font.BOLD, 10))
      g.setColor(WHITE)
      drawCentered(g, currentUser, x, y - 62)
    }
  }

  // FIX 2: Complete animated particle drawing method.
  def drawParticles(g: Graphics2D): Unit = {

    particles.foreach { p =>

      val x = sx(p.x)
      val y = sy(p.y)

      if (
        x >= -12 &&
        y >= -12 &&
        x <= SCREEN_W + 12 &&
        y <= SCREEN_H + 12
      ) {

        val alpha = clamp(p.life / 4.0, 0.0, 0.82).toFloat
        val oldComposite = g.getComposite

        try {
          g.setComposite(
            AlphaComposite.SrcOver.derive(alpha)
          )

          g.setColor(
            hueColor(p.hue, 0.66f, 1.0f)
          )

          val size = math.max(2, p.size.toInt)

          g.fillOval(
            x - size / 2,
            y - size / 2,
            size,
            size
          )

          g.setColor(new Color(255, 255, 255, 150))
          g.fillOval(x - 1, y - 1, math.max(1, size / 3), math.max(1, size / 3))

        } finally {
          g.setComposite(oldComposite)
        }
      }
    }
  }

  def drawNetworkPlayers(g: Graphics2D): Unit = {

    def drawEntries(entries: Seq[(String, String)]): Unit = {

      entries.foreach { pair =>

        val id = pair._1

        if (
          !id.equals("HOST-" + safeName(currentUser)) &&
          !id.startsWith(safeName(currentUser) + "-")
        ) {

          val xy = pair._2.split(",", 2)

          if (xy.length == 2) {
            try {
              drawPlayer(
                g,
                xy(0).toDouble,
                xy(1).toDouble,
                false
              )

              g.setFont(new Font("SansSerif", Font.BOLD, 10))
              g.setColor(GOLD)

              drawCentered(
                g,
                id.take(18),
                sx(xy(0).toDouble),
                sy(xy(1).toDouble) - 61
              )

            } catch {
              case _: NumberFormatException => ()
            }
          }
        }
      }
    }

    multiplayerMode match {
      case "HOST" => lanServer.foreach(s => drawEntries(s.snapshot()))
      case "JOIN" => lanClient.foreach(c => drawEntries(c.snapshot()))
      case _ => ()
    }
  }

  def drawMap(g: Graphics2D, w: Int): Unit = {

    val x = w - 188
    val y = 115
    val mw = 166
    val mh = 112

    g.setColor(new Color(5, 13, 29, 220))
    g.fillRoundRect(x, y, mw, mh, 13, 13)

    g.setColor(new Color(70, 225, 220, 170))
    g.drawRoundRect(x, y, mw, mh, 13, 13)

    def mx(v: Double): Int =
      x + 8 + (v / WORLD_W * (mw - 16)).toInt

    def my(v: Double): Int =
      y + 8 + (v / WORLD_H * (mh - 16)).toInt

    crystals.filter(!_.collected).foreach { c =>
      g.setColor(hueColor(c.hue))
      g.fillOval(mx(c.x) - 3, my(c.y) - 3, 6, 6)
    }

    g.setColor(MINT)
    g.fillOval(mx(portalX) - 4, my(portalY) - 4, 8, 8)

    g.setColor(WHITE)
    g.fillOval(mx(playerX) - 3, my(playerY) - 3, 6, 6)

    g.setFont(new Font("SansSerif", Font.BOLD, 9))
    g.drawString("LEVEL MAP", x + 9, y + 13)
  }

  def drawHUD(g: Graphics2D, w: Int, h: Int): Unit = {

    g.setColor(new Color(5, 15, 32, 225))
    g.fillRoundRect(15, 14, 465, 111, 17, 17)

    g.setColor(new Color(75, 230, 219, 170))
    g.drawRoundRect(15, 14, 465, 111, 17, 17)

    g.setFont(new Font("SansSerif", Font.BOLD, 18))
    g.setColor(CYAN)
    g.drawString("BIXUU  /  SPRING SEASON", 29, 40)

    g.setFont(new Font("SansSerif", Font.BOLD, 11))
    g.setColor(WHITE)
    g.drawString(
      "LEVEL " + activeLevel + " - " + levels(activeLevel).name,
      29,
      61
    )

    g.setFont(new Font("SansSerif", Font.PLAIN, 10))
    g.setColor(GOLD)
    g.drawString("EVENT: " + activeEventTitle.take(38), 29, 78)

    g.setFont(new Font("SansSerif", Font.BOLD, 11))
    g.setColor(WHITE)
    g.drawString(
      "CRYSTALS  " + collectedCount + " / " + crystals.length,
      29,
      101
    )

    g.setColor(new Color(34, 55, 72))
    g.fillRoundRect(250, 91, 205, 9, 8, 8)

    g.setColor(MINT)
    g.fillRoundRect(
      250, 91,
      (205.0 * collectedCount / crystals.length).toInt,
      9, 8, 8
    )

    val mm = remainingSeconds.toInt / 60
    val ss = remainingSeconds.toInt % 60

    g.setColor(new Color(5, 15, 32, 225))
    g.fillRoundRect(w - 185, 17, 165, 70, 14, 14)

    g.setFont(new Font("Monospaced", Font.BOLD, 22))
    g.setColor(if (remainingSeconds < 40) PINK else GOLD)

    g.drawString(
      "%02d:%02d".format(mm, ss),
      w - 164,
      49
    )

    g.setFont(new Font("SansSerif", Font.PLAIN, 10))
    g.setColor(WHITE)
    g.drawString("TIME LEFT", w - 162, 70)

    g.setColor(new Color(5, 15, 32, 225))
    g.fillRoundRect(15, h - 49, w - 30, 35, 12, 12)

    g.setFont(new Font("SansSerif", Font.BOLD, 11))
    g.setColor(MINT)
    g.drawString("WASD / ARROWS: MOVE", 28, h - 27)

    g.setColor(CYAN)
    g.drawString("E: COLLECT", 220, h - 27)

    g.setColor(PINK)
    g.drawString("SPACE: DASH", 330, h - 27)

    g.setColor(GOLD)
    g.drawString("F: GLOW", 455, h - 27)

    g.setColor(WHITE)
    g.drawString("M: MAP   ESC: PAUSE   R: RESTART / MENU", 555, h - 27)

    g.setFont(new Font("SansSerif", Font.BOLD, 13))
    g.setColor(PINK)
    g.drawString("HEARTS: " + ("? " * health), 500, 42)

    g.setColor(MUTED)
    g.setFont(new Font("SansSerif", Font.PLAIN, 10))

    g.drawString(
      if (multiplayerMode == "SOLO") "SOLO"
      else "LAN MULTIPLAYER - " + multiplayerMode,
      500,
      61
    )

    if (showMap) drawMap(g, w)

    if (toastTime > 0.0) {

      val tw = math.min(
        w - 50,
        math.max(340, g.getFontMetrics.stringWidth(toast) + 36)
      )

      val tx = (w - tw) / 2

      g.setColor(new Color(5, 16, 34, 235))
      g.fillRoundRect(tx, h - 96, tw, 32, 14, 14)

      g.setColor(CYAN)
      g.drawRoundRect(tx, h - 96, tw, 32, 14, 14)

      g.setFont(new Font("SansSerif", Font.BOLD, 11))
      g.setColor(WHITE)

      drawCentered(g, toast, w / 2, h - 76)
    }
  }

  def drawOverlay(g: Graphics2D, w: Int, h: Int): Unit = {

    g.setColor(new Color(3, 8, 22, 210))
    g.fillRect(0, 0, w, h)

    val title =
      if (won) "LEVEL COMPLETE!"
      else if (lost) "ADVENTURE OVER"
      else "PAUSED"

    val sub =
      if (won) "You restored the spring and opened the portal."
      else if (lost) "Try again and avoid the garden shadows."
      else "Your adventure is paused."

    g.setFont(new Font("SansSerif", Font.BOLD, 38))
    g.setColor(if (won) MINT else if (lost) PINK else CYAN)

    drawCentered(g, title, w / 2, h / 2 - 45)

    g.setFont(new Font("SansSerif", Font.PLAIN, 16))
    g.setColor(WHITE)
    drawCentered(g, sub, w / 2, h / 2 - 8)

    g.setFont(new Font("SansSerif", Font.BOLD, 15))
    g.setColor(GOLD)

    drawCentered(
      g,
      if (won) "ENTER: NEXT LEVEL     R: LEVEL SELECT"
      else "R: RETRY THIS LEVEL     ESC: RESUME",
      w / 2,
      h / 2 + 40
    )
  }

  // ============================================================
  // GAME PANEL
  // ============================================================

  final class GamePanel extends JPanel {

    setPreferredSize(new Dimension(SCREEN_W, SCREEN_H))
    setBackground(BG_TOP)
    setFocusable(true)
    setDoubleBuffered(true)

    addKeyListener(new KeyAdapter {

      override def keyPressed(e: KeyEvent): Unit = {

        val k = e.getKeyCode
        val fresh = !keys.contains(k)

        keys += k

        if (fresh) {

          if (!started && (k == KeyEvent.VK_ENTER || k == KeyEvent.VK_SPACE)) {
            started = true
          }

          else if (k == KeyEvent.VK_E) {
            collectOrUse()
          }

          else if (k == KeyEvent.VK_SPACE && started && !won && !lost) {
            if (dashCooldown <= 0.0) {
              dashTime = 0.22
              dashCooldown = 1.0
            }
          }

          else if (k == KeyEvent.VK_F) {
            glowMode = !glowMode
          }

          else if (k == KeyEvent.VK_M) {
            showMap = !showMap
          }

          else if (k == KeyEvent.VK_ESCAPE && !won && !lost) {
            paused = !paused
          }

          else if (k == KeyEvent.VK_R) {

            if (won) {
              returnToLevels()
            }

            else if (lost) {
              if (multiplayerMode == "SOLO") restartCurrentLevel()
              else returnToLevels()
            }

            else if (paused) {
              paused = false
            }

            else {
              returnToLevels()
            }
          }

          else if (k == KeyEvent.VK_ENTER && won) {

            if (multiplayerMode != "SOLO") {
              JOptionPane.showMessageDialog(
                frame,
                "Return to Level Select before starting another multiplayer adventure."
              )
              returnToLevels()
            }

            else if (activeLevel < 3) {
              startGame(activeLevel + 1, "SOLO")
            }

            else {
              returnToLevels()
            }
          }
        }
      }

      override def keyReleased(e: KeyEvent): Unit = {
        keys -= e.getKeyCode
      }
    })

    addMouseListener(new MouseAdapter {
      override def mouseClicked(e: MouseEvent): Unit = {
        requestFocusInWindow()
      }
    })

    override protected def paintComponent(graphics: Graphics): Unit = {

      super.paintComponent(graphics)

      val g = graphics.create().asInstanceOf[Graphics2D]

      try {

        g.setRenderingHint(
          RenderingHints.KEY_ANTIALIASING,
          RenderingHints.VALUE_ANTIALIAS_ON
        )

        g.setRenderingHint(
          RenderingHints.KEY_RENDERING,
          RenderingHints.VALUE_RENDER_QUALITY
        )

        val w = getWidth
        val h = getHeight

        cameraX = clamp(
          playerX - w / 2.0,
          0.0,
          WORLD_W - w
        )

        cameraY = clamp(
          playerY - h / 2.0,
          0.0,
          WORLD_H - h
        )

        drawBackground(g, w, h)
        drawGround(g, w, h)
        drawWaterfall(g)

        trees.foreach(t => drawTree(g, t))
        flowers.foreach(f => drawFlower(g, f))
        crystals.foreach(c => drawCrystal(g, c))
        villains.foreach(v => drawVillain(g, v))

        drawPortal(g)
        drawParticles(g)
        drawNetworkPlayers(g)
        drawPlayer(g, playerX, playerY, true)

        drawHUD(g, w, h)

        if (paused || won || lost) {
          drawOverlay(g, w, h)
        }

      } finally {
        g.dispose()
      }
    }
  }

  // ============================================================
  // LOGIN SCREEN
  // ============================================================

  def buildLoginScreen(): JPanel = {

    val p = verticalPanel()

    p.add(Box.createVerticalGlue())

    addCentered(p, heading("BIXUU", 22, MINT), 4)
    addCentered(p, heading("SPRING SEASON", 39, CYAN), 8)
    addCentered(p, heading("EVENT ADVENTURE WORLD", 18, PINK), 2)

    addCentered(
      p,
      bodyLabel("Login with a player name to enter the garden."),
      18
    )

    val form = new JPanel(new GridBagLayout())
    form.setOpaque(false)

    val c = new GridBagConstraints()
    c.insets = new Insets(7, 7, 7, 7)
    c.fill = GridBagConstraints.HORIZONTAL

    c.gridx = 0
    c.gridy = 0

    val nameLabel = new JLabel("Player name")
    nameLabel.setForeground(WHITE)
    form.add(nameLabel, c)

    c.gridx = 1

    nameField = new JTextField("Player", 18)
    form.add(nameField, c)

    c.gridx = 0
    c.gridy = 1

    val pinLabel = new JLabel("Admin PIN (Bixuu only)")
    pinLabel.setForeground(WHITE)
    form.add(pinLabel, c)

    c.gridx = 1

    pinField = new JPasswordField(18)
    form.add(pinField, c)

    p.add(Box.createVerticalStrut(12))
    p.add(form)

    val login = styledButton("LOGIN / CREATE PROFILE")

    addCentered(p, login, 12)

    addCentered(
      p,
      bodyLabel("Admin demo login: Bixuu   |   PIN: " + ADMIN_PIN),
      7
    )

    addCentered(
      p,
      bodyLabel("Local demo profile - no online account required."),
      4
    )

    p.add(Box.createVerticalGlue())

    addAction(login) {

      val typed = safeName(nameField.getText)
      val pin = new String(pinField.getPassword)

      if (typed.equalsIgnoreCase(ADMIN_NAME) && pin == ADMIN_PIN) {
        currentUser = ADMIN_NAME
        isAdmin = true
      }

      else {
        currentUser = typed
        isAdmin = false

        if (typed.equalsIgnoreCase(ADMIN_NAME)) {
          JOptionPane.showMessageDialog(
            frame,
            "Admin PIN is not correct. You will enter as a regular player."
          )
        }
      }

      updateMenuWelcome()
      adminButton.setVisible(isAdmin)
      showCard("menu")
    }

    p
  }

  // ============================================================
  // MAIN MENU
  // ============================================================

  def buildMenuScreen(): JPanel = {

    val p = verticalPanel()

    p.add(Box.createVerticalGlue())

    p.add(heading("BIXUU SPRING SEASON", 32, CYAN))
    p.add(Box.createVerticalStrut(5))

    welcomeLabel = bodyLabel("Welcome, Player")
    p.add(welcomeLabel)

    p.add(Box.createVerticalStrut(24))

    val levelsBtn = styledButton("LEVELS & ADVENTURES")
    val multiBtn = styledButton("MULTIPLAYER - HOTSPOT / WI-FI")

    adminButton = styledButton("BIXUU ADMIN PANEL")
    val logoutBtn = styledButton("LOG OUT")

    addCentered(p, levelsBtn, 8)
    addCentered(p, multiBtn, 8)
    addCentered(p, adminButton, 8)
    addCentered(p, logoutBtn, 8)

    adminButton.setVisible(false)

    p.add(Box.createVerticalGlue())

    addAction(levelsBtn) {
      refreshLevelButtons()
      showCard("levels")
    }

    addAction(multiBtn) {
      showCard("multiplayer")
    }

    addAction(adminButton) {
      refreshEventList()
      showCard("admin")
    }

    addAction(logoutBtn) {

      closeNetwork()

      if (gameTimer != null) gameTimer.stop()

      currentUser = "Player"
      isAdmin = false

      nameField.setText("Player")
      pinField.setText("")
      adminButton.setVisible(false)

      showCard("login")
    }

    p
  }

  def updateMenuWelcome(): Unit = {
    if (welcomeLabel != null) {
      welcomeLabel.setText(
        "Welcome, " + currentUser +
        (if (isAdmin) " - ADMIN" else " - PLAYER")
      )
    }
  }

  // ============================================================
  // LEVEL SELECT
  // ============================================================

  def buildLevelsScreen(): JPanel = {

    val p = verticalPanel()

    addCentered(p, heading("CHOOSE YOUR ADVENTURE", 30, CYAN), 3)

    addCentered(
      p,
      bodyLabel("Complete each level to unlock the next adventure."),
      4
    )

    addCentered(
      p,
      heading("LEVEL 1 - WHISPERING WOODS", 18, MINT),
      21
    )

    addCentered(
      p,
      bodyLabel("Collect 5 nature seeds.  |  1 Shadow"),
      3
    )

    addCentered(
      p,
      heading("LEVEL 2 - AURORA FALLS", 18, PINK),
      14
    )

    addCentered(
      p,
      bodyLabel("Collect 6 cosmic crystals.  |  2 Shadows"),
      3
    )

    addCentered(
      p,
      heading("LEVEL 3 - CELESTIAL WATERFALL TEMPLE", 18, GOLD),
      14
    )

    addCentered(
      p,
      bodyLabel("Collect 7 temple gems.  |  3 Shadows"),
      3
    )

    levelButtons.clear()

    val b1 = styledButton("PLAY LEVEL 1")
    val b2 = styledButton("PLAY LEVEL 2 - LOCKED")
    val b3 = styledButton("PLAY LEVEL 3 - LOCKED")

    levelButtons += b1
    levelButtons += b2
    levelButtons += b3

    addCentered(p, b1, 13)
    addCentered(p, b2, 7)
    addCentered(p, b3, 7)

    val back = styledButton("BACK TO MAIN MENU")
    addCentered(p, back, 15)

    p.add(Box.createVerticalGlue())

    addAction(b1) {
      startGame(1, "SOLO")
    }

    addAction(b2) {
      if (completedLevels.contains(1)) {
        startGame(2, "SOLO")
      } else {
        JOptionPane.showMessageDialog(
          frame,
          "Complete Level 1 first to unlock Aurora Falls."
        )
      }
    }

    addAction(b3) {
      if (completedLevels.contains(2)) {
        startGame(3, "SOLO")
      } else {
        JOptionPane.showMessageDialog(
          frame,
          "Complete Level 2 first to unlock Celestial Waterfall Temple."
        )
      }
    }

    addAction(back) {
      showCard("menu")
    }

    refreshLevelButtons()

    p
  }

  def refreshLevelButtons(): Unit = {

    if (levelButtons.length == 3) {

      levelButtons(0).setText(
        if (completedLevels.contains(1))
          "REPLAY LEVEL 1 - COMPLETED"
        else
          "PLAY LEVEL 1"
      )

      levelButtons(1).setEnabled(
        completedLevels.contains(1)
      )

      levelButtons(1).setText(
        if (completedLevels.contains(2))
          "REPLAY LEVEL 2 - COMPLETED"
        else if (completedLevels.contains(1))
          "PLAY LEVEL 2 - AURORA FALLS"
        else
          "PLAY LEVEL 2 - LOCKED"
      )

      levelButtons(2).setEnabled(
        completedLevels.contains(2)
      )

      levelButtons(2).setText(
        if (completedLevels.contains(3))
          "REPLAY LEVEL 3 - COMPLETED"
        else if (completedLevels.contains(2))
          "PLAY LEVEL 3 - CELESTIAL TEMPLE"
        else
          "PLAY LEVEL 3 - LOCKED"
      )
    }
  }

  // ============================================================
  // MULTIPLAYER LOBBY
  // ============================================================

  def buildMultiplayerScreen(): JPanel = {

    val p = verticalPanel()

    addCentered(p, heading("MULTIPLAYER LOBBY", 31, CYAN), 4)

    addCentered(
      p,
      bodyLabel("Play using the same Wi-Fi or mobile hotspot."),
      5
    )

    addCentered(
      p,
      bodyLabel("One device hosts a room. Other players join using the host IP."),
      3
    )

    hostIpLabel = bodyLabel(
      "Host IP: start a room to see the local address"
    )

    addCentered(p, hostIpLabel, 15)

    val hostPort = new JTextField(String.valueOf(DEFAULT_PORT), 8)
    val joinIp = new JTextField("192.168.1.2", 15)
    val joinPort = new JTextField(String.valueOf(DEFAULT_PORT), 8)

    val roomLevel = new JComboBox[String](Array(
      "Level 1 - Whispering Woods",
      "Level 2 - Aurora Falls",
      "Level 3 - Celestial Waterfall Temple"
    ))

    val levelRow = new JPanel()
    levelRow.setOpaque(false)

    levelRow.add(labelFor("Adventure level:"))
    levelRow.add(roomLevel)

    p.add(levelRow)

    val hostRow = new JPanel()
    hostRow.setOpaque(false)

    hostRow.add(labelFor("Host port:"))
    hostRow.add(hostPort)

    val hostBtn = styledButton("HOST MULTIPLAYER ROOM")

    p.add(hostRow)
    addCentered(p, hostBtn, 6)

    addCentered(
      p,
      bodyLabel("--- OR JOIN A FRIEND'S ROOM ---"),
      15
    )

    val joinRow = new JPanel()
    joinRow.setOpaque(false)

    joinRow.add(labelFor("Host IP:"))
    joinRow.add(joinIp)
    joinRow.add(labelFor("Port:"))
    joinRow.add(joinPort)

    p.add(joinRow)

    val joinBtn = styledButton("JOIN MULTIPLAYER ROOM")

    addCentered(p, joinBtn, 9)

    multiplayerStatus = bodyLabel("Status: not connected")

    addCentered(p, multiplayerStatus, 8)

    val back = styledButton("BACK TO MAIN MENU")

    addCentered(p, back, 13)

    p.add(Box.createVerticalGlue())

    addAction(hostBtn) {

      try {

        val port = hostPort.getText.trim.toInt

        require(
          port >= 1024 && port <= 65535,
          "Port must be between 1024 and 65535."
        )

        closeNetwork()

        val server = new LanServer(port, currentUser)
        server.start()

        lanServer = Some(server)

        val ip =
          try InetAddress.getLocalHost.getHostAddress
          catch {
            case _: Throwable => "Check your Wi-Fi IPv4 address"
          }

        hostIpLabel.setText(
          "HOST IP: " + ip + "    PORT: " + port
        )

        multiplayerStatus.setText("Status: room hosted.")

        JOptionPane.showMessageDialog(
          frame,
          "Your room is hosted.\n\nHost IP: " + ip +
          "\nPort: " + port +
          "\n\nOther devices must connect to the same Wi-Fi or mobile hotspot.",
          "Multiplayer room",
          JOptionPane.INFORMATION_MESSAGE
        )

        startGame(roomLevel.getSelectedIndex + 1, "HOST")

      } catch {
        case ex: Throwable =>
          JOptionPane.showMessageDialog(
            frame,
            "Could not host room: " + ex.getMessage +
            "\nTry another port or allow Java through the firewall.",
            "Host error",
            JOptionPane.ERROR_MESSAGE
          )
      }
    }

    addAction(joinBtn) {

      try {

        val ip = joinIp.getText.trim
        val port = joinPort.getText.trim.toInt

        require(ip.nonEmpty, "Enter the host IP.")
        require(port >= 1024 && port <= 65535, "Invalid port.")

        closeNetwork()

        val client = new LanClient(ip, port, currentUser)
        lanClient = Some(client)

        multiplayerStatus.setText(
          "Status: connected to " + ip + ":" + port
        )

        startGame(roomLevel.getSelectedIndex + 1, "JOIN")

      } catch {
        case ex: Throwable =>
          JOptionPane.showMessageDialog(
            frame,
            "Could not join room: " + ex.getMessage +
            "\nCheck the host IP, port, hotspot and firewall.",
            "Join error",
            JOptionPane.ERROR_MESSAGE
          )
      }
    }

    addAction(back) {
      showCard("menu")
    }

    p
  }

  def labelFor(text: String): JLabel = {
    val l = new JLabel(text)
    l.setForeground(WHITE)
    l.setFont(new Font("SansSerif", Font.BOLD, 13))
    l
  }

  // ============================================================
  // ADMIN PANEL
  // ============================================================

  def refreshEventList(): Unit = {

    if (eventListArea != null) {

      val all = ArrayBuffer[String](
        "SPRING SEASON - EVENT 01: GLITCH IN THE GARDEN",
        "SPRING SEASON - EVENT 02: " + activeEventTitle
      )

      extraEvents.foreach { e =>
        all += ("CUSTOM EVENT: " + e)
      }

      eventListArea.setText(all.mkString("\n"))
    }

    if (eventTitleField != null) {
      eventTitleField.setText(activeEventTitle)
    }
  }

  def buildAdminScreen(): JPanel = {

    val p = verticalPanel()

    addCentered(
      p,
      heading("BIXUU ADMIN CONTROL ROOM", 29, GOLD),
      3
    )

    addCentered(
      p,
      bodyLabel("Admin can edit the event title and add event cards."),
      4
    )

    val titleRow = new JPanel()
    titleRow.setOpaque(false)

    titleRow.add(labelFor("Event 02 title:"))

    eventTitleField = new JTextField(activeEventTitle, 28)

    titleRow.add(eventTitleField)
    p.add(titleRow)

    val apply = styledButton("SAVE EVENT TITLE")

    addCentered(p, apply, 8)

    val addRow = new JPanel()
    addRow.setOpaque(false)

    val newEventField = new JTextField(25)

    addRow.add(labelFor("New event:"))
    addRow.add(newEventField)

    p.add(addRow)

    val addEvent = styledButton("ADD EVENT CARD")

    addCentered(p, addEvent, 8)

    eventListArea = new JTextArea(6, 48)
    eventListArea.setEditable(false)
    eventListArea.setFont(new Font("Monospaced", Font.PLAIN, 13))
    eventListArea.setBackground(new Color(5, 15, 32))
    eventListArea.setForeground(MINT)

    val scroller = new JScrollPane(eventListArea)

    // JScrollPane is a JComponent.
    scroller.setAlignmentX(Component.CENTER_ALIGNMENT)

    p.add(Box.createVerticalStrut(8))
    p.add(scroller)

    addCentered(
      p,
      bodyLabel("Admin edits remain in memory for this session."),
      7
    )

    val back = styledButton("BACK TO MAIN MENU")

    addCentered(p, back, 8)

    p.add(Box.createVerticalGlue())

    addAction(apply) {

      val t = eventTitleField.getText.trim

      if (t.nonEmpty) {

        activeEventTitle = t.take(55)
        refreshEventList()

        JOptionPane.showMessageDialog(
          frame,
          "Event 02 display title updated."
        )
      }
    }

    addAction(addEvent) {

      val t = newEventField.getText.trim

      if (t.nonEmpty && !extraEvents.contains(t)) {

        extraEvents += t.take(55)
        newEventField.setText("")

        refreshEventList()
      }
    }

    addAction(back) {
      showCard("menu")
    }

    refreshEventList()

    p
  }

  // ============================================================
  // UI INITIALIZATION
  // ============================================================

  def buildUI(): Unit = {

    cards = new CardLayout()
    root = new JPanel(cards)

    root.add(buildLoginScreen(), "login")
    root.add(buildMenuScreen(), "menu")
    root.add(buildLevelsScreen(), "levels")
    root.add(buildMultiplayerScreen(), "multiplayer")
    root.add(buildAdminScreen(), "admin")

    frame = new JFrame(APP_TITLE)

    frame.setContentPane(root)

    frame.setDefaultCloseOperation(
      WindowConstants.DO_NOTHING_ON_CLOSE
    )

    frame.addWindowListener(
      new java.awt.event.WindowAdapter {

        override def windowClosing(
          e: java.awt.event.WindowEvent
        ): Unit = {

          if (gameTimer != null) {
            gameTimer.stop()
          }

          closeNetwork()
          frame.dispose()
        }
      }
    )

    frame.setMinimumSize(new Dimension(900, 650))
    frame.setSize(1050, 720)
    frame.setLocationRelativeTo(null)
    frame.setVisible(true)

    showCard("login")
  }

  def main(args: Array[String]): Unit = {

    SwingUtilities.invokeLater(
      new Runnable {
        override def run(): Unit = buildUI()
      }
    )
  }
}

BixuuSpringSeasonMultiplayer.main(Array[String]())