Code Sketch


granny yoi 3d
By: Mhalsakant School
Category: Programming
import javax.swing._
import java.awt._
import java.awt.event._
import scala.collection.mutable.ArrayBuffer
import scala.collection.mutable

object OldHouse3D {

  // ==========================================================
  // OLD HOUSE 3D
  // Polygon-based 3D horror prototype
  // Scala + Java Swing + software perspective renderer
  // ==========================================================

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

  case class Box(
    x: Double, y: Double, z: Double,
    sx: Double, sy: Double, sz: Double,
    color: Color,
    solid: Boolean
  )

  case class Tri(a: V3, b: V3, c: V3, color: Color)

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

  case class ScreenTri(
    depth: Double,
    xs: Array[Int],
    ys: Array[Int],
    color: Color
  )

  val worldTriangles = ArrayBuffer[Tri]()
  val collisionBoxes = ArrayBuffer[Box]()
  val keys = mutable.HashSet[Int]()

  val WALL = new Color(75, 65, 56)
  val WALL2 = new Color(105, 88, 71)
  val FLOOR = new Color(70, 58, 47)
  val WOOD = new Color(86, 52, 32)
  val DARKWOOD = new Color(42, 29, 22)
  val METAL = new Color(100, 104, 108)
  val RED = new Color(85, 18, 20)

  val SW = 1100
  val SH = 680

  var px = 3.2
  var pz = 4.2
  var yaw = math.Pi
  var pitch = 0.0

  var day = 1
  var started = false
  var gameOver = false
  var escaped = false
  var hidden = false

  var eyeRise = 0.0
  var flashlight = true
  var showMap = false

  var hasKey = false
  var doorOpen = false

  val keyX = 9.4
  val keyZ = 3.3

  val doorX = 20.5
  val doorZ = 17.65

  val wardrobeX = 1.25
  val wardrobeZ = 4.8

  val vaseX = 4.7
  val vaseZ = 2.4

  var vaseY = 1.05
  var vaseVelocity = 0.0
  var vaseFalling = false
  var vaseBroken = false

  var noiseX = vaseX
  var noiseZ = vaseZ
  var noiseTimer = 0.0

  var enemyX = 21.0
  var enemyZ = 3.0
  var waypointX = enemyX
  var waypointZ = enemyZ
  var aiClock = 0.0
  var patrolIndex = 0

  var message = "DAY 1: PRESS ENTER TO WAKE UP"
  var messageTimer = 5.0

  var lastMouseX = -1
  var lastMouseY = -1

  var gameTimer: Timer = _
  var frame: JFrame = _
  var panel: GamePanel = _

  val GRID = 0.5
  val GW = 48
  val GH = 36

  // ----------------------------------------------------------
  // 3D MODEL CREATION
  // ----------------------------------------------------------

  def shade(c: Color, f: Double): Color = {
    new Color(
      math.max(0, math.min(255, (c.getRed * f).toInt)),
      math.max(0, math.min(255, (c.getGreen * f).toInt)),
      math.max(0, math.min(255, (c.getBlue * f).toInt))
    )
  }

  def boxTriangles(b: Box): ArrayBuffer[Tri] = {
    val x0 = b.x - b.sx / 2
    val x1 = b.x + b.sx / 2
    val y0 = b.y - b.sy / 2
    val y1 = b.y + b.sy / 2
    val z0 = b.z - b.sz / 2
    val z1 = b.z + b.sz / 2

    val v = Array(
      V3(x0, y0, z0),
      V3(x1, y0, z0),
      V3(x1, y1, z0),
      V3(x0, y1, z0),
      V3(x0, y0, z1),
      V3(x1, y0, z1),
      V3(x1, y1, z1),
      V3(x0, y1, z1)
    )

    val faces = Array(
      Array(0, 3, 2, 1),
      Array(4, 5, 6, 7),
      Array(0, 4, 7, 3),
      Array(1, 2, 6, 5),
      Array(3, 7, 6, 2),
      Array(0, 1, 5, 4)
    )

    val light = Array(0.66, 1.0, 0.62, 0.82, 1.10, 0.40)
    val out = ArrayBuffer[Tri]()

    for (i <- faces.indices) {
      val f = faces(i)
      val col = shade(b.color, light(i))

      out += Tri(v(f(0)), v(f(1)), v(f(2)), col)
      out += Tri(v(f(0)), v(f(2)), v(f(3)), col)
    }

    out
  }

  def addBox(
    x: Double, y: Double, z: Double,
    sx: Double, sy: Double, sz: Double,
    col: Color,
    solid: Boolean = true
  ): Unit = {

    val b = Box(x, y, z, sx, sy, sz, col, solid)

    worldTriangles ++= boxTriangles(b)

    if (solid) collisionBoxes += b
  }

