Code Sketch


granny
By: Mhalsakant School
Category: Programming
import java.awt._
import java.awt.event._
import java.awt.geom.Point2D
import javax.swing._
import scala.collection.mutable.ArrayBuffer

/*
 * ================================================================
 *                OLD HOUSE ESCAPE 3D
 *           Kojo + Scala + Java Swing/AWT
 * ================================================================
 *
 *  Controls:
 *    ENTER        Start / Restart
 *    W A S D      Move
 *    SHIFT        Sprint
 *    C            Crouch
 *    Mouse        Look
 *    Arrow Keys   Look fallback
 *    E            Interact / Collect / Hide / Exit
 *    F            Flashlight
 *    ESC          Quit
 *
 *  No external images / models / assets required.
 * ================================================================
 */

object OldHouseEscape3D {

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

  class Game
      extends JPanel
      with KeyListener
      with MouseListener
      with MouseMotionListener {

    // ==============================================================
    //                          WORLD
    // ==============================================================

    val MAP_W = 31
    val MAP_H = 25

    val world = Array.fill(MAP_H, MAP_W)(false)

    // Outer walls
    for (z <- 0 until MAP_H) {
      for (x <- 0 until MAP_W) {
        if (
          x == 0 ||
          z == 0 ||
          x == MAP_W - 1 ||
          z == MAP_H - 1
        ) {
          world(z)(x) = true
        }
      }
    }

    def wall(x: Int, z: Int): Unit = {
      if (
        x >= 0 &&
        x < MAP_W &&
        z >= 0 &&
        z < MAP_H
      ) {
        world(z)(x) = true
      }
    }

    // Vertical house sections
    for (z <- 2 to 22 if z != 5 && z != 11 && z != 18) {
      wall(8, z)
    }

    for (
      z <- 2 to 22
      if z != 4 && z != 9 && z != 16 && z != 21
    ) {
      wall(15, z)
    }

    for (
      z <- 2 to 22
      if z != 6 && z != 12 && z != 20
    ) {
      wall(23, z)
    }

    // Horizontal house sections
    for (
      x <- 2 to 28
      if x != 5 && x != 12 && x != 19 && x != 26
    ) {
      wall(x, 7)
    }

    for (
      x <- 2 to 28
      if x != 4 && x != 11 && x != 18 && x != 26
    ) {
      wall(x, 14)
    }

    for (
      x <- 2 to 28
      if x != 6 && x != 16 && x != 21 && x != 27
    ) {
      wall(x, 19)
    }

    // South exit opening
    world(24)(14) = false

    // ==============================================================
    //                    PLAYER / CAMERA
    // ==============================================================

    var px = 3.5
    var py = 1.45
    var pz = 3.5

    var yaw = 0.0
    var pitch = 0.0

    val FOCAL = 690.0
    val NEAR = 0.06

    var started = false
    var escaped = false
    var gameOver = false

    var night = 1
    val MAX_NIGHTS = 5

    var nightTime = 150.0

    var crouched = false
    var hidden = false
    var flashlight = true

    var sprintEnergy = 100.0

    // ==============================================================
    //                           MESSAGE
    // ==============================================================

    var msg = "PRESS ENTER TO START"
    var msgTime = 999.0

    def message(s: String, t: Double): Unit = {
      msg = s
      msgTime = t
    }

    // ==============================================================
    //                            ITEMS
    // ==============================================================

    case class Item(
      name: String,
      var x: Double,
      var z: Double,
      var collected: Boolean,
      kind: Int
    )

    val items = Array(
      Item("RUSTED KEY", 5.5, 4.5, false, 0),
      Item("FUSE", 18.5, 9.5, false, 1),
      Item("CUTTER", 27.5, 16.5, false, 2)
    )

    // ==============================================================
    //                      HIDING PLACES
    // ==============================================================

    case class Hide(
      name: String,
      x: Double,
      z: Double
    )

    val hidingPlaces = Array(
      Hide("WARDROBE", 4.5, 10.5),
      Hide("CABINET", 19.5, 4.5),
      Hide("STORAGE", 26.5, 18.5)
    )

    // ==============================================================
    //                         ENEMY AI
    // ==============================================================

    var enemyX = 27.5
    var enemyZ = 3.5

    var enemyDir = math.Pi
    var enemyMode = "PATROL"

    var targetX = enemyX
    var targetZ = enemyZ

    var lastSeenX = enemyX
    var lastSeenZ = enemyZ

    var searchTime = 0.0
    var attackCooldown = 0.0

    val patrol = Array(
      (27.5, 3.5),
      (27.5, 10.5),
      (27.5, 17.5),
      (21.5, 17.5),
      (17.5, 17.5),
      (17.5, 12.5),
      (11.5, 12.5),
      (11.5, 4.5),
      (5.5, 4.5),
      (5.5, 16.5),
      (12.5, 21.5),
      (21.5, 21.5)
    )

    var patrolIndex = 0

    // ==============================================================
    //                          INPUT
    // ==============================================================

    val held =
      scala.collection.mutable.Set[Int]()

    var interactPressed = false

    var lastMouseX = -1
    var lastMouseY = -1

    var mouseInside = false

    // ==============================================================
    //                            NOISE
    // ==============================================================

    var noiseX = px
    var noiseZ = pz

    var noisePower = 0.0
    var stepNoiseTimer = 0.0

    // ==============================================================
    //                            CLOCK
    // ==============================================================

    var lastTime = System.nanoTime()

    val timer = new javax.swing.Timer(
      16,
      new ActionListener {
        override def actionPerformed(
          e: ActionEvent
        ): Unit = {

          updateGame()
          repaint()
        }
      }
    )

    // ==============================================================
    //                       PANEL SETUP
    // ==============================================================

    setPreferredSize(
      new Dimension(1280, 760)
    )

    setFocusable(true)
    setFocusTraversalKeysEnabled(false)
    setDoubleBuffered(true)

    addKeyListener(this)
    addMouseListener(this)
    addMouseMotionListener(this)

    setBackground(
      new Color(3, 4, 7)
    )

    override def addNotify(): Unit = {
      super.addNotify()

      requestFocusInWindow()

      lastTime = System.nanoTime()

      if (!timer.isRunning) {
        timer.start()
      }
    }

    // ==============================================================
    //                          MATH
    // ==============================================================

    def distance(
      ax: Double,
      az: Double,
      bx: Double,
      bz: Double
    ): Double = {

      math.hypot(
        ax - bx,
        az - bz
      )
    }

    def isWall(
      x: Int,
      z: Int
    ): Boolean = {

      if (
        x < 0 ||
        x >= MAP_W ||
        z < 0 ||
        z >= MAP_H
      ) {
        true
      } else {
        world(z)(x)
      }
    }

    def canWalk(
      x: Double,
      z: Double
    ): Boolean = {

      val r = 0.22

      val checks = Array(
        (x - r, z - r),
        (x + r, z - r),
        (x - r, z + r),
        (x + r, z + r)
      )

      checks.forall {
        case (xx, zz) =>
          !isWall(
            math.floor(xx).toInt,
            math.floor(zz).toInt
          )
      }
    }

    def normalizeAngle(
      a: Double
    ): Double = {

      var v = a

      while (v > math.Pi) {
        v -= math.Pi * 2.0
      }

      while (v < -math.Pi) {
        v += math.Pi * 2.0
      }

      v
    }

    // ==============================================================
    //                         LINE OF SIGHT
    // ==============================================================

    def clearSight(
      ax: Double,
      az: Double,
      bx: Double,
      bz: Double
    ): Boolean = {

      val d =
        distance(
          ax,
          az,
          bx,
          bz
        )

      val steps =
        math.max(
          1,
          (d / 0.11).toInt
        )

      var i = 1

      while (i < steps) {

        val t =
          i.toDouble /
            steps.toDouble

        val x =
          ax + (bx - ax) * t

        val z =
          az + (bz - az) * t

        if (
          isWall(
            math.floor(x).toInt,
            math.floor(z).toInt
          )
        ) {
          return false
        }

        i += 1
      }

      true
    }

    def enemyCanSeePlayer: Boolean = {

      if (
        hidden ||
        !started ||
        gameOver ||
        escaped
      ) {
        false
      } else {

        val dx = px - enemyX
        val dz = pz - enemyZ

        val d =
          math.hypot(dx, dz)

        val maxDistance =
          if (crouched) 5.2
          else 8.8

        val desired =
          math.atan2(dx, dz)

        val difference =
          math.abs(
            normalizeAngle(
              desired - enemyDir
            )
          )

        d < maxDistance &&
        difference < 1.20 &&
        clearSight(
          enemyX,
          enemyZ,
          px,
          pz
        )
      }
    }

    // ==============================================================
    //                         PATH FINDING
    // ==============================================================

    def nextStep(
      sx: Int,
      sz: Int,
      tx: Int,
      tz: Int
    ): (Int, Int) = {

      if (
        sx == tx &&
        sz == tz
      ) {
        return (sx, sz)
      }

      if (isWall(tx, tz)) {
        return (-1, -1)
      }

      val visited =
        Array.fill(
          MAP_H,
          MAP_W
        )(false)

      val prevX =
        Array.fill(
          MAP_H,
          MAP_W
        )(-1)

      val prevZ =
        Array.fill(
          MAP_H,
          MAP_W
        )(-1)

      val q =
        new java.util.ArrayDeque[(Int, Int)]()

      q.add((sx, sz))

      visited(sz)(sx) = true

      val dirs = Array(
        (1, 0),
        (-1, 0),
        (0, 1),
        (0, -1)
      )

      var found = false

      while (
        !q.isEmpty &&
        !found
      ) {

        val p = q.removeFirst()

        var j = 0

        while (
          j < dirs.length
        ) {

          val nx =
            p._1 + dirs(j)._1

          val nz =
            p._2 + dirs(j)._2

          if (
            nx >= 0 &&
            nx < MAP_W &&
            nz >= 0 &&
            nz < MAP_H &&
            !isWall(nx, nz) &&
            !visited(nz)(nx)
          ) {

            visited(nz)(nx) = true

            prevX(nz)(nx) = p._1
            prevZ(nz)(nx) = p._2

            q.add((nx, nz))

            if (
              nx == tx &&
              nz == tz
            ) {
              found = true
            }
          }

          j += 1
        }
      }

      if (!visited(tz)(tx)) {
        return (-1, -1)
      }

      var cx = tx
      var cz = tz

      while (
        !(
          prevX(cz)(cx) == sx &&
          prevZ(cz)(cx) == sz
        )
      ) {

        val ax = prevX(cz)(cx)
        val az = prevZ(cz)(cx)

        if (
          ax < 0 ||
          az < 0
        ) {
          return (-1, -1)
        }

        cx = ax
        cz = az
      }

      (cx, cz)
    }

    // ==============================================================
    //                       ENEMY MOVEMENT
    // ==============================================================

    def moveEnemyTo(
      tx: Double,
      tz: Double,
      speed: Double,
      dt: Double
    ): Unit = {

      val sx =
        math.floor(enemyX).toInt

      val sz =
        math.floor(enemyZ).toInt

      val txc =
        math.floor(tx).toInt

      val tzc =
        math.floor(tz).toInt

      val step =
        nextStep(
          sx,
          sz,
          txc,
          tzc
        )

      val gx =
        if (step._1 < 0)
          tx
        else
          step._1 + 0.5

      val gz =
        if (step._2 < 0)
          tz
        else
          step._2 + 0.5

      val dx =
        gx - enemyX

      val dz =
        gz - enemyZ

      val d =
        math.hypot(dx, dz)

      if (d > 0.03) {

        val amount =
          math.min(
            speed * dt,
            d
          )

        val nx =
          dx / d

        val nz =
          dz / d

        val ex =
          enemyX + nx * amount

        val ez =
          enemyZ + nz * amount

        if (
          canWalk(
            ex,
            enemyZ
          )
        ) {
          enemyX = ex
        }

        if (
          canWalk(
            enemyX,
            ez
          )
        ) {
          enemyZ = ez
        }

        enemyDir =
          math.atan2(dx, dz)
      }
    }

    // ==============================================================
    //                          ENEMY AI
    // ==============================================================

    def updateEnemy(
      dt: Double
    ): Unit = {

      if (attackCooldown > 0) {
        attackCooldown -= dt
      }

      if (enemyCanSeePlayer) {

        enemyMode = "CHASE"

        lastSeenX = px
        lastSeenZ = pz

        searchTime = 5.5

      } else if (
        noisePower > 0 &&
        !hidden &&
        distance(
          enemyX,
          enemyZ,
          noiseX,
          noiseZ
        ) < noisePower
      ) {

        enemyMode = "INVESTIGATE"

        targetX = noiseX
        targetZ = noiseZ

        searchTime = 4.5
      }

      enemyMode match {

        case "PATROL" =>

          val p =
            patrol(patrolIndex)

          moveEnemyTo(
            p._1,
            p._2,
            1.20 +
              (night - 1) * 0.11,
            dt
          )

          if (
            distance(
              enemyX,
              enemyZ,
              p._1,
              p._2
            ) < 0.50
          ) {

            patrolIndex =
              (patrolIndex + 1) %
                patrol.length
          }

        case "INVESTIGATE" =>

          moveEnemyTo(
            targetX,
            targetZ,
            1.38 +
              (night - 1) * 0.12,
            dt
          )

          if (
            distance(
              enemyX,
              enemyZ,
              targetX,
              targetZ
            ) < 0.55
          ) {

            enemyMode = "SEARCH"

            lastSeenX = targetX
            lastSeenZ = targetZ

            searchTime = 3.8
          }

        case "CHASE" =>

          lastSeenX = px
          lastSeenZ = pz

          moveEnemyTo(
            px,
            pz,
            2.05 +
              (night - 1) * 0.17,
            dt
          )

          searchTime -= dt

          if (
            searchTime <= 0 &&
            !enemyCanSeePlayer
          ) {

            enemyMode = "SEARCH"
            searchTime = 3.2
          }

        case "SEARCH" =>

          moveEnemyTo(
            lastSeenX,
            lastSeenZ,
            1.46 +
              (night - 1) * 0.10,
            dt
          )

          searchTime -= dt

          if (
            searchTime <= 0 &&
            distance(
              enemyX,
              enemyZ,
              lastSeenX,
              lastSeenZ
            ) < 0.85
          ) {

            enemyMode = "PATROL"
          }

        case _ =>
          enemyMode = "PATROL"
      }

      if (
        !hidden &&
        attackCooldown <= 0 &&
        distance(
          px,
          pz,
          enemyX,
          enemyZ
        ) < 0.66
      ) {

        caught()

        attackCooldown = 1.5
      }
    }

    // ==============================================================
    //                       RESET NIGHT
    // ==============================================================

    def resetNight(): Unit = {

      px = 3.5
      py = 1.45
      pz = 3.5

      yaw = 0.0
      pitch = 0.0

      enemyX = 27.5
      enemyZ = 3.5

      enemyDir = math.Pi
      enemyMode = "PATROL"

      patrolIndex = 0

      hidden = false
      crouched = false

      flashlight = true

      nightTime = 150.0

      sprintEnergy = 100.0

      noisePower = 0.0
      stepNoiseTimer = 0.0

      interactPressed = false

      val layouts = Array(
        Array(
          (5.5, 4.5),
          (18.5, 9.5),
          (27.5, 16.5)
        ),
        Array(
          (4.5, 5.5),
          (19.5, 12.5),
          (26.5, 16.5)
        ),
        Array(
          (6.5, 16.5),
          (17.5, 4.5),
          (27.5, 11.5)
        ),
        Array(
          (5.5, 11.5),
          (17.5, 16.5),
          (27.5, 4.5)
        ),
        Array(
          (4.5, 4.5),
          (19.5, 9.5),
          (27.5, 17.5)
        )
      )

      val set =
        layouts(
          math.min(
            night - 1,
            layouts.length - 1
          )
        )

      var i = 0

      while (i < items.length) {

        items(i).x =
          set(i)._1

        items(i).z =
          set(i)._2

        items(i).collected =
          false

        i += 1
      }
    }

    // ==============================================================
    //                          CAUGHT
    // ==============================================================

    def caught(): Unit = {

      night += 1

      if (night > MAX_NIGHTS) {

        gameOver = true

        message(
          "THE HOUSE WON - PRESS ENTER TO RESTART",
          999
        )

      } else {

        resetNight()

        message(
          "THE CARETAKER CAUGHT YOU - NIGHT " +
            night,
          3.0
        )
      }
    }

    // ==============================================================
    //                           INTERACT
    // ==============================================================

    def interactNow(): Unit = {

      if (
        !started ||
        escaped ||
        gameOver
      ) {
        return
      }

      // Leave hiding place
      if (hidden) {

        hidden = false

        message(
          "YOU LEAVE THE HIDING PLACE",
          1.5
        )

        return
      }

      // Find nearest item
      var nearest: Item = null
      var best = 99.0

      var i = 0

      while (i < items.length) {

        val it = items(i)

        if (!it.collected) {

          val d =
            distance(
              px,
              pz,
              it.x,
              it.z
            )

          if (
            d < 1.20 &&
            d < best
          ) {

            nearest = it
            best = d
          }
        }

        i += 1
      }

      if (nearest != null) {

        nearest.collected = true

        noiseX = px
        noiseZ = pz
        noisePower = 7.5

        message(
          "FOUND: " +
            nearest.name,
          2.4
        )

        return
      }

      // Hiding places
      for (h <- hidingPlaces) {

        if (
          distance(
            px,
            pz,
            h.x,
            h.z
          ) < 1.10
        ) {

          hidden = true

          message(
            "HIDDEN IN " +
              h.name +
              " - PRESS E TO LEAVE",
            2.0
          )

          return
        }
      }

      // Exit
      if (
        px > 12.8 &&
        px < 16.0 &&
        pz > 23.0
      ) {

        if (
          items.forall(
            _.collected
          )
        ) {

          escaped = true

          message(
            "YOU ESCAPED THE OLD HOUSE!",
            999
          )

        } else {

          message(
            "EXIT LOCKED - FIND ALL 3 OBJECTS",
            2.4
          )
        }
      }
    }

    // ==============================================================
    //                        GAME UPDATE
    // ==============================================================

    def updateGame(): Unit = {

      val now =
        System.nanoTime()

      var dt =
        (now - lastTime) /
          1000000000.0

      lastTime = now

      if (
        dt <= 0 ||
        dt > 0.05
      ) {
        dt = 0.016
      }

      if (msgTime > 0) {
        msgTime -= dt
      }

      noisePower =
        math.max(
          0.0,
          noisePower - dt * 4.5
        )

      // ------------------------------------------------------------
      // Start screen
      // ------------------------------------------------------------

      if (!started) {

        if (
          held.contains(
            KeyEvent.VK_ENTER
          )
        ) {

          started = true

          escaped = false
          gameOver = false

          night = 1

          resetNight()

          message(
            "NIGHT 1 - FIND THE OBJECTS AND ESCAPE",
            3.0
          )

          held.remove(
            KeyEvent.VK_ENTER
          )
        }

        return
      }

      // ------------------------------------------------------------
      // End screen
      // ------------------------------------------------------------

      if (
        gameOver ||
        escaped
      ) {

        if (
          held.contains(
            KeyEvent.VK_ENTER
          )
        ) {

          started = false
          escaped = false
          gameOver = false

          night = 1

          held.remove(
            KeyEvent.VK_ENTER
          )

          message(
            "PRESS ENTER TO START",
            999
          )
        }

        return
      }

      // ------------------------------------------------------------
      // Crouch
      // ------------------------------------------------------------

      crouched =
        held.contains(
          KeyEvent.VK_C
        )

      // ------------------------------------------------------------
      // Arrow-key camera
      // ------------------------------------------------------------

      if (
        held.contains(
          KeyEvent.VK_LEFT
        )
      ) {

        yaw -= dt * 1.8
      }

      if (
        held.contains(
          KeyEvent.VK_RIGHT
        )
      ) {

        yaw += dt * 1.8
      }

      if (
        held.contains(
          KeyEvent.VK_UP
        )
      ) {

        pitch =
          math.max(
            -0.45,
            pitch - dt
          )
      }

      if (
        held.contains(
          KeyEvent.VK_DOWN
        )
      ) {

        pitch =
          math.min(
            0.45,
            pitch + dt
          )
      }

      // ------------------------------------------------------------
      // Flashlight
      // ------------------------------------------------------------

      if (
        held.contains(
          KeyEvent.VK_F
        )
      ) {

        flashlight =
          !flashlight

        held.remove(
          KeyEvent.VK_F
        )

        message(
          if (flashlight)
            "FLASHLIGHT ON"
          else
            "FLASHLIGHT OFF",
          1.2
        )
      }

      // ------------------------------------------------------------
      // Interaction
      // ------------------------------------------------------------

      if (interactPressed) {

        interactNow()

        interactPressed = false
      }

      // ------------------------------------------------------------
      // Movement
      // ------------------------------------------------------------

      var mx = 0.0
      var mz = 0.0

      if (
        held.contains(
          KeyEvent.VK_W
        )
      ) {

        mx += math.sin(yaw)
        mz += math.cos(yaw)
      }

      if (
        held.contains(
          KeyEvent.VK_S
        )
      ) {

        mx -= math.sin(yaw)
        mz -= math.cos(yaw)
      }

      if (
        held.contains(
          KeyEvent.VK_A
        )
      ) {

        mx -= math.cos(yaw)
        mz += math.sin(yaw)
      }

      if (
        held.contains(
          KeyEvent.VK_D
        )
      ) {

        mx += math.cos(yaw)
        mz -= math.sin(yaw)
      }

      val rawLen =
        math.hypot(mx, mz)

      val moving =
        rawLen > 0.001

      val sprinting =
        moving &&
        held.contains(
          KeyEvent.VK_SHIFT
        ) &&
        !crouched &&
        sprintEnergy > 0.5

      val speed =
        if (crouched)
          1.05
        else if (sprinting)
          4.0
        else
          2.45

      if (
        moving &&
        !hidden
      ) {

        val nx =
          mx /
            rawLen *
            speed *
            dt

        val nz =
          mz /
            rawLen *
            speed *
            dt

        movePlayer(
          nx,
          nz
        )

        if (sprinting) {

          sprintEnergy =
            math.max(
              0.0,
              sprintEnergy -
                28.0 * dt
            )

        } else {

          sprintEnergy =
            math.min(
              100.0,
              sprintEnergy +
                11.0 * dt
            )
        }

        // Sound
        if (!crouched) {

          stepNoiseTimer -= dt

          if (
            stepNoiseTimer <= 0
          ) {

            noiseX = px
            noiseZ = pz

            noisePower =
              if (sprinting)
                12.0
              else
                7.0

            stepNoiseTimer =
              if (sprinting)
                0.30
              else
                0.66
          }
        }

      } else {

        sprintEnergy =
          math.min(
            100.0,
            sprintEnergy +
              14.0 * dt
          )
      }

      // Enemy
      updateEnemy(dt)

      // Night timer
      nightTime -= dt

      if (nightTime <= 0) {

        night += 1

        if (night > MAX_NIGHTS) {

          gameOver = true

          message(
            "FIVE NIGHTS PASSED - PRESS ENTER",
            999
          )

        } else {

          resetNight()

          message(
            "A NEW NIGHT BEGINS...",
            2.5
          )
        }
      }
    }

    // ==============================================================
    //                       PLAYER MOVEMENT
    // ==============================================================

    def movePlayer(
      dx: Double,
      dz: Double
    ): Unit = {

      if (hidden) {
        return
      }

      val nx = px + dx
      val nz = pz + dz

      if (
        canWalk(
          nx,
          pz
        )
      ) {
        px = nx
      }

      if (
        canWalk(
          px,
          nz
        )
      ) {
        pz = nz
      }
    }

    // ==============================================================
    //                    3D RENDER STRUCTURES
    // ==============================================================

    case class V(
      x: Double,
      y: Double,
      z: Double
    )

    case class ScreenPoint(
      x: Int,
      y: Int,
      depth: Double
    )

    case class Poly(
      pts: Array[V],
      shade: Double,
      col: Color
    )

    // ==============================================================
    //                           CAMERA
    // ==============================================================

    def camera(
      v: V
    ): Option[V] = {

      val dx = v.x - px
      val dy = v.y - py
      val dz = v.z - pz

      val c =
        math.cos(yaw)

      val s =
        math.sin(yaw)

      val cx =
        c * dx - s * dz

      val cz =
        s * dx + c * dz

      if (cz <= NEAR) {

        None

      } else {

        Some(
          V(
            cx,
            dy,
            cz
          )
        )
      }
    }

    def project(
      v: V
    ): Option[ScreenPoint] = {

      camera(v) match {

        case None =>
          None

        case Some(c) =>

          val sx =
            getWidth / 2.0 +
              c.x *
                FOCAL /
                c.z

          val sy =
            getHeight / 2.0 -
              c.y *
                FOCAL /
                c.z +
              pitch *
                250.0

          Some(
            ScreenPoint(
              sx.toInt,
              sy.toInt,
              c.z
            )
          )
      }
    }

    // ==============================================================
    //                            CUBE
    // ==============================================================

    def cube(
      polys: ArrayBuffer[Poly],
      cx: Double,
      cy: Double,
      cz: Double,
      sx: Double,
      sy: Double,
      sz: Double,
      color: Color
    ): Unit = {

      val x0 =
        cx - sx / 2

      val x1 =
        cx + sx / 2

      val y0 =
        cy - sy / 2

      val y1 =
        cy + sy / 2

      val z0 =
        cz - sz / 2

      val z1 =
        cz + sz / 2

      val a =
        V(x0, y0, z0)

      val b =
        V(x1, y0, z0)

      val c =
        V(x1, y1, z0)

      val d =
        V(x0, y1, z0)

      val e =
        V(x0, y0, z1)

      val f =
        V(x1, y0, z1)

      val gg =
        V(x1, y1, z1)

      val h =
        V(x0, y1, z1)

      polys +=
        Poly(
          Array(a, b, c, d),
          0.66,
          color
        )

      polys +=
        Poly(
          Array(f, e, h, gg),
          0.58,
          color
        )

      polys +=
        Poly(
          Array(e, a, d, h),
          0.62,
          color
        )

      polys +=
        Poly(
          Array(b, f, gg, c),
          0.82,
          color
        )

      polys +=
        Poly(
          Array(d, c, gg, h),
          1.00,
          color
        )

      polys +=
        Poly(
          Array(e, f, b, a),
          0.42,
          color
        )
    }

    // ==============================================================
    //                          FLOOR
    // ==============================================================

    def renderFloor(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      for (
        z <- 1 until MAP_H - 1;
        x <- 1 until MAP_W - 1;
        if !world(z)(x)
      ) {

        val d =
          distance(
            px,
            pz,
            x + 0.5,
            z + 0.5
          )

        if (d < 22) {

          val c =
            if (
              (x + z) % 2 == 0
            )
              new Color(
                58,
                57,
                58
              )
            else
              new Color(
                47,
                47,
                49
              )

          polys +=
            Poly(
              Array(
                V(x, 0, z),
                V(x + 1, 0, z),
                V(
                  x + 1,
                  0,
                  z + 1
                ),
                V(
                  x,
                  0,
                  z + 1
                )
              ),
              0.78,
              c
            )
        }
      }
    }

    // ==============================================================
    //                         CEILING
    // ==============================================================

    def renderCeiling(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      for (
        z <- 1 until MAP_H - 1;
        x <- 1 until MAP_W - 1;
        if !world(z)(x)
      ) {

        val d =
          distance(
            px,
            pz,
            x + 0.5,
            z + 0.5
          )

        if (d < 20) {

          polys +=
            Poly(
              Array(
                V(
                  x,
                  3.2,
                  z + 1
                ),
                V(
                  x + 1,
                  3.2,
                  z + 1
                ),
                V(
                  x + 1,
                  3.2,
                  z
                ),
                V(
                  x,
                  3.2,
                  z
                )
              ),
              0.22,
              new Color(
                35,
                31,
                30
              )
            )
        }
      }
    }

    // ==============================================================
    //                           WALLS
    // ==============================================================

    def renderWalls(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      for (
        z <- 0 until MAP_H;
        x <- 0 until MAP_W
        if world(z)(x)
      ) {

        if (
          distance(
            px,
            pz,
            x + 0.5,
            z + 0.5
          ) < 23
        ) {

          val base =
            if (
              (x * 13 + z * 7) % 3 == 0
            )
              new Color(
                93,
                84,
                73
              )
            else
              new Color(
                82,
                77,
                70
              )

          cube(
            polys,
            x + 0.5,
            1.6,
            z + 0.5,
            1.0,
            3.2,
            1.0,
            base
          )
        }
      }
    }

    // ==============================================================
    //                         FURNITURE
    // ==============================================================

    def renderFurniture(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      for (h <- hidingPlaces) {

        cube(
          polys,
          h.x,
          1.35,
          h.z,
          0.95,
          2.7,
          0.80,
          new Color(
            57,
            45,
            37
          )
        )

        cube(
          polys,
          h.x,
          2.77,
          h.z,
          1.05,
          0.08,
          0.86,
          new Color(
            103,
            82,
            64
          )
        )

        cube(
          polys,
          h.x + 0.48,
          1.25,
          h.z,
          0.07,
          2.45,
          0.58,
          new Color(
            37,
            29,
            24
          )
        )
      }

      val crates = Array(
        (4.8, 20.8, 1.3, 0.9),
        (12.6, 4.4, 0.9, 0.9),
        (20.0, 10.8, 1.0, 0.8),
        (26.4, 5.0, 1.1, 0.9)
      )

      for (c <- crates) {

        cube(
          polys,
          c._1,
          0.62,
          c._2,
          c._3,
          1.24,
          c._4,
          new Color(
            75,
            54,
            38
          )
        )
      }
    }

    // ==============================================================
    //                            ITEMS
    // ==============================================================

    def renderItems(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      val t =
        System.nanoTime()
          .toDouble /
          280000000.0

      for (
        it <- items
        if !it.collected
      ) {

        if (
          distance(
            px,
            pz,
            it.x,
            it.z
          ) < 18
        ) {

          val bob =
            math.sin(
              t +
              it.x * 0.4
            ) * 0.08

          val col =
            it.kind match {

              case 0 =>
                new Color(
                  217,
                  169,
                  63
                )

              case 1 =>
                new Color(
                  77,
                  151,
                  225
                )

              case _ =>
                new Color(
                  188,
                  119,
                  67
                )
            }

          cube(
            polys,
            it.x,
            0.55 + bob,
            it.z,
            0.30,
            0.30,
            0.30,
            col
          )
        }
      }
    }

    // ==============================================================
    //                           ENEMY
    // ==============================================================

    def renderEnemy(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      if (
        distance(
          px,
          pz,
          enemyX,
          enemyZ
        ) < 22
      ) {

        val alert =
          enemyMode == "CHASE"

        cube(
          polys,
          enemyX,
          0.98,
          enemyZ,
          0.72,
          1.45,
          0.55,
          if (alert)
            new Color(
              63,
              41,
              43
            )
          else
            new Color(
              45,
              43,
              47
            )
        )

        cube(
          polys,
          enemyX,
          2.02,
          enemyZ,
          0.54,
          0.54,
          0.50,
          new Color(
            164,
            140,
            122
          )
        )

        // Eyes
        cube(
          polys,
          enemyX - 0.13,
          2.08,
          enemyZ + 0.23,
          0.09,
          0.09,
          0.06,
          new Color(
            220,
            65,
            65
          )
        )

        cube(
          polys,
          enemyX + 0.13,
          2.08,
          enemyZ + 0.23,
          0.09,
          0.09,
          0.06,
          new Color(
            220,
            65,
            65
          )
        )

        // Shoulders
        cube(
          polys,
          enemyX - 0.47,
          1.15,
          enemyZ,
          0.22,
          1.08,
          0.48,
          new Color(
            50,
            48,
            52
          )
        )

        cube(
          polys,
          enemyX + 0.47,
          1.15,
          enemyZ,
          0.22,
          1.08,
          0.48,
          new Color(
            50,
            48,
            52
          )
        )
      }
    }

    // ==============================================================
    //                            EXIT
    // ==============================================================

    def renderExit(
      polys: ArrayBuffer[Poly]
    ): Unit = {

      cube(
        polys,
        14.5,
        1.6,
        24.35,
        2.3,
        3.2,
        0.20,
        new Color(
          68,
          68,
          75
        )
      )

      val unlocked =
        items.forall(
          _.collected
        )

      cube(
        polys,
        14.5,
        1.55,
        24.22,
        1.9,
        2.9,
        0.10,
        if (unlocked)
          new Color(
            75,
            160,
            92
          )
        else
          new Color(
            135,
            49,
            49
          )
      )
    }

    // ==============================================================
    //                           SHADING
    // ==============================================================

    def shaded(
      c: Color,
      s: Double
    ): Color = {

      def clamp(
        v: Int
      ): Int = {

        math.max(
          0,
          math.min(
            255,
            v
          )
        )
      }

      new Color(
        clamp(
          (c.getRed * s).toInt
        ),
        clamp(
          (c.getGreen * s).toInt
        ),
        clamp(
          (c.getBlue * s).toInt
        )
      )
    }

    // ==============================================================
    //                        WORLD RENDER
    // ==============================================================

    def renderWorld(
      g: Graphics2D
    ): Unit = {

      val polys =
        ArrayBuffer[Poly]()

      renderFloor(polys)
      renderCeiling(polys)
      renderWalls(polys)
      renderFurniture(polys)
      renderExit(polys)
      renderItems(polys)
      renderEnemy(polys)

      val sorted =
        polys.sortBy { p =>

          val proj =
            p.pts.flatMap(
              project
            )

          if (proj.nonEmpty)
            proj.map(
              _.depth
            ).sum /
              proj.length
          else
            9999.0
        }.reverse

      for (p <- sorted) {

        val projected =
          p.pts.flatMap(
            project
          )

        if (
          projected.length ==
            p.pts.length
        ) {

          val poly =
            new Polygon()

          projected.foreach { q =>
            poly.addPoint(
              q.x,
              q.y
            )
          }

          g.setColor(
            shaded(
              p.col,
              p.shade
            )
          )

          g.fillPolygon(poly)

          g.setColor(
            new Color(
              8,
              8,
              10,
              135
            )
          )

          g.drawPolygon(poly)
        }
      }
    }

    // ==============================================================
    //                       ATMOSPHERE
    // ==============================================================

    def atmosphere(
      g: Graphics2D
    ): Unit = {

      val w = getWidth
      val h = getHeight

      if (hidden) {

        g.setColor(
          new Color(
            0,
            0,
            0,
            238
          )
        )

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

        g.setColor(
          new Color(
            145,
            116,
            90,
            110
          )
        )

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

        g.drawString(
          "Inside the hiding place...",
          28,
          h - 80
        )

      } else {

        val flicker =
          1.0 +
            math.sin(
              System.nanoTime()
                .toDouble /
                120000000.0
            ) * 0.03

        val radius =
          if (flashlight)
            math.min(w, h) *
              0.48 *
              flicker
          else
            math.min(w, h) *
              0.24

        val paint =
          new RadialGradientPaint(
            new Point2D.Float(
              w / 2f,
              h / 2f
            ),
            radius.toFloat,
            Array(
              0.0f,
              0.32f,
              0.72f,
              1.0f
            ),
            Array(
              new Color(
                0,
                0,
                0,
                0
              ),
              new Color(
                0,
                0,
                0,
                25
              ),
              new Color(
                0,
                0,
                0,
                138
              ),
              new Color(
                0,
                0,
                0,
                if (flashlight)
                  210
                else
                  250
              )
            )
          )

        g.setPaint(paint)

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

    // ==============================================================
    //                         CROSSHAIR
    // ==============================================================

    def crosshair(
      g: Graphics2D
    ): Unit = {

      if (
        !started ||
        gameOver ||
        escaped
      ) {
        return
      }

      val cx =
        getWidth / 2

      val cy =
        getHeight / 2

      g.setColor(
        new Color(
          245,
          245,
          245,
          185
        )
      )

      g.setStroke(
        new BasicStroke(
          1.5f
        )
      )

      g.drawLine(
        cx - 7,
        cy,
        cx + 7,
        cy
      )

      g.drawLine(
        cx,
        cy - 7,
        cx,
        cy + 7
      )
    }

    // ==============================================================
    //                             HUD
    // ==============================================================

    def hud(
      g: Graphics2D
    ): Unit = {

      val w = getWidth
      val h = getHeight

      // ------------------------------------------------------------
      // START SCREEN
      // ------------------------------------------------------------

      if (!started) {

        g.setColor(Color.WHITE)

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

        g.drawString(
          "OLD HOUSE ESCAPE 3D",
          60,
          105
        )

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

        g.drawString(
          "Original first-person stealth horror game",
          62,
          145
        )

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

        g.drawString(
          "PRESS ENTER TO START",
          62,
          220
        )

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

        g.drawString(
          "WASD Move | SHIFT Sprint | C Crouch | Mouse Look",
          62,
          270
        )

        g.drawString(
          "E Interact | F Flashlight | Arrow Keys Look | ESC Quit",
          62,
          298
        )

        g.drawString(
          "Collect the 3 objects, stay hidden, and escape through the south exit.",
          62,
          326
        )

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

        g.drawString(
          "No external assets required.",
          62,
          365
        )

        return
      }

      // ------------------------------------------------------------
      // TOP LEFT INFO
      // ------------------------------------------------------------

      g.setColor(Color.WHITE)

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

      val secs =
        math.max(
          0,
          nightTime.toInt
        )

      val mm =
        secs / 60

      val ss =
        secs % 60

      val count =
        items.count(
          _.collected
        )

      g.drawString(
        "NIGHT " +
          night +
          " / " +
          MAX_NIGHTS,
        20,
        28
      )

      g.drawString(
        "TIME %02d:%02d".format(
          mm,
          ss
        ),
        20,
        53
      )

      g.drawString(
        "OBJECTS " +
          count +
          " / 3",
        20,
        78
      )

      g.drawString(
        "HUNTER " +
          enemyMode,
        20,
        103
      )

      g.drawString(
        "STAMINA",
        20,
        128
      )

      // Stamina background
      g.setColor(
        new Color(
          30,
          30,
          34,
          190
        )
      )

      g.fillRoundRect(
        20,
        136,
        180,
        12,
        7,
        7
      )

      // Stamina amount
      g.setColor(
        new Color(
          200,
          200,
          210,
          220
        )
      )

      g.fillRoundRect(
        21,
        137,
        math.max(
          0,
          math.min(
            178,
            (sprintEnergy * 1.78).toInt
          )
        ),
        10,
        6,
        6
      )

      // ------------------------------------------------------------
      // OBJECTIVE
      // ------------------------------------------------------------

      g.setColor(Color.WHITE)

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

      val objective =
        if (count < 3)
          "OBJECTIVE: FIND ALL THREE OBJECTS"
        else
          "OBJECTIVE: REACH THE SOUTH EXIT"

      g.drawString(
        objective,
        20,
        h - 48
      )

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

      g.drawString(
        "WASD Move | SHIFT Sprint | C Crouch | E Interact | F Light | Mouse Look",
        20,
        h - 20
      )

      // ------------------------------------------------------------
      // HIDDEN
      // ------------------------------------------------------------

      if (hidden) {

        g.setColor(
          new Color(
            180,
            225,
            190
          )
        )

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

        g.drawString(
          "HIDDEN",
          w - 105,
          30
        )
      }

      // ------------------------------------------------------------
      // NOISE
      // ------------------------------------------------------------

      if (
        noisePower > 0 &&
        !hidden
      ) {

        g.setColor(
          new Color(
            235,
            205,
            125,
            210
          )
        )

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

        g.drawString(
          "NOISE DETECTED",
          w - 165,
          55
        )
      }

      // ------------------------------------------------------------
      // MESSAGE
      // ------------------------------------------------------------

      if (
        msgTime > 0 ||
        gameOver ||
        escaped
      ) {

        val bw =
          math.min(
            920,
            w - 80
          )

        val bh = 58

        val bx =
          (w - bw) / 2

        val by =
          h - 128

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

        g.fillRoundRect(
          bx,
          by,
          bw,
          bh,
          14,
          14
        )

        g.setColor(Color.WHITE)

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

        val tw =
          g.getFontMetrics
            .stringWidth(msg)

        g.drawString(
          msg,
          bx + (bw - tw) / 2,
          by + 36
        )
      }

      // ------------------------------------------------------------
      // END SCREEN
      // ------------------------------------------------------------

      if (
        gameOver ||
        escaped
      ) {

        g.setColor(
          new Color(
            0,
            0,
            0,
            185
          )
        )

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

        g.setColor(Color.WHITE)

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

        val title =
          if (escaped)
            "YOU ESCAPED!"
          else
            "GAME OVER"

        val tw =
          g.getFontMetrics
            .stringWidth(title)

        g.drawString(
          title,
          (w - tw) / 2,
          h / 2 - 5
        )

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

        val sub =
          "PRESS ENTER TO RESTART"

        val sw =
          g.getFontMetrics
            .stringWidth(sub)

        g.drawString(
          sub,
          (w - sw) / 2,
          h / 2 + 38
        )
      }
    }

    // ==============================================================
    //                       PAINT COMPONENT
    // ==============================================================

    override def paintComponent(
      gg: Graphics
    ): Unit = {

      super.paintComponent(gg)

      val g =
        gg.asInstanceOf[Graphics2D]

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

      // Sky
      val sky =
        new GradientPaint(
          0,
          0,
          new Color(
            13,
            16,
            26
          ),
          0,
          getHeight,
          new Color(
            2,
            3,
            6
          )
        )

      g.setPaint(sky)

      g.fillRect(
        0,
        0,
        getWidth,
        getHeight
      )

      if (started) {
        renderWorld(g)
      }

      atmosphere(g)
      crosshair(g)
      hud(g)
    }

    // ==============================================================
    //                       KEYBOARD INPUT
    // ==============================================================

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

      if (
        e.getKeyCode ==
          KeyEvent.VK_ESCAPE
      ) {

        timer.stop()

        val window =
          SwingUtilities
            .getWindowAncestor(this)

        if (window != null) {
          window.dispose()
        }

        return
      }

      held += e.getKeyCode

      if (
        e.getKeyCode ==
          KeyEvent.VK_E
      ) {

        interactPressed = true
      }

      requestFocusInWindow()
    }

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

      held -= e.getKeyCode
    }

    override def keyTyped(
      e: KeyEvent
    ): Unit = {}

    // ==============================================================
    //                       MOUSE INPUT
    // ==============================================================

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

      mouseInside = true

      lastMouseX = e.getX
      lastMouseY = e.getY

      requestFocusInWindow()
    }

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

