Code Sketch


Bixuu02
By: Mhalsakant School
Category: Programming

import java.awt.{AlphaComposite, BasicStroke, Color, Dimension, Font, Graphics, Graphics2D, GradientPaint, Polygon, RenderingHints, Cursor}
import java.awt.event.{ActionEvent, ActionListener, KeyAdapter, KeyEvent, MouseAdapter, MouseEvent}
import javax.swing.{JFrame, JPanel, SwingUtilities, Timer, WindowConstants}
import java.util.Random
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer

// ================================================================
// BIXUU - SPRING SEASON EVENT 02
// AURORA FALLS: THE LOST SPRING
//
// Original animated Scala + Java Swing exploration game.
// No external images, libraries or internet required.
//
// CONTROLS:
// WASD / ARROWS = MOVE
// E = COLLECT CRYSTAL / OPEN PORTAL
// SPACE = DASH
// F = TOGGLE GLOW
// M = TOGGLE MAP
// ESC = PAUSE
// R = RESTART
// ================================================================

object BixuuAuroraFalls {

  val APP_TITLE = "BIXUU - SPRING SEASON EVENT 02: AURORA FALLS"

  val WORLD_W = 1900
  val WORLD_H = 1350

  val SCREEN_W = 1180
  val SCREEN_H = 760

  val GOAL_COUNT = 6

  val random = new Random(20261031L)

  val BG_TOP = new Color(9, 14, 39)
  val BG_BOTTOM = new Color(15, 64, 70)

  val DEEP_GREEN = new Color(12, 49, 43)
  val GREEN = new Color(41, 135, 76)
  val MINT = new Color(133, 255, 201)
  val CYAN = new Color(73, 239, 240)
  val PINK = new Color(255, 125, 203)
  val PURPLE = new Color(174, 117, 255)
  val GOLD = new Color(255, 224, 132)
  val WHITE = new Color(245, 252, 255)

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

  final class GardenTree(
    val x: Double,
    val y: Double,
    val scale: Double,
    val hue: Int
  )