  def dynamicBox(
    out: ArrayBuffer[Tri],
    x: Double, y: Double, z: Double,
    sx: Double, sy: Double, sz: Double,
    col: Color
  ): Unit = {
    out ++= boxTriangles(
      Box(x, y, z, sx, sy, sz, col, false)
    )
  }

  // ----------------------------------------------------------
  // HOUSE WALLS
  // ----------------------------------------------------------

  def wallX(
    x: Double,
    z0: Double,
    z1: Double,
    gaps: Seq[(Double, Double)]
  ): Unit = {

    var cursor = z0

    for ((a, b) <- gaps.sortBy(_._1)) {
      if (a > cursor) {
        addBox(
          x, 1.5, (cursor + a) / 2,
          0.22, 3.0, a - cursor,
          WALL
        )
      }

      cursor = math.max(cursor, b)
    }

    if (cursor < z1) {
      addBox(
        x, 1.5, (cursor + z1) / 2,
        0.22, 3.0, z1 - cursor,
        WALL
      )
    }
  }

  def wallZ(
    z: Double,
    x0: Double,
    x1: Double,
    gaps: Seq[(Double, Double)]
  ): Unit = {

    var cursor = x0

    for ((a, b) <- gaps.sortBy(_._1)) {
      if (a > cursor) {
        addBox(
          (cursor + a) / 2, 1.5, z,
          a - cursor, 3.0, 0.22,
          WALL
        )
      }

      cursor = math.max(cursor, b)
    }

    if (cursor < x1) {
      addBox(
        (cursor + x1) / 2, 1.5, z,
        x1 - cursor, 3.0, 0.22,
        WALL
      )
    }
  }

  def buildHouse(): Unit = {
    worldTriangles.clear()
    collisionBoxes.clear()

    // Main floor
    addBox(
      12.0, -0.18, 9.0,
      24.0, 0.36, 18.0,
      FLOOR, false
    )

    // Outer walls
    addBox(0.1, 1.5, 9.0, 0.3, 3.0, 18.0, WALL)
    addBox(23.9, 1.5, 9.0, 0.3, 3.0, 18.0, WALL)
    wallZ(0.1, 0.1, 23.9, Seq())

    // Exit opening on the front wall
    wallZ(18.0, 0.25, 23.75, Seq((19.7, 21.3)))

    // Room partitions and door openings
    wallX(
      6.0, 0.25, 17.75,
      Seq((2.4, 3.6), (8.2, 9.4), (14.0, 15.2))
    )

    wallX(
      12.0, 0.25, 17.75,
      Seq((3.0, 4.2), (8.0, 9.2), (14.6, 15.8))
    )

    wallZ(
      6.0, 0.25, 23.75,
      Seq((3.5, 4.8), (8.0, 9.2), (17.5, 18.7))
    )

    wallZ(
      12.0, 0.25, 23.75,
      Seq((2.0, 3.2), (8.5, 9.7), (15.5, 16.7),
          (19.9, 21.3))
    )

    // BEDROOM: bed frame, mattress and pillow
    addBox(2.3, 0.28, 2.1, 2.25, 0.40, 2.9, DARKWOOD)
    addBox(2.3, 0.58, 2.1, 2.05, 0.23, 2.68,
      new Color(105, 100, 92))

    addBox(2.3, 0.73, 0.98, 1.1, 0.12, 0.48,
      new Color(195, 187, 170))

    // Bed legs
    for (x <- Seq(1.35, 3.25); z <- Seq(0.85, 3.25)) {
      addBox(x, 0.14, z, 0.17, 0.28, 0.17, DARKWOOD)
    }

    // Nightstand
    addBox(4.75, 0.43, 2.3, 0.8, 0.84, 0.8, WOOD)
    addBox(4.75, 0.88, 2.3, 0.88, 0.10, 0.88, DARKWOOD)

    // Wardrobe
    addBox(
      wardrobeX, 1.10, wardrobeZ,
      0.95, 2.2, 0.9,
      new Color(61, 40, 29)
    )

    addBox(
      1.70, 1.10, wardrobeZ,
      0.10, 2.05, 0.86,
      new Color(83, 53, 37)
    )

    // Dining table
    addBox(9.0, 0.68, 5.0, 1.6, 0.16, 1.2, WOOD)

    for (x <- Seq(8.35, 9.65); z <- Seq(4.55, 5.45)) {
      addBox(x, 0.32, z, 0.13, 0.64, 0.13, DARKWOOD)
    }

    // Cupboards in other rooms
    addBox(15.0, 0.90, 2.0, 1.0, 1.8, 0.65, DARKWOOD)
    addBox(18.5, 0.95, 10.3, 1.1, 1.9, 0.9, WOOD)
    addBox(4.0, 0.95, 14.8, 1.1, 1.9, 0.9, DARKWOOD)

    // Tables and room furniture
    addBox(17.1, 0.45, 4.5, 1.5, 0.12, 1.0, WOOD)
    addBox(18.2, 0.37, 14.0, 1.25, 0.75, 0.8, DARKWOOD)

    // 3D stairs: each step is real polygonal geometry.
    for (i <- 0 until 10) {
      val height = (i + 1) * 0.14

      addBox(
        15.8, height / 2,
        7.2 + i * 0.34,
        3.0, height, 0.36,
        new Color(85, 62, 46),
        false
      )
    }

    // Stair railing
    addBox(14.25, 0.75, 8.9, 0.12, 1.5, 3.5, DARKWOOD)
    addBox(17.35, 0.75, 8.9, 0.12, 1.5, 3.5, DARKWOOD)

    // Windows, represented by dark glass and frames
    addBox(3.0, 2.0, 0.23, 1.5, 0.9, 0.08,
      new Color(28, 43, 51), false)

    addBox(14.0, 2.0, 0.23, 1.5, 0.9, 0.08,
      new Color(28, 43, 51), false)

    // A few wall decorations
    addBox(10.0, 2.0, 0.23, 0.65, 0.75, 0.08,
      new Color(42, 31, 24), false)

    addBox(10.0, 2.0, 0.17, 0.52, 0.61, 0.04,
      new Color(85, 74, 60), false)
  }