      mouseInside = false
    }

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

      requestFocusInWindow()

      lastMouseX = e.getX
      lastMouseY = e.getY
    }

    override def mouseReleased(
      e: MouseEvent
    ): Unit = {}

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

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

      if (
        mouseInside &&
        lastMouseX >= 0 &&
        started &&
        !hidden &&
        !gameOver &&
        !escaped
      ) {

        val dx =
          e.getX - lastMouseX

        val dy =
          e.getY - lastMouseY

        yaw +=
          dx * 0.0042

        pitch =
          math.max(
            -0.45,
            math.min(
              0.45,
              pitch +
                dy * 0.0018
            )
          )
      }

      lastMouseX = e.getX
      lastMouseY = e.getY
    }

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

      mouseMoved(e)
    }
  }

  // ================================================================
  //                         GAME LAUNCHER
  // ================================================================

  def launchGame(): Unit = {

    SwingUtilities.invokeLater(
      new Runnable {

        override def run(): Unit = {

          try {

            val frame =
              new JFrame(
                "Old House Escape 3D"
              )

            frame.setDefaultCloseOperation(
              WindowConstants.EXIT_ON_CLOSE
            )

            frame.setResizable(false)

            val game =
              new Game()

            frame.setContentPane(game)

            // Explicit size so the Kojo window is not 0x0
            frame.setSize(
              new Dimension(
                1280,
                760
              )
            )

            frame.setMinimumSize(
              new Dimension(
                1000,
                600
              )
            )

            frame.setLocationRelativeTo(null)

            frame.setVisible(true)

            // Strong focus request
            game.setFocusable(true)
            game.requestFocusInWindow()
            game.grabFocus()

            game.repaint()

          } catch {
            case ex: Throwable =>

              ex.printStackTrace()

              JOptionPane.showMessageDialog(
                null,
                "Could not start Old House Escape 3D.\n\n" +
                  ex.getClass.getName +
                  "\n" +
                  Option(ex.getMessage)
                    .getOrElse("Unknown error"),
                "Game Error",
                JOptionPane.ERROR_MESSAGE
              )
          }
        }
      }
    )
  }
}

// ================================================================
// IMPORTANT:
// This explicit call makes it work from Kojo Script Editor.
// ================================================================

OldHouseEscape3D.launchGame()