  final class GardenFlower(
    val x: Double,
    val y: Double,
    val size: Double,
    val hue: Int,
    val phase: Double,
    val special: Boolean
  )

  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
  )

  val keys = mutable.Set[Int]()

  val crystals = ArrayBuffer[Crystal]()
  val trees = ArrayBuffer[GardenTree]()
  val flowers = ArrayBuffer[GardenFlower]()
  val stars = ArrayBuffer[Star]()
  val particles = ArrayBuffer[Particle]()

  var playerX = 300.0
  var playerY = 1110.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 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 toast = "Welcome to Aurora Falls!"
  var toastTime = 4.0

  var glitchTime = 0.0
  var glitchActive = false

  var panel: GamePanel = null
  var window: JFrame = null
  var gameTimer: Timer = null

  val portalX = 1530.0
  val portalY = 560.0

  // --------------------------------------------------------------
  // HELPER METHODS
  // --------------------------------------------------------------

  def clamp(
    value: Double,
    minimum: Double,
    maximum: Double
  ): Double = {
    math.max(minimum, math.min(maximum, value))
  }

  def distance(
    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(
    hue: Int,
    saturation: Float = 0.82f,
    brightness: Float = 1.0f
  ): Color = {
    Color.getHSBColor(
      (hue.toDouble / 360.0).toFloat,
      saturation,
      brightness
    )
  }

  def showToast(message: String, duration: Double = 2.8): Unit = {
    toast = message
    toastTime = duration
  }

  def collectedCount: Int = {
    crystals.count(_.collected)
  }

  def screenX(x: Double): Int = {
    math.round(x - cameraX).toInt
  }

  def screenY(y: Double): Int = {
    math.round(y - cameraY).toInt
  }

  // --------------------------------------------------------------
  // WORLD GENERATION
  // --------------------------------------------------------------

  def setupWorld(): Unit = {

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

    // Six magical crystals
    crystals += new Crystal(300, 900, 155, "MINT SEED")
    crystals += new Crystal(590, 700, 190, "STAR PETAL")
    crystals += new Crystal(980, 920, 285, "MOON BLOOM")
    crystals += new Crystal(1330, 920, 48, "SUN CRYSTAL")
    crystals += new Crystal(1640, 760, 195, "WATER GEM")
    crystals += new Crystal(1040, 345, 315, "AURORA ORB")

    // Garden flower regions
    val zones = Array(
      (100.0, 150.0, 570.0, 300.0),
      (1200.0, 130.0, 560.0, 320.0),
      (100.0, 950.0, 650.0, 260.0),
      (1100.0, 1000.0, 650.0, 250.0),
      (650.0, 400.0, 300.0, 250.0),
      (1450.0, 520.0, 280.0, 280.0)
    )

    val flowerHues = Array(145, 164, 188, 282, 315, 48)

    var i = 0

    while (i < 220) {

      val z = zones(random.nextInt(zones.length))

      val x = z._1 + random.nextDouble() * z._3
      val y = z._2 + random.nextDouble() * z._4

      val hue = flowerHues(
        random.nextInt(flowerHues.length)
      )

      flowers += new GardenFlower(
        x,
        y,
        5.0 + random.nextDouble() * 8.0,
        hue,
        random.nextDouble() * 6.28,
        random.nextInt(8) == 0
      )

      i += 1
    }

    // Trees around the forest
    val treeData = Array(
      (120.0, 250.0, 1.1, 150),
      (340.0, 190.0, 0.9, 160),
      (670.0, 220.0, 1.0, 145),
      (1050.0, 180.0, 0.9, 155),
      (1740.0, 220.0, 1.1, 165),
      (1790.0, 620.0, 1.0, 150),
      (1680.0, 1100.0, 1.1, 160),
      (1270.0, 1190.0, 0.9, 145),
      (810.0, 1190.0, 1.0, 165),
      (360.0, 1200.0, 1.1, 155),
      (110.0, 820.0, 0.9, 160),
      (490.0, 520.0, 0.8, 150),
      (970.0, 700.0, 0.9, 165),
      (1320.0, 370.0, 0.8, 150)
    )

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

    // Cosmic stars
    i = 0

    while (i < 180) {

      stars += new Star(
        random.nextDouble() * WORLD_W,
        random.nextDouble() * WORLD_H,
        1 + random.nextInt(3),
        random.nextDouble() * 6.28
      )

      i += 1
    }

    // Ambient particles
    i = 0

    while (i < 100) {

      particles += new Particle(
        random.nextDouble() * WORLD_W,
        random.nextDouble() * WORLD_H,
        (random.nextDouble() - 0.5) * 8.0,
        -8.0 - random.nextDouble() * 18.0,
        2.0 + random.nextDouble() * 3.0,
        flowerHues(random.nextInt(flowerHues.length)),
        3.0 + random.nextDouble() * 7.0
      )

      i += 1
    }
  }

  // --------------------------------------------------------------
  // GAME STATE
  // --------------------------------------------------------------

  def startAdventure(): Unit = {

    if (!started && !won && !lost) {

      started = true
      paused = false

      showToast(
        "Adventure started! Find all six magical crystals.",
        4.0
      )
    }

    if (panel != null) {
      panel.requestFocusInWindow()
    }
  }

  def resetGame(): Unit = {

    playerX = 300.0
    playerY = 1110.0

    facingX = 0.0
    facingY = -1.0

    frames = 0L
    remainingSeconds = 360.0

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

    glowMode = true
    showMap = true

    dashTime = 0.0
    dashCooldown = 0.0

    crystals.foreach(_.collected = false)

    keys.clear()

    showToast(
      "BIXUU - SPRING SEASON EVENT 02",
      4.0
    )

    if (panel != null) {
      panel.requestFocusInWindow()
      panel.repaint()
    }
  }

  def triggerDash(): Unit = {

    if (
      started &&
      !paused &&
      !won &&
      !lost &&
      dashCooldown <= 0.0
    ) {

      dashTime = 0.22
      dashCooldown = 1.0

      showToast("COSMIC DASH!", 0.7)
    }
  }

  // --------------------------------------------------------------
  // COLLECT CRYSTALS AND OPEN PORTAL
  // --------------------------------------------------------------

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

    var i = 0

    while (i < amount) {

      val angle = random.nextDouble() * math.Pi * 2.0
      val speed = 35.0 + random.nextDouble() * 135.0

      particles += new Particle(
        x,
        y,
        math.cos(angle) * speed,
        math.sin(angle) * speed,
        2.0 + random.nextDouble() * 4.0,
        hue,
        0.7 + random.nextDouble() * 1.1
      )

      i += 1
    }
  }

  def interact(): Unit = {

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

    val nearby = crystals
      .filter(c => !c.collected)
      .sortBy(c => distance(playerX, playerY, c.x, c.y))
      .headOption

    nearby match {

      case Some(c)
        if distance(playerX, playerY, c.x, c.y) < 100.0 =>

        c.collected = true

        showToast(
          "CRYSTAL RESTORED: " +
          c.label +
          "  [" + collectedCount + "/" + GOAL_COUNT + "]",
          2.7
        )

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

        if (collectedCount == GOAL_COUNT) {

          showToast(
            "ALL CRYSTALS RESTORED! FIND THE WATERFALL PORTAL!",
            4.0
          )

          spawnBurst(portalX, portalY, 160, 55)
        }

      case _ =>

        if (
          distance(playerX, playerY, portalX, portalY) < 125.0
        ) {

          if (collectedCount >= GOAL_COUNT) {

            won = true

            showToast(
              "AURORA FALLS RESTORED! ADVENTURE COMPLETE!",
              9.0
            )

            spawnBurst(portalX, portalY, 165, 100)

          } else {

            showToast(
              "PORTAL LOCKED! COLLECT ALL SIX CRYSTALS.",
              2.8
            )
          }

        } else {

          showToast(
            "MOVE CLOSE TO A CRYSTAL OR THE PORTAL AND PRESS E.",
            2.5
          )
        }
    }
  }

  // --------------------------------------------------------------
  // GAME UPDATE AND ANIMATION
  // --------------------------------------------------------------

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

    glitchTime -= dt

    if (glitchTime <= 0.0) {

      glitchTime = 2.0 + random.nextDouble() * 3.0
      glitchActive = random.nextInt(5) == 0
    }

    // Update floating particles
    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 deadParticles = particles.filter(_.life <= 0.0)

    deadParticles.foreach { p =>
      particles -= p
    }

    while (particles.length < 95) {

      particles += new Particle(
        random.nextDouble() * WORLD_W,
        WORLD_H - 10,
        (random.nextDouble() - 0.5) * 14.0,
        -8.0 - random.nextDouble() * 20.0,
        2.0 + random.nextDouble() * 3.0,
        Array(150, 165, 188, 285, 315, 48)(
          random.nextInt(6)
        ),
        4.0
      )
    }

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

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

    if (remainingSeconds <= 0.0) {

      lost = true

      showToast(
        "TIME EXPIRED! PRESS R TO TRY AGAIN.",
        10.0
      )

      return
    }

    // Movement input
    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 length = math.sqrt(mx * mx + my * my)

    if (length > 0.0) {

      mx /= length
      my /= length

      facingX = mx
      facingY = my

      val speed =
        if (dashTime > 0.0) 430.0
        else 215.0

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

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

      if (dashTime > 0.0 && frames % 3 == 0) {

        particles += new Particle(
          playerX,
          playerY,
          -facingX * 20.0,
          -facingY * 20.0,
          4.0,
          185,
          0.6
        )
      }
    }
  }

  // --------------------------------------------------------------
  // DRAW HELPERS
  // --------------------------------------------------------------

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

    val old = g.getComposite

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

    g.setColor(color)

    g.fillOval(
      x - radius,
      y - radius,
      radius * 2,
      radius * 2
    )

    g.setComposite(old)
  }

  def drawCentered(
    g: Graphics2D,
    text: String,
    x: Int,
    y: Int
  ): Unit = {

    val width = g.getFontMetrics.stringWidth(text)

    g.drawString(
      text,
      x - width / 2,
      y
    )
  }

  // --------------------------------------------------------------
  // COSMIC SKY
  // --------------------------------------------------------------

  def drawCosmicSky(
    g: Graphics2D
  ): Unit = {

    val moonX = screenX(1580)
    val moonY = screenY(150)

    drawGlow(
      g,
      moonX,
      moonY,
      125,
      PURPLE,
      0.13f
    )

    g.setColor(new Color(255, 230, 167))
    g.fillOval(
      moonX - 48,
      moonY - 48,
      96,
      96
    )

    g.setColor(new Color(255, 248, 207))
    g.fillOval(
      moonX - 34,
      moonY - 35,
      66,
      66
    )

    stars.foreach { s =>

      val x = screenX(s.x)
      val y = screenY(s.y)

      if (
        x >= 0 &&
        y >= 0 &&
        x < SCREEN_W &&
        y < SCREEN_H
      ) {

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

        g.setColor(
          new Color(255, 255, 255, brightness)
        )

        g.fillOval(
          x,
          y,
          s.size,
          s.size
        )
      }
    }
  }

  // --------------------------------------------------------------
  // FOREST GROUND
  // --------------------------------------------------------------

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

    val old = g.getComposite

    g.setPaint(
      new GradientPaint(
        0f,
        0f,
        new Color(24, 98, 73),
        0f,
        h.toFloat,
        new Color(8, 43, 42)
      )
    )

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

    // Grass patches
    for (i <- 0 until 18) {

      val wx = (i * 131 + 55) % WORLD_W
      val wy = (i * 181 + 170) % WORLD_H

      val x = screenX(wx)
      val y = screenY(wy)

      g.setColor(
        new Color(66, 181, 110, 35)
      )

      g.fillOval(
        x - 65,
        y - 35,
        130,
        70
      )
    }

    // Soft cosmic grid
    g.setComposite(
      AlphaComposite.SrcOver.derive(0.08f)
    )

    g.setColor(CYAN)

    var x = 0

    while (x < w) {

      g.drawLine(x, 0, x, h)
      x += 80
    }

    var y = 0

    while (y < h) {

      g.drawLine(0, y, w, y)
      y += 80
    }

    g.setComposite(old)
  }

  // --------------------------------------------------------------
  // FOREST PATH
  // --------------------------------------------------------------

  def drawPaths(g: Graphics2D): Unit = {

    val routes = Array(
      (300.0, 1110.0, 300.0, 900.0),
      (300.0, 900.0, 590.0, 700.0),
      (590.0, 700.0, 980.0, 920.0),
      (980.0, 920.0, 1330.0, 920.0),
      (1330.0, 920.0, 1640.0, 760.0),
      (1640.0, 760.0, 1530.0, 560.0),
      (1530.0, 560.0, 1040.0, 345.0)
    )

    val oldStroke = g.getStroke

    g.setStroke(
      new BasicStroke(
        100.0f,
        BasicStroke.CAP_ROUND,
        BasicStroke.JOIN_ROUND
      )
    )

    g.setColor(new Color(19, 49, 51))

    routes.foreach { p =>

      g.drawLine(
        screenX(p._1),
        screenY(p._2),
        screenX(p._3),
        screenY(p._4)
      )
    }

    g.setStroke(
      new BasicStroke(
        75.0f,
        BasicStroke.CAP_ROUND,
        BasicStroke.JOIN_ROUND
      )
    )

    g.setColor(new Color(60, 112, 92, 180))

    routes.foreach { p =>

      g.drawLine(
        screenX(p._1),
        screenY(p._2),
        screenX(p._3),
        screenY(p._4)
      )
    }

    g.setStroke(oldStroke)
  }

  // --------------------------------------------------------------
  // TREES
  // --------------------------------------------------------------

  def drawTree(
    g: Graphics2D,
    tree: GardenTree
  ): Unit = {

    val x = screenX(tree.x)
    val y = screenY(tree.y)
    val s = tree.scale

    if (
      x < -150 ||
      y < -180 ||
      x > SCREEN_W + 150 ||
      y > SCREEN_H + 150
    ) {
      return
    }

    drawGlow(
      g,
      x,
      y - (85 * s).toInt,
      (48 * s).toInt,
      hueColor(tree.hue),
      0.08f
    )

    // Shadow
    g.setColor(new Color(0, 0, 0, 65))

    g.fillOval(
      (x - 44 * s).toInt,
      (y - 13 * s).toInt,
      (88 * s).toInt,
      (25 * s).toInt
    )

    // Trunk
    g.setColor(new Color(91, 54, 39))

    g.fillRoundRect(
      (x - 9 * s).toInt,
      (y - 77 * s).toInt,
      (18 * s).toInt,
      (82 * s).toInt,
      9,
      9
    )

    // Tree leaves
    g.setColor(
      hueColor(tree.hue, 0.65f, 0.47f)
    )

    g.fillOval(
      (x - 43 * s).toInt,
      (y - 143 * s).toInt,
      (82 * s).toInt,
      (84 * s).toInt
    )

    g.setColor(
      hueColor(tree.hue, 0.61f, 0.62f)
    )

    g.fillOval(
      (x - 32 * s).toInt,
      (y - 157 * s).toInt,
      (67 * s).toInt,
      (68 * s).toInt
    )

    g.setColor(new Color(170, 255, 207, 110))

    g.drawArc(
      (x - 27 * s).toInt,
      (y - 149 * s).toInt,
      (54 * s).toInt,
      (48 * s).toInt,
      40,
      120
    )
  }

  // --------------------------------------------------------------
  // FLOWERS
  // --------------------------------------------------------------

  def drawFlower(
    g: Graphics2D,
    flower: GardenFlower
  ): Unit = {

    val x = screenX(flower.x)
    val y = screenY(flower.y)

    if (
      x < -30 ||
      y < -30 ||
      x > SCREEN_W + 30 ||
      y > SCREEN_H + 30
    ) {
      return
    }

    val pulse =
      0.86 + 0.14 * math.sin(frames * 0.07 + flower.phase)

    val radius =
      math.max(3, (flower.size * pulse).toInt)

    val color =
      hueColor(flower.hue, 0.75f, 1.0f)

    if (flower.special && glowMode) {

      drawGlow(
        g,
        x,
        y,
        radius * 5,
        color,
        0.10f
      )
    }

    g.setColor(new Color(32, 114, 66))

    g.drawLine(
      x,
      y + 3,
      x,
      y + radius * 3
    )

    g.setColor(color)

    val petalsCount =
      if (flower.special) 8
      else 5

    var i = 0

    while (i < petalsCount) {

      val angle =
        i * math.Pi * 2.0 / petalsCount

      val px =
        x + (math.cos(angle) * radius * 0.8).toInt

      val py =
        y + (math.sin(angle) * radius * 0.8).toInt

      g.fillOval(
        px - radius / 2,
        py - radius / 2,
        radius,
        radius
      )

      i += 1
    }

    g.setColor(GOLD)

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

  // --------------------------------------------------------------
  // MAGICAL WATERFALL
  // --------------------------------------------------------------

  def drawWaterfall(
    g: Graphics2D
  ): Unit = {

    val x = screenX(1500)
    val y = screenY(170)

    // Rocky cliff
    g.setColor(new Color(71, 89, 91))

    g.fillRoundRect(
      x - 145,
      y - 20,
      290,
      505,
      35,
      35
    )

    g.setColor(new Color(117, 137, 127))

    g.fillOval(
      x - 140,
      y - 25,
      280,
      100
    )

    g.setColor(new Color(50, 77, 80))

    g.fillOval(
      x - 120,
      y + 5,
      75,
      55
    )

    // Main water stream
    g.setPaint(
      new GradientPaint(
        x - 55,
        y.toFloat,
        new Color(179, 249, 255),
        x + 55,
        (y + 425).toFloat,
        new Color(20, 116, 222)
      )
    )

    g.fillRoundRect(
      x - 56,
      y + 30,
      112,
      395,
      24,
      24
    )

    // Water stream highlights
    var i = 0

    while (i < 17) {

      val sx = x - 44 + i * 5

      val sy =
        y + 40 + ((frames * 6 + i * 29) % 360).toInt

      g.setColor(
        new Color(238, 255, 255, 185)
      )

      g.fillRoundRect(
        sx,
        sy,
        3,
        30,
        5,
        5
      )

      i += 1
    }

    // Water pool
    g.setColor(
      new Color(31, 170, 224, 235)
    )

    g.fillOval(
      x - 165,
      y + 380,
      330,
      100
    )

    g.setColor(
      new Color(158, 247, 255, 170)
    )

    g.fillOval(
      x - 90,
      y + 397,
      180,
      47
    )

    // Moving ripples
    i = 0

    while (i < 6) {

      val offset =
        ((frames * 3 + i * 27) % 95).toInt

      g.setColor(
        new Color(225, 255, 255, 115)
      )

      g.drawOval(
        x - 50 - offset / 2,
        y + 405 - offset / 6,
        100 + offset,
        30 + offset / 3
      )

      i += 1
    }

    // Splash particles
    for (j <- 0 until 10) {

      val sx =
        x - 95 + ((j * 23 + frames * 2) % 190).toInt

      val sy =
        y + 408 + (j * 13) % 35

      g.setColor(
        new Color(216, 250, 255, 160)
      )

      g.fillOval(
        sx,
        sy,
        4,
        7
      )
    }
  }

  // --------------------------------------------------------------
  // COSMIC CRYSTALS
  // --------------------------------------------------------------

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

    if (crystal.collected) {
      return
    }

    val x = screenX(crystal.x)
    val y = screenY(crystal.y)

    if (
      x < -120 ||
      y < -120 ||
      x > SCREEN_W + 120 ||
      y > SCREEN_H + 120
    ) {
      return
    }

    val color =
      hueColor(crystal.hue, 0.8f, 1.0f)

    val pulse =
      1.0 + 0.12 * math.sin(frames * 0.09 + crystal.hue)

    val r = (18 * pulse).toInt

    if (glowMode) {

      drawGlow(
        g,
        x,
        y,
        r * 5,
        color,
        0.12f
      )
    }

    val pointsX = Array(
      x,
      x + r,
      x,
      x - r
    )

    val pointsY = Array(
      y - r,
      y,
      y + r,
      y
    )

    g.setColor(color)

    g.fillPolygon(
      pointsX,
      pointsY,
      4
    )

    g.setColor(WHITE)

    g.setStroke(new BasicStroke(2.0f))

    g.drawPolygon(
      pointsX,
      pointsY,
      4
    )

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

    g.drawLine(
      x,
      y - r + 4,
      x + r / 2,
      y
    )

    // Crystal label
    g.setFont(
      new Font("SansSerif", Font.BOLD, 10)
    )

    g.setColor(WHITE)

    drawCentered(
      g,
      crystal.label,
      x,
      y - r - 20
    )

    g.setColor(CYAN)

    drawCentered(
      g,
      "E - COLLECT",
      x,
      y + r + 20
    )
  }

  // --------------------------------------------------------------
  // PORTAL
  // --------------------------------------------------------------

  def drawPortal(
    g: Graphics2D
  ): Unit = {

    val x = screenX(portalX)
    val y = screenY(portalY)

    val ready =
      collectedCount >= GOAL_COUNT

    val pulse =
      1.0 + math.sin(frames * 0.05) * 0.06

    val w = (125 * pulse).toInt
    val h = (155 * pulse).toInt

    val portalColor =
      if (ready) MINT
      else PURPLE

    if (glowMode) {

      drawGlow(
        g,
        x,
        y,
        115,
        portalColor,
        0.12f
      )
    }

    g.setColor(new Color(7, 14, 35))

    g.fillOval(
      x - w / 2,
      y - h / 2,
      w,
      h
    )

    g.setColor(portalColor)

    g.setStroke(
      new BasicStroke(6.0f)
    )

    g.drawOval(
      x - w / 2,
      y - h / 2,
      w,
      h
    )

    g.setColor(
      new Color(91, 235, 250, 145)
    )

    g.setStroke(
      new BasicStroke(3.0f)
    )

    g.drawOval(
      x - w / 2 + 12,
      y - h / 2 + 13,
      w - 24,
      h - 26
    )

    // Rotating magical particles
    for (i <- 0 until 8) {

      val angle =
        frames * 0.035 + i * math.Pi * 2.0 / 8.0

      val px =
        x + (math.cos(angle) * 48).toInt

      val py =
        y + (math.sin(angle) * 65).toInt

      g.setColor(
        if (i % 2 == 0) CYAN
        else PINK
      )

      g.fillOval(
        px - 4,
        py - 4,
        8,
        8
      )
    }

    g.setFont(
      new Font("SansSerif", Font.BOLD, 12)
    )

    g.setColor(
      if (ready) MINT
      else WHITE
    )

    drawCentered(
      g,
      if (ready) "PORTAL UNLOCKED"
      else "LOCKED PORTAL",
      x,
      y + h / 2 + 25
    )
  }

  // --------------------------------------------------------------
  // PLAYER CHARACTER
  // --------------------------------------------------------------

  def drawPlayer(
    g: Graphics2D
  ): Unit = {

    val x = screenX(playerX)
    val y = screenY(playerY)

    val bob =
      math.sin(frames * 0.2) * 2.0

    if (glowMode) {

      drawGlow(
        g,
        x,
        y,
        42,
        CYAN,
        0.09f
      )
    }

    // Shadow
    g.setColor(
      new Color(0, 0, 0, 100)
    )

    g.fillOval(
      x - 21,
      y + 17,
      42,
      12
    )

    // Legs
    g.setColor(
      new Color(24, 39, 66)
    )

    g.fillRoundRect(
      x - 13,
      y + bob.toInt,
      11,
      21,
      5,
      5
    )

    g.fillRoundRect(
      x + 3,
      y - bob.toInt,
      11,
      21,
      5,
      5
    )

    // Boots
    g.setColor(CYAN)

    g.fillRoundRect(
      x - 14,
      y + 16,
      13,
      6,
      4,
      4
    )

    g.fillRoundRect(
      x + 2,
      y + 16,
      13,
      6,
      4,
      4
    )

    // Body
    g.setColor(
      new Color(35, 65, 119)
    )

    g.fillRoundRect(
      x - 18,
      y - 26 + bob.toInt,
      36,
      34,
      12,
      12
    )

    g.setColor(
      new Color(49, 189, 193)
    )

    g.fillRoundRect(
      x - 14,
      y - 22 + bob.toInt,
      28,
      26,
      10,
      10
    )

    // Chest light
    g.setColor(WHITE)

    g.fillOval(
      x - 5,
      y - 17 + bob.toInt,
      10,
      10
    )

    // Head
    g.setColor(
      new Color(239, 205, 175)
    )

    g.fillOval(
      x - 12,
      y - 48 + bob.toInt,
      24,
      25
    )

    // Hair
    g.setColor(
      new Color(36, 28, 46)
    )

    g.fillArc(
      x - 13,
      y - 54 + bob.toInt,
      26,
      27,
      0,
      180
    )

    // Eyes / visor
    g.setColor(
      new Color(15, 43, 69)
    )

    g.fillRoundRect(
      x - 9,
      y - 39 + bob.toInt,
      18,
      8,
      5,
      5
    )

    g.setColor(CYAN)

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

    // Dash trail
    if (dashTime > 0.0) {

      g.setColor(
        new Color(100, 249, 240, 150)
      )

      g.setStroke(
        new BasicStroke(4.0f)
      )

      g.drawLine(
        x - (facingX * 45).toInt,
        y + 5 - (facingY * 45).toInt,
        x,
        y + 5
      )
    }
  }

  // --------------------------------------------------------------
  // PARTICLES
  // --------------------------------------------------------------

  def drawParticles(
    g: Graphics2D
  ): Unit = {

    particles.foreach { p =>

      val x = screenX(p.x)
      val y = screenY(p.y)

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

        val alpha =
          clamp(p.life / 3.0, 0.0, 0.85).toFloat

        val old = g.getComposite

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

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

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

        g.fillOval(
          x,
          y,
          sz,
          sz
        )

        g.setComposite(old)
      }
    }
  }

  // --------------------------------------------------------------
  // MINI MAP
  // --------------------------------------------------------------

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

    val x = w - 190
    val y = 116

    val mw = 168
    val mh = 118

    g.setColor(
      new Color(5, 14, 29, 220)
    )

    g.fillRoundRect(
      x, y, mw, mh, 14, 14
    )

    g.setColor(
      new Color(75, 231, 222, 180)
    )

    g.drawRoundRect(
      x, y, mw, mh, 14, 14
    )

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

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

    // Remaining crystals
    crystals.filter(!_.collected).foreach { c =>

      g.setColor(
        hueColor(c.hue)
      )

      g.fillOval(
        mapX(c.x) - 3,
        mapY(c.y) - 3,
        6,
        6
      )
    }

    // Portal
    g.setColor(MINT)

    g.fillOval(
      mapX(portalX) - 4,
      mapY(portalY) - 4,
      8,
      8
    )

    // Player position
    g.setColor(WHITE)

    g.fillOval(
      mapX(playerX) - 4,
      mapY(playerY) - 4,
      8,
      8
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 9)
    )

    g.setColor(WHITE)

    g.drawString(
      "MAGIC MAP",
      x + 10,
      y + 13
    )
  }

  // --------------------------------------------------------------
  // HUD
  // --------------------------------------------------------------

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

    // Top left
    g.setColor(
      new Color(5, 17, 33, 225)
    )

    g.fillRoundRect(
      16, 15, 440, 90, 18, 18
    )

    g.setColor(
      new Color(75, 235, 219, 180)
    )

    g.drawRoundRect(
      16, 15, 440, 90, 18, 18
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 20)
    )

    g.setColor(CYAN)

    g.drawString(
      "BIXUU",
      31,
      42
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 12)
    )

    g.setColor(WHITE)

    g.drawString(
      "SPRING SEASON EVENT 02",
      31,
      61
    )

    g.drawString(
      "CRYSTALS  " + collectedCount + " / " + GOAL_COUNT,
      31,
      88
    )

    // Progress bar
    g.setColor(
      new Color(36, 54, 72)
    )

    g.fillRoundRect(
      228, 76, 205, 9, 8, 8
    )

    g.setColor(
      if (collectedCount == GOAL_COUNT) MINT
      else PINK
    )

    g.fillRoundRect(
      228,
      76,
      (205.0 * collectedCount / GOAL_COUNT).toInt,
      9,
      8,
      8
    )

    // Time
    val minutes =
      remainingSeconds.toInt / 60

    val seconds =
      remainingSeconds.toInt % 60

    val timeText =
      "%02d:%02d".format(minutes, seconds)

    g.setColor(
      new Color(5, 17, 33, 225)
    )

    g.fillRoundRect(
      w - 185, 18, 165, 69, 15, 15
    )

    g.setColor(GOLD)

    g.setFont(
      new Font("Monospaced", Font.BOLD, 23)
    )

    g.drawString(
      timeText,
      w - 163,
      51
    )

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

    g.setColor(WHITE)

    g.drawString(
      "TIME REMAINING",
      w - 161,
      72
    )

    // Bottom controls
    val by = h - 50

    g.setColor(
      new Color(5, 17, 32, 225)
    )

    g.fillRoundRect(
      14, by, w - 28, 36, 12, 12
    )

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

    g.setColor(MINT)

    g.drawString(
      "WASD / ARROWS : MOVE",
      27,
      by + 22
    )

    g.setColor(CYAN)

    g.drawString(
      "E : COLLECT",
      222,
      by + 22
    )

    g.setColor(PINK)

    g.drawString(
      "SPACE : DASH",
      335,
      by + 22
    )

    g.setColor(GOLD)

    g.drawString(
      "F : GLOW",
      468,
      by + 22
    )

    g.setColor(WHITE)

    g.drawString(
      "M : MAP   ESC : PAUSE   R : RESTART",
      585,
      by + 22
    )

    if (showMap) {
      drawMiniMap(g, w)
    }

    // Toast message
    if (toastTime > 0.0) {

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

      val tx = (w - tw) / 2

      g.setColor(
        new Color(5, 16, 33, 235)
      )

      g.fillRoundRect(
        tx, h - 100, tw, 34, 14, 14
      )

      g.setColor(CYAN)

      g.drawRoundRect(
        tx, h - 100, tw, 34, 14, 14
      )

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

      g.setColor(WHITE)

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

    // Ambient glitch effect
    if (
      glitchActive &&
      frames % 4 < 2 &&
      started &&
      !paused &&
      !won &&
      !lost
    ) {

      val old = g.getComposite

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

      g.setColor(PINK)
      g.fillRect(0, 120, w, 3)

      g.setColor(CYAN)
      g.fillRect(0, 126, w, 2)

      g.setComposite(old)
    }
  }

  // --------------------------------------------------------------
  // INTRO SCREEN
  // --------------------------------------------------------------

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

    g.setColor(
      new Color(3, 8, 24, 205)
    )

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

    val cx = w / 2
    val cy = h / 2

    drawGlow(
      g,
      cx,
      cy - 70,
      190,
      PURPLE,
      0.12f
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 20)
    )

    g.setColor(MINT)

    drawCentered(
      g,
      "BIXUU",
      cx,
      cy - 178
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 21)
    )

    g.setColor(WHITE)

    drawCentered(
      g,
      "SPRING SEASON EVENT 02",
      cx,
      cy - 139
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 43)
    )

    g.setColor(CYAN)

    drawCentered(
      g,
      "AURORA FALLS",
      cx,
      cy - 83
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 23)
    )

    g.setColor(PINK)

    drawCentered(
      g,
      "THE LOST SPRING",
      cx,
      cy - 45
    )

    g.setFont(
      new Font("SansSerif", Font.PLAIN, 15)
    )

    g.setColor(WHITE)

    drawCentered(
      g,
      "Explore a magical forest, collect six crystals",
      cx,
      cy + 1
    )

    drawCentered(
      g,
      "and unlock the secret Cosmic Waterfall Portal.",
      cx,
      cy + 24
    )

    val bx = cx - 145
    val by = cy + 57

    g.setColor(
      new Color(20, 102, 91)
    )

    g.fillRoundRect(
      bx, by, 290, 58, 18, 18
    )

    g.setStroke(
      new BasicStroke(2.5f)
    )

    g.setColor(CYAN)

    g.drawRoundRect(
      bx, by, 290, 58, 18, 18
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 20)
    )

    g.setColor(WHITE)

    drawCentered(
      g,
      "START ADVENTURE",
      cx,
      by + 36
    )

    g.setFont(
      new Font("SansSerif", Font.PLAIN, 13)
    )

    g.setColor(GOLD)

    drawCentered(
      g,
      "PRESS ENTER OR CLICK TO PLAY",
      cx,
      by + 91
    )

    g.setFont(
      new Font("SansSerif", Font.PLAIN, 11)
    )

    g.setColor(
      new Color(205, 226, 238)
    )

    drawCentered(
      g,
      "ORIGINAL OFFLINE SCALA GAME",
      cx,
      h - 22
    )
  }

  // --------------------------------------------------------------
  // PAUSE / WIN / GAME OVER SCREEN
  // --------------------------------------------------------------

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

    g.setColor(
      new Color(3, 8, 22, 215)
    )

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

    val message =
      if (won) "SPRING RESTORED!"
      else if (lost) "TIME HAS RUN OUT"
      else "GAME PAUSED"

    val subtitle =
      if (won) "You unlocked the Cosmic Waterfall Portal!"
      else if (lost) "Try again and restore all six crystals."
      else "Your adventure is waiting."

    g.setFont(
      new Font("SansSerif", Font.BOLD, 39)
    )

    g.setColor(
      if (won) MINT
      else if (lost) PINK
      else CYAN
    )

    drawCentered(
      g,
      message,
      w / 2,
      h / 2 - 50
    )

    g.setFont(
      new Font("SansSerif", Font.PLAIN, 16)
    )

    g.setColor(WHITE)

    drawCentered(
      g,
      subtitle,
      w / 2,
      h / 2 - 10
    )

    g.setFont(
      new Font("SansSerif", Font.BOLD, 17)
    )

    g.setColor(GOLD)

    drawCentered(
      g,
      "PRESS R TO RESTART",
      w / 2,
      h / 2 + 42
    )
  }

  // --------------------------------------------------------------
  // GAME PANEL
  // --------------------------------------------------------------

  final class GamePanel extends JPanel {

    setPreferredSize(
      new Dimension(SCREEN_W, SCREEN_H)
    )

    setBackground(BG_TOP)
    setFocusable(true)
    setDoubleBuffered(true)

    setCursor(
      new Cursor(Cursor.CROSSHAIR_CURSOR)
    )

    // Keyboard
    addKeyListener(
      new KeyAdapter {

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

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

          keys += k

          if (fresh) {

            k match {

              case KeyEvent.VK_ENTER =>
                if (!started) startAdventure()

              case KeyEvent.VK_E =>
                interact()

              case KeyEvent.VK_SPACE =>
                if (!started) startAdventure()
                else triggerDash()

              case KeyEvent.VK_F =>
                glowMode = !glowMode

              case KeyEvent.VK_M =>
                showMap = !showMap

              case KeyEvent.VK_ESCAPE =>
                if (started && !won && !lost) {
                  paused = !paused
                }

              case KeyEvent.VK_R =>
                resetGame()

              case _ =>
            }
          }
        }

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

          keys -= e.getKeyCode
        }
      }
    )

    // Mouse
    addMouseListener(
      new MouseAdapter {

        override def mouseClicked(e: MouseEvent): Unit = {

          requestFocusInWindow()

          if (!started) {
            startAdventure()
          } else if (paused || won || lost) {
            resetGame()
          } else {
            interact()
          }
        }
      }
    )

    // Main rendering
    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

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

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

        // Render layers
        drawGround(g, w, h)

        drawCosmicSky(g)

        drawPaths(g)

        drawWaterfall(g)

        trees.foreach(t => drawTree(g, t))

        flowers.foreach(f => drawFlower(g, f))

        crystals.foreach(c => drawCrystal(g, c))

        drawPortal(g)

        drawParticles(g)

        drawPlayer(g)

        if (started) {
          drawHUD(g, w, h)
        }

        if (!started) {
          drawIntro(g, w, h)
        } else if (paused || won || lost) {
          drawEndScreen(g, w, h)
        }

      } finally {

        g.dispose()
      }
    }
  }

  // --------------------------------------------------------------
  // START GAME WINDOW
  // --------------------------------------------------------------

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

    setupWorld()

    SwingUtilities.invokeLater(
      new Runnable {

        override def run(): Unit = {

          window = new JFrame(APP_TITLE)

          panel = new GamePanel()

          window.setContentPane(panel)

          window.setDefaultCloseOperation(
            WindowConstants.EXIT_ON_CLOSE
          )

          window.setMinimumSize(
            new Dimension(900, 620)
          )

          window.setSize(
            SCREEN_W,
            SCREEN_H
          )

          window.setLocationRelativeTo(null)

          window.setVisible(true)

          panel.requestFocusInWindow()

          // Approximately 60 FPS
          gameTimer = new Timer(
            16,
            new ActionListener {

              override def actionPerformed(
                e: ActionEvent
              ): Unit = {

                updateGame(0.016)

                if (panel != null) {
                  panel.repaint()
                }
              }
            }
          )

          gameTimer.start()
        }
      }
    )
  }
}

// Launch
BixuuAuroraFalls.main(Array[String]())