  // ----------------------------------------------------------
  // PLAYER, CAMERA AND COLLISION
  // ----------------------------------------------------------

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

  def distance(
    x1: Double, z1: Double,
    x2: Double, z2: Double
  ): Double = {
    math.sqrt(
      (x1 - x2) * (x1 - x2) +
      (z1 - z2) * (z1 - z2)
    )
  }

  def floorHeight(x: Double, z: Double): Double = {
    if (x >= 14.3 && x <= 17.3 && z >= 7.0 && z <= 10.5) {
      1.4 * clamp((z - 7.0) / 3.5, 0.0, 1.0)
    } else {
      0.0
    }
  }

  def blocked(x: Double, z: Double, radius: Double): Boolean = {
    if (x < 0.35 || x > 23.65 || z < 0.35) return true

    // The front exit is traversable after its door is unlocked.
    if (z > 17.65 && !(doorOpen && x > 19.4 && x < 21.6)) {
      return true
    }

    if (z > 19.0) return true

    if (!doorOpen &&
        math.abs(x - doorX) < 0.72 + radius &&
        math.abs(z - doorZ) < 0.23 + radius) {
      return true
    }

    collisionBoxes.exists { b =>
      math.abs(x - b.x) < b.sx / 2 + radius &&
      math.abs(z - b.z) < b.sz / 2 + radius
    }
  }

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

    if (!blocked(px + dx, pz, radius)) px += dx
    if (!blocked(px, pz + dz, radius)) pz += dz

    px = clamp(px, 0.4, 23.6)
  }

  def wake(): Unit = {
    started = true
    eyeRise = 0.0
    message = "DAY " + day + " - FIND A WAY OUT"
    messageTimer = 4.0
  }

  def resetGame(): Unit = {
    day = 1
    started = false
    gameOver = false
    escaped = false
    hasKey = false
    doorOpen = false
    hidden = false

    px = 3.2
    pz = 4.2
    yaw = math.Pi
    pitch = 0.0

    eyeRise = 0.0
    flashlight = true
    showMap = false

    vaseY = 1.05
    vaseVelocity = 0.0
    vaseFalling = false
    vaseBroken = false

    noiseTimer = 0.0

    enemyX = 21.0
    enemyZ = 3.0
    waypointX = enemyX
    waypointZ = enemyZ

    message = "DAY 1: PRESS ENTER TO WAKE UP"
    messageTimer = 5.0
  }

  // ----------------------------------------------------------
  // INTERACTIONS
  // ----------------------------------------------------------

  def say(s: String, seconds: Double = 2.5): Unit = {
    message = s
    messageTimer = seconds
  }

  def interact(): Unit = {
    if (!started || gameOver || escaped) return

    if (distance(px, pz, doorX, doorZ - 0.7) < 1.35) {
      if (hasKey) {
        doorOpen = true
        say("EXIT UNLOCKED! KEEP MOVING!")
      } else {
        say("LOCKED! SEARCH THE HOUSE FOR A KEY.")
      }
      return
    }

    if (distance(px, pz, wardrobeX, wardrobeZ) < 1.25) {
      hidden = !hidden

      if (hidden) {
        say("HIDDEN. PRESS E TO LEAVE THE WARDROBE.")
      } else {
        say("YOU LEFT THE WARDROBE.")
      }

      return
    }

    if (distance(px, pz, vaseX, vaseZ) < 1.25 &&
        !vaseFalling && !vaseBroken) {

      vaseFalling = true
      vaseVelocity = 0.0
      say("THE OBJECT IS FALLING!", 1.4)
      return
    }

    if (distance(px, pz, keyX, keyZ) < 1.0 && !hasKey) {
      hasKey = true
      say("KEY COLLECTED! FIND THE EXIT.")
      return
    }

    say("NOTHING TO INTERACT WITH HERE.")
  }

  // ----------------------------------------------------------
  // ENEMY PATHFINDING
  // ----------------------------------------------------------

  def walkableCell(x: Int, z: Int): Boolean = {
    if (x < 0 || z < 0 || x >= GW || z >= GH) return false

    !blocked(
      x * GRID + GRID / 2,
      z * GRID + GRID / 2,
      0.12
    )
  }

  def nearestCell(wx: Double, wz: Double): Int = {
    val ox = clamp((wx / GRID).toInt, 0, GW - 1).toInt
    val oz = clamp((wz / GRID).toInt, 0, GH - 1).toInt

    var result = -1
    var radius = 0

    while (result < 0 && radius < 10) {
      var dz = -radius

      while (dz <= radius && result < 0) {
        var dx = -radius

        while (dx <= radius && result < 0) {
          if (math.abs(dx) == radius || math.abs(dz) == radius) {
            val nx = ox + dx
            val nz = oz + dz

            if (walkableCell(nx, nz)) {
              result = nz * GW + nx
            }
          }

          dx += 1
        }

        dz += 1
      }

      radius += 1
    }

    result
  }

  def nextWaypoint(
    sx: Double, sz: Double,
    tx: Double, tz: Double
  ): (Double, Double) = {

    val start = nearestCell(sx, sz)
    val goal = nearestCell(tx, tz)

    if (start < 0 || goal < 0) return (tx, tz)
    if (start == goal) return (tx, tz)

    val previous = Array.fill(GW * GH)(-1)
    val queue = mutable.Queue[Int]()

    previous(start) = start
    queue.enqueue(start)

    var iterations = 0

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

    while (queue.nonEmpty &&
           previous(goal) == -1 &&
           iterations < GW * GH) {

      val current = queue.dequeue()
      val cx = current % GW
      val cz = current / GW

      for ((dx, dz) <- dirs) {
        val nx = cx + dx
        val nz = cz + dz

        if (walkableCell(nx, nz)) {
          val ni = nz * GW + nx

          if (previous(ni) == -1) {
            previous(ni) = current
            queue.enqueue(ni)
          }
        }
      }

      iterations += 1
    }

    if (previous(goal) == -1) return (tx, tz)

    var cur = goal

    while (previous(cur) != start && cur != start &&
           previous(cur) >= 0) {
      cur = previous(cur)
    }

    (
      (cur % GW) * GRID + GRID / 2,
      (cur / GW) * GRID + GRID / 2
    )
  }

  val patrol = Array(
    (20.8, 3.0),
    (21.8, 9.0),
    (18.0, 14.0),
    (10.0, 14.0),
    (8.0, 8.5)
  )

  def resetDay(): Unit = {
    day += 1

    if (day > 5) {
      gameOver = true
      say("DAY 5 FINISHED. GAME OVER.", 20.0)
    } else {
      px = 3.2
      pz = 4.2
      yaw = math.Pi
      pitch = 0.0

      eyeRise = 0.0
      hasKey = false
      doorOpen = false
      hidden = false

      vaseY = 1.05
      vaseVelocity = 0.0
      vaseFalling = false
      vaseBroken = false

      noiseTimer = 0.0

      enemyX = 21.0
      enemyZ = 3.0

      waypointX = enemyX
      waypointZ = enemyZ

      say("DAY " + day + " - YOU WOKE UP AGAIN!", 4.5)
    }
  }

  def updateEnemy(dt: Double): Unit = {
    if (hidden || gameOver || escaped) return

    val playerDistance = distance(px, pz, enemyX, enemyZ)

    var targetX = enemyX
    var targetZ = enemyZ

    if (noiseTimer > 0.0) {
      targetX = noiseX
      targetZ = noiseZ
    } else if (playerDistance < 12.0) {
      targetX = px
      targetZ = pz
    } else {
      val p = patrol(patrolIndex)
      targetX = p._1
      targetZ = p._2

      if (distance(enemyX, enemyZ, targetX, targetZ) < 0.7) {
        patrolIndex = (patrolIndex + 1) % patrol.length
      }
    }

    aiClock += dt

    if (aiClock >= 0.65) {
      aiClock = 0.0

      val p = nextWaypoint(enemyX, enemyZ, targetX, targetZ)

      waypointX = p._1
      waypointZ = p._2
    }

    val dx = waypointX - enemyX
    val dz = waypointZ - enemyZ
    val len = math.sqrt(dx * dx + dz * dz)

    if (len > 0.06) {
      val step = math.min(len, 1.1 * dt)

      val nx = enemyX + dx / len * step
      val nz = enemyZ + dz / len * step

      if (!blocked(nx, enemyZ, 0.18)) enemyX = nx
      if (!blocked(enemyX, nz, 0.18)) enemyZ = nz
    }

    if (playerDistance < 0.65) {
      resetDay()
    }
  }

  // ----------------------------------------------------------
  // GAME UPDATE
  // ----------------------------------------------------------

  def updateGame(): Unit = {
    val dt = 0.033

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

    if (messageTimer > 0.0) messageTimer -= dt
    if (noiseTimer > 0.0) noiseTimer -= dt

    // Rise from bed: camera height changes smoothly.
    eyeRise = math.min(1.0, eyeRise + dt * 0.8)

    if (vaseFalling && !vaseBroken) {
      vaseVelocity += 9.8 * dt
      vaseY -= vaseVelocity * dt

      if (vaseY <= 0.12) {
        vaseY = 0.12
        vaseBroken = true
        vaseFalling = false

        noiseX = vaseX
        noiseZ = vaseZ
        noiseTimer = 12.0

        say("CRASH! THE SOUND ATTRACTED THE ENEMY!", 4.0)
      }
    }

    // Player movement.
    if (!hidden) {
      var forward = 0.0
      var side = 0.0

      if (keys.contains(KeyEvent.VK_W)) forward += 1.0
      if (keys.contains(KeyEvent.VK_S)) forward -= 1.0
      if (keys.contains(KeyEvent.VK_D)) side += 1.0
      if (keys.contains(KeyEvent.VK_A)) side -= 1.0

      val len = math.sqrt(forward * forward + side * side)

      if (len > 0.0) {
        val speed =
          if (keys.contains(KeyEvent.VK_SHIFT)) 3.2
          else 2.0

        val f = forward / len * speed * dt
        val s = side / len * speed * dt

        val dx = math.sin(yaw) * f + math.cos(yaw) * s
        val dz = math.cos(yaw) * f - math.sin(yaw) * s

        movePlayer(dx, dz)
      }
    }

    // Auto-pickup once the key is reached.
    if (!hasKey && distance(px, pz, keyX, keyZ) < 0.55) {
      hasKey = true
      say("KEY COLLECTED! FIND THE EXIT.")
    }

    // Exit only after opening its locked door.
    if (doorOpen &&
        px > 19.4 && px < 21.6 &&
        pz > 18.25) {
      escaped = true
      say("YOU ESCAPED THE OLD HOUSE!", 20.0)
    }

    updateEnemy(dt)
  }

  // ----------------------------------------------------------
  // ENEMY MODEL
  // ----------------------------------------------------------

  def addEnemyModel(out: ArrayBuffer[Tri]): Unit = {
    val ground = floorHeight(enemyX, enemyZ)

    // Original, simple low-poly horror character.
    dynamicBox(
      out,
      enemyX, ground + 1.05, enemyZ,
      0.52, 1.15, 0.36,
      new Color(65, 18, 22)
    )

    dynamicBox(
      out,
      enemyX, ground + 1.86, enemyZ,
      0.43, 0.47, 0.43,
      new Color(178, 156, 133)
    )

    dynamicBox(
      out,
      enemyX, ground + 2.08, enemyZ,
      0.48, 0.17, 0.48,
      new Color(25, 22, 20)
    )

    dynamicBox(
      out,
      enemyX - 0.37, ground + 1.15, enemyZ,
      0.19, 0.88, 0.20,
      new Color(95, 62, 48)
    )

    dynamicBox(
      out,
      enemyX + 0.37, ground + 1.15, enemyZ,
      0.19, 0.88, 0.20,
      new Color(95, 62, 48)
    )

    dynamicBox(
      out,
      enemyX - 0.16, ground + 0.32, enemyZ,
      0.20, 0.65, 0.24,
      new Color(35, 29, 28)
    )

    dynamicBox(
      out,
      enemyX + 0.16, ground + 0.32, enemyZ,
      0.20, 0.65, 0.24,
      new Color(35, 29, 28)
    )
  }

  // ----------------------------------------------------------
  // 3D CAMERA AND PERSPECTIVE RENDERING
  // ----------------------------------------------------------

  def cameraY: Double = {
    floorHeight(px, pz) + 0.78 + 0.88 * eyeRise
  }

  def transform(v: V3): Cam = {
    val dx = v.x - px
    val dy = v.y - cameraY
    val dz = v.z - pz

    val d = dx * math.sin(yaw) + dz * math.cos(yaw)
    val sx = dx * math.cos(yaw) - dz * math.sin(yaw)

    val cp = math.cos(pitch)
    val sp = math.sin(pitch)

    Cam(
      sx,
      dy * cp - d * sp,
      d * cp + dy * sp
    )
  }

  def screenTriangle(
    t: Tri,
    w: Int,
    h: Int
  ): Option[ScreenTri] = {

    val a = transform(t.a)
    val b = transform(t.b)
    val c = transform(t.c)

    if (a.z <= 0.12 || b.z <= 0.12 || c.z <= 0.12) {
      return None
    }

    val focal = w * 0.84

    def sx(p: Cam): Int =
      clamp(w / 2.0 + p.x * focal / p.z, -5000, 5000).toInt

    def sy(p: Cam): Int =
      clamp(h / 2.0 - p.y * focal / p.z, -5000, 5000).toInt

    val centerX = (t.a.x + t.b.x + t.c.x) / 3.0
    val centerZ = (t.a.z + t.b.z + t.c.z) / 3.0

    val dx = centerX - px
    val dz = centerZ - pz
    val dist = math.sqrt(dx * dx + dz * dz)

    val direction =
      if (dist < 0.001) 1.0
      else (dx * math.sin(yaw) + dz * math.cos(yaw)) / dist

    val lighting =
      if (!flashlight) 0.13
      else if (direction > 0.30) {
        clamp(1.20 - dist * 0.035, 0.28, 1.15)
      } else {
        0.24
      }

    Some(
      ScreenTri(
        (a.z + b.z + c.z) / 3.0,
        Array(sx(a), sx(b), sx(c)),
        Array(sy(a), sy(b), sy(c)),
        shade(t.color, lighting)
      )
    )
  }

  def draw3D(g: Graphics2D, w: Int, h: Int): Unit = {
    val horizon = h / 2

    // Ceiling darkness and atmospheric background.
    g.setPaint(
      new GradientPaint(
        0f, 0f, new Color(8, 10, 14),
        0f, horizon.toFloat, new Color(48, 43, 38)
      )
    )
    g.fillRect(0, 0, w, horizon)

    g.setPaint(
      new GradientPaint(
        0f, horizon.toFloat, new Color(47, 40, 32),
        0f, h.toFloat, new Color(10, 10, 12)
      )
    )
    g.fillRect(0, horizon, w, h - horizon)

    val all = ArrayBuffer[Tri]()
    all ++= worldTriangles

    // Exit door.
    if (!doorOpen) {
      dynamicBox(
        all,
        doorX, 1.2, doorZ,
        1.25, 2.4, 0.14,
        new Color(65, 38, 26)
      )

      dynamicBox(
        all,
        doorX + 0.40, 1.15, doorZ - 0.09,
        0.07, 0.12, 0.06,
        new Color(205, 166, 50)
      )
    }

    // Key model: gold shaft and ring.
    if (!hasKey) {
      dynamicBox(
        all,
        keyX, 0.24, keyZ,
        0.42, 0.055, 0.055,
        new Color(235, 184, 35)
      )

      dynamicBox(
        all,
        keyX - 0.23, 0.24, keyZ,
        0.12, 0.06, 0.12,
        new Color(245, 206, 66)
      )

      dynamicBox(
        all,
        keyX + 0.17, 0.24, keyZ,
        0.06, 0.10, 0.08,
        new Color(245, 206, 66)
      )
    }

    // Vase before it breaks.
    if (!vaseBroken) {
      dynamicBox(
        all,
        vaseX, vaseY, vaseZ,
        0.24, 0.34, 0.24,
        new Color(115, 62, 48)
      )

      dynamicBox(
        all,
        vaseX, vaseY + 0.18, vaseZ,
        0.15, 0.06, 0.15,
        new Color(155, 99, 66)
      )
    } else {
      dynamicBox(
        all,
        vaseX - 0.12, 0.06, vaseZ,
        0.19, 0.05, 0.12,
        new Color(115, 62, 48)
      )

      dynamicBox(
        all,
        vaseX + 0.12, 0.06, vaseZ + 0.05,
        0.15, 0.04, 0.08,
        new Color(155, 99, 66)
      )
    }

    addEnemyModel(all)

    // Painter's algorithm: far polygons first, near polygons last.
    val projected = ArrayBuffer[ScreenTri]()

    all.foreach { t =>
      screenTriangle(t, w, h).foreach(projected += _)
    }

    val ordered = projected.sortWith(_.depth > _.depth)

    ordered.foreach { t =>
      g.setColor(t.color)

      g.fillPolygon(
        t.xs,
        t.ys,
        3
      )
    }

    // Crosshair.
    g.setColor(new Color(230, 230, 215, 180))
    g.drawLine(w / 2 - 5, h / 2, w / 2 + 5, h / 2)
    g.drawLine(w / 2, h / 2 - 5, w / 2, h / 2 + 5)

    drawHUD(g, w, h)

    if (showMap) drawMap(g, w)
  }

  // ----------------------------------------------------------
  // HUD / DAY SYSTEM DISPLAY
  // ----------------------------------------------------------

  def drawHUD(g: Graphics2D, w: Int, h: Int): Unit = {
    g.setColor(new Color(0, 0, 0, 175))
    g.fillRect(0, 0, w, 65)

    g.setFont(new Font("Arial", Font.BOLD, 17))
    g.setColor(new Color(245, 222, 185))

    g.drawString(
      "DAY " + day +
      "    KEY: " + (if (hasKey) "YES" else "NO") +
      "    FLASHLIGHT: " + (if (flashlight) "ON" else "OFF"),
      16, 25
    )

    g.setFont(new Font("Arial", Font.PLAIN, 14))
    g.setColor(Color.WHITE)

    g.drawString(
      "WASD Move | Mouse Look | SHIFT Run | E Interact | F Light | M Map | R Restart",
      16, 49
    )

    g.setColor(new Color(0, 0, 0, 185))
    g.fillRect(0, h - 44, w, 44)

    if (messageTimer > 0.0) {
      g.setFont(new Font("Arial", Font.BOLD, 17))
      g.setColor(new Color(255, 224, 165))
      g.drawString(message, 16, h - 17)
    }

    if (distance(px, pz, enemyX, enemyZ) < 5.0 && !hidden) {
      g.setFont(new Font("Arial", Font.BOLD, 16))
      g.setColor(new Color(255, 65, 55))
      g.drawString("SOMETHING IS NEAR...", w - 265, 89)
    }

    if (gameOver || escaped) {
      g.setColor(new Color(0, 0, 0, 220))
      g.fillRect(0, 0, w, h)

      g.setFont(new Font("Arial", Font.BOLD, 42))
      g.setColor(if (escaped) new Color(85, 255, 125)
                 else new Color(255, 75, 60))

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

      val fm = g.getFontMetrics
      g.drawString(title, (w - fm.stringWidth(title)) / 2, h / 2)

      g.setFont(new Font("Arial", Font.BOLD, 20))
      g.setColor(Color.WHITE)
      g.drawString(
        "Press R to restart",
        w / 2 - 95,
        h / 2 + 48
      )
    }
  }

  def drawMap(g: Graphics2D, w: Int): Unit = {
    val ox = w - 200
    val oy = 90
    val sx = 7.0
    val sz = 7.0

    g.setColor(new Color(0, 0, 0, 190))
    g.fillRect(ox - 6, oy - 6, 184, 144)

    g.setColor(new Color(130, 120, 110))
    collisionBoxes.foreach { b =>
      g.fillRect(
        ox + (b.x - b.sx / 2).toInt * sx.toInt,
        oy + (b.z - b.sz / 2).toInt * sz.toInt,
        math.max(1, (b.sx * sx).toInt),
        math.max(1, (b.sz * sz).toInt)
      )
    }

    g.setColor(Color.RED)
    g.fillOval(
      ox + (enemyX * sx).toInt - 3,
      oy + (enemyZ * sz).toInt - 3,
      7, 7
    )

    g.setColor(Color.CYAN)
    g.fillOval(
      ox + (px * sx).toInt - 3,
      oy + (pz * sz).toInt - 3,
      7, 7
    )
  }

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

  def drawIntro(g: Graphics2D, w: Int, h: Int): Unit = {
    g.setPaint(
      new GradientPaint(
        0f, 0f, new Color(5, 6, 9),
        w.toFloat, h.toFloat, new Color(55, 23, 20)
      )
    )

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

    g.setColor(new Color(25, 20, 18))
    g.fillRect(100, 100, w - 200, h - 180)

    g.setColor(new Color(61, 43, 32))
    g.fillRect(170, 135, w - 340, h - 245)

    // Bed silhouette.
    g.setColor(new Color(31, 25, 24))
    g.fillRect(190, h - 190, 280, 60)

    g.setColor(new Color(95, 75, 64))
    g.fillRect(210, h - 220, 240, 35)

    // Door silhouette.
    g.setColor(new Color(40, 24, 18))
    g.fillRect(w - 320, 170, 105, 270)

    g.setColor(new Color(198, 150, 62))
    g.fillOval(w - 240, 305, 12, 12)

    g.setFont(new Font("Arial", Font.BOLD, 48))
    g.setColor(new Color(220, 210, 195))

    val title = "OLD HOUSE 3D"
    g.drawString(
      title,
      (w - g.getFontMetrics.stringWidth(title)) / 2,
      85
    )

    g.setFont(new Font("Arial", Font.BOLD, 23))
    g.setColor(new Color(255, 90, 70))

    val subtitle = "DAY 1"
    g.drawString(
      subtitle,
      (w - g.getFontMetrics.stringWidth(subtitle)) / 2,
      h - 112
    )

    g.setFont(new Font("Arial", Font.BOLD, 21))
    g.setColor(new Color(250, 220, 160))

    val prompt = "PRESS ENTER TO WAKE UP"
    g.drawString(
      prompt,
      (w - g.getFontMetrics.stringWidth(prompt)) / 2,
      h - 62
    )

    g.setFont(new Font("Arial", Font.PLAIN, 16))
    g.setColor(Color.LIGHT_GRAY)

    val controls = "WASD - MOVE     MOUSE - LOOK     SHIFT - RUN"
    g.drawString(
      controls,
      (w - g.getFontMetrics.stringWidth(controls)) / 2,
      h - 25
    )
  }

  // ----------------------------------------------------------
  // MAIN WINDOW
  // ----------------------------------------------------------

  class GamePanel extends JPanel {
    setPreferredSize(new Dimension(SW, SH))
    setFocusable(true)
    setBackground(Color.BLACK)
    setFocusTraversalKeysEnabled(false)

    override def paintComponent(graphics: Graphics): Unit = {
      super.paintComponent(graphics)

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

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

      if (!started) {
        drawIntro(g, getWidth, getHeight)
      } else {
        draw3D(g, getWidth, getHeight)
      }

      g.dispose()
    }
  }

  def start(): Unit = {
    buildHouse()
    resetGame()

    SwingUtilities.invokeLater(new Runnable {
      def run(): Unit = {
        frame = new JFrame("OLD HOUSE 3D - Scala Horror Game")
        panel = new GamePanel()

        panel.addKeyListener(new KeyAdapter {
          override def keyPressed(e: KeyEvent): Unit = {
            val k = e.getKeyCode
            keys.add(k)

            if (k == KeyEvent.VK_ENTER && !started && !gameOver) {
              wake()
            }

            if (k == KeyEvent.VK_E) interact()

            if (k == KeyEvent.VK_F) {
              flashlight = !flashlight
              say(
                if (flashlight) "FLASHLIGHT ON"
                else "FLASHLIGHT OFF"
              )
            }

            if (k == KeyEvent.VK_M) showMap = !showMap

            if (k == KeyEvent.VK_R) {
              resetGame()
            }
          }

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

        panel.addMouseMotionListener(new MouseMotionAdapter {
          override def mouseMoved(e: MouseEvent): Unit = {
            if (started && !gameOver && !escaped) {
              if (lastMouseX >= 0) {
                val dx = e.getX - lastMouseX
                val dy = e.getY - lastMouseY

                yaw += dx * 0.006
                pitch = clamp(pitch - dy * 0.004, -0.55, 0.55)
              }

              lastMouseX = e.getX
              lastMouseY = e.getY
            }
          }

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

        panel.addFocusListener(new java.awt.event.FocusAdapter {
          override def focusLost(e: java.awt.event.FocusEvent): Unit = {
            keys.clear()
            lastMouseX = -1
            lastMouseY = -1
          }
        })

        frame.setContentPane(panel)
        frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE)
        frame.pack()
        frame.setLocationRelativeTo(null)
        frame.setVisible(true)

        panel.requestFocusInWindow()

        gameTimer = new Timer(33, new ActionListener {
          def actionPerformed(e: ActionEvent): Unit = {
            updateGame()
            panel.repaint()
          }
        })

        gameTimer.start()
      }
    })
  }
}

OldHouse3D.start()