Code Sketch


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

/*
 * OLD HOUSE 3D - improved standalone Scala/Swing prototype.
 * Features: triangle rasterizer + depth buffer, near-plane clipping,
 * hinged opening doors, stairs with changing player height, upstairs
 * gallery, WASD movement, mouse look, flashlight, key, wardrobe hiding,
 * noise attraction, enemy patrol/chase, and 5-day loop.
 *
 * This is an original low-poly prototype, not the original Granny game.
 * It uses no third-party game-engine libraries, but it is not a
 * commercial-quality full 3D engine.
 */
object OldHouse3DImproved {
  case class V3(x: Double, y: Double, z: Double)
  case class CV(x: Double, y: Double, z: Double)
  case class Tri(a: V3, b: V3, c: V3, color: Color)
  case class Box(x: Double, y: Double, z: Double,
                 sx: Double, sy: Double, sz: Double,
                 color: Color, solid: Boolean)

  final class Door(val x: Double, val z: Double, val width: Double,
                   val plane: String, val hingeSide: Double,
                   val yBase: Double, val height: Double,
                   val locked: Boolean, val isExit: Boolean,
                   val name: String) {
    var angle = 0.0
    var target = 0.0
  }

  val SW = 1000
  val SH = 620
  val NEAR = 0.12
  val FOCAL = 760.0
  val WALL = new Color(86, 75, 64)
  val WALL_ALT = new Color(110, 91, 72)
  val FLOOR = new Color(76, 60, 46)
  val WOOD = new Color(91, 57, 36)
  val DARK_WOOD = new Color(46, 31, 24)

  val triangles = ArrayBuffer[Tri]()
  val solids = ArrayBuffer[Box]()
  val doors = ArrayBuffer[Door]()
  val keys = mutable.HashSet[Int]()

  var px = 3.2
  var pz = 4.15
  var yaw = math.Pi
  var pitch = 0.0
  var onUpperFloor = false
  var wakeProgress = 0.0
  var started = false
  var gameOver = false
  var escaped = false
  var hidden = false
  var hasKey = false
  var flashlight = true
  var showMap = false
  var day = 1

  val keyX = 9.35
  val keyZ = 3.35
  val wardrobeX = 1.25
  val wardrobeZ = 4.7
  val vaseX = 4.65
  val vaseZ = 2.35
  var vaseY = 0.9
  var vaseVel = 0.0
  var vaseFalling = false
  var vaseBroken = false
  var noiseX = vaseX
  var noiseZ = vaseZ
  var noiseTimer = 0.0

  var enemyX = 20.5
  var enemyZ = 3.2
  var enemyUpper = false
  var enemyTargetX = enemyX
  var enemyTargetZ = enemyZ
  var aiTimer = 0.0
  var patrolIndex = 0
  val patrol = Array((20.5, 3.2), (21.7, 8.0), (19.0, 12.8),
                     (16.0, 14.2), (8.5, 8.5))

  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 = _
  var image: BufferedImage = _
  var pixels: Array[Int] = _
  var zBuffer: Array[Double] = _

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

  def dist(x1: Double, z1: Double, x2: Double, z2: Double): Double = {
    val dx = x1 - x2
    val dz = z1 - z2
    math.sqrt(dx * dx + dz * dz)
  }

  def shade(c: Color, f0: Double): Color = {
    val f = clamp(f0, 0.0, 1.35)
    new Color(
      clamp(c.getRed * f, 0, 255).toInt,
      clamp(c.getGreen * f, 0, 255).toInt,
      clamp(c.getBlue * f, 0, 255).toInt
    )
  }

  // -------------------------- Geometry --------------------------

  def addBox(x: Double, y: Double, z: Double,
             sx: Double, sy: Double, sz: Double,
             color: Color, solid: Boolean = true): Unit = {
    val b = Box(x, y, z, sx, sy, sz, color, solid)
    triangles ++= boxTriangles(b)
    if (solid) solids += b
  }

  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 factors = Array(0.68, 0.95, 0.58, 0.78, 1.22, 0.40)
    val out = ArrayBuffer[Tri]()
    for (i <- faces.indices) {
      val f = faces(i)
      val c = shade(b.color, factors(i))
      out += Tri(v(f(0)), v(f(1)), v(f(2)), c)
      out += Tri(v(f(0)), v(f(2)), v(f(3)), c)
    }
    out
  }

  def addWallX(x: Double, z0: Double, z1: Double,
               gaps: Seq[(Double, Double)], yBase: Double = 0.0,
               height: Double = 2.8, color: Color = WALL): Unit = {
    var cursor = z0
    for ((a,b) <- gaps.sortBy(_._1)) {
      if (a > cursor + 0.001)
        addBox(x, yBase + height/2, (cursor+a)/2,
          0.22, height, a-cursor, color, true)
      cursor = math.max(cursor,b)
    }
    if (cursor < z1 - 0.001)
      addBox(x, yBase + height/2, (cursor+z1)/2,
        0.22, height, z1-cursor, color, true)
  }

  def addWallZ(z: Double, x0: Double, x1: Double,
               gaps: Seq[(Double, Double)], yBase: Double = 0.0,
               height: Double = 2.8, color: Color = WALL): Unit = {
    var cursor = x0
    for ((a,b) <- gaps.sortBy(_._1)) {
      if (a > cursor + 0.001)
        addBox((cursor+a)/2, yBase + height/2, z,
          a-cursor, height, 0.22, color, true)
      cursor = math.max(cursor,b)
    }
    if (cursor < x1 - 0.001)
      addBox((cursor+x1)/2, yBase + height/2, z,
        x1-cursor, height, 0.22, color, true)
  }

  def addDoor(x: Double, z: Double, width: Double, plane: String,
              hingeSide: Double = 1.0, yBase: Double = 0.0,
              height: Double = 2.65, locked: Boolean = false,
              isExit: Boolean = false, name: String = "Door"): Unit = {
    doors += new Door(x,z,width,plane,hingeSide,yBase,height,locked,isExit,name)
  }

  def makeDoorTriangles(d: Door): ArrayBuffer[Tri] = {
    val c = math.cos(d.angle)
    val s = math.sin(d.angle)
    // Local width and normal before rotating around the hinge.
    val (baseUx, baseUz, baseNx, baseNz, pivotX, pivotZ) =
      if (d.plane == "X") {
        (0.0, d.hingeSide, d.hingeSide, 0.0,
         d.x, d.z - d.hingeSide*d.width/2)
      } else {
        (d.hingeSide, 0.0, 0.0, 1.0,
         d.x - d.hingeSide*d.width/2, d.z)
      }
    def rotateX(ux: Double, uz: Double): Double = ux*c + uz*s
    def rotateZ(ux: Double, uz: Double): Double = uz*c - ux*s
    val ux = rotateX(baseUx, baseUz)
    val uz = rotateZ(baseUx, baseUz)
    val nx = rotateX(baseNx, baseNz)
    val nz = rotateZ(baseNx, baseNz)
    val thick = 0.12
    def point(u: Double, v: Double, y: Double): V3 =
      V3(pivotX + ux*u + nx*v, y, pivotZ + uz*u + nz*v)
    val v = Array(
      point(0,-thick/2,d.yBase), point(d.width,-thick/2,d.yBase),
      point(d.width,-thick/2,d.yBase+d.height), point(0,-thick/2,d.yBase+d.height),
      point(0,thick/2,d.yBase), point(d.width,thick/2,d.yBase),
      point(d.width,thick/2,d.yBase+d.height), point(0,thick/2,d.yBase+d.height)
    )
    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 out = ArrayBuffer[Tri]()
    val factors = Array(0.72, 0.88, 0.56, 0.92, 1.12, 0.42)
    for (i <- faces.indices) {
      val f = faces(i)
      val col = shade(if (d.isExit) new Color(75,40,27) else new Color(94,59,37), factors(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)
    }
    // Small handle on the front side.
    val handle = Box(
      pivotX + ux*d.width*0.82 + nx*0.09,
      d.yBase + d.height*0.48,
      pivotZ + uz*d.width*0.82 + nz*0.09,
      0.08, 0.08, 0.08, new Color(211,174,73), false
    )
    out ++= boxTriangles(handle)
    out
  }

  def buildHouse(): Unit = {
    triangles.clear(); solids.clear(); doors.clear()

    // Main ground-floor slab.
    addBox(12.0,-0.16,9.0,24.0,0.32,18.0,FLOOR,false)

    // Exterior walls, with a front exit opening.
    addBox(0.12,1.4,9.0,0.24,2.8,18.0,WALL,true)
    addBox(23.88,1.4,9.0,0.24,2.8,18.0,WALL,true)
    addWallZ(0.12,0.12,23.88,Seq(),0.0,2.8,WALL_ALT)
    addWallZ(17.88,0.12,23.88,Seq((19.7,21.3)),0.0,2.8,WALL_ALT)

    // Ground-floor partitions. Door openings are real gaps in the wall.
    addWallX(6.0,0.25,17.75,Seq((2.4,3.6),(8.2,9.4),(14.0,15.2)))
    addWallX(12.0,0.25,17.75,Seq((3.0,4.2),(8.0,9.2),(14.6,15.8)))
    addWallZ(6.0,0.25,23.75,Seq((3.5,4.8),(8.0,9.2),(14.3,17.3)))
    addWallZ(12.0,0.25,23.75,Seq((2.0,3.2),(8.5,9.7),(15.5,16.7),(19.9,21.3)))

    addDoor(6.0,3.0,1.2,"X",1.0,0.0,2.65,false,false,"Bedroom door")
    addDoor(6.0,8.8,1.2,"X",-1.0,0.0,2.65,false,false,"Hall door")
    addDoor(6.0,14.6,1.2,"X",1.0,0.0,2.65,false,false,"Storage door")
    addDoor(12.0,3.6,1.2,"X",-1.0,0.0,2.65,false,false,"Kitchen door")
    addDoor(12.0,8.6,1.2,"X",1.0,0.0,2.65,false,false,"Stair hall door")
    addDoor(12.0,15.2,1.2,"X",-1.0,0.0,2.65,false,false,"Back room door")
    addDoor(4.15,6.0,1.3,"Z",1.0,0.0,2.65,false,false,"Room door")
    addDoor(8.6,6.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Hall door")
    addDoor(2.6,12.0,1.2,"Z",1.0,0.0,2.65,false,false,"Lower room door")
    addDoor(9.1,12.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Back hall door")
    addDoor(16.1,12.0,1.2,"Z",1.0,0.0,2.65,false,false,"Back room door")
    addDoor(20.6,12.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Exit hall door")
    addDoor(20.5,17.88,1.6,"Z",1.0,0.0,2.65,true,true,"Locked exit")

    // Bedroom furniture.
    addBox(2.3,0.23,2.1,2.25,0.34,2.9,DARK_WOOD,true)
    addBox(2.3,0.48,2.1,2.05,0.18,2.68,new Color(115,106,94),true)
    addBox(2.3,0.60,1.02,1.08,0.12,0.48,new Color(194,184,166),true)
    for (x <- Seq(1.35,3.25); z <- Seq(0.85,3.25))
      addBox(x,0.13,z,0.17,0.26,0.17,DARK_WOOD,true)

    // Wardrobe.
    addBox(wardrobeX,1.08,wardrobeZ,0.92,2.16,0.86,new Color(61,41,30),true)
    addBox(1.73,1.08,wardrobeZ,0.08,2.03,0.82,new Color(83,55,39),true)

    // Tables, cupboards and other simple room props.
    addBox(4.75,0.42,2.3,0.82,0.82,0.82,WOOD,true)
    addBox(4.75,0.86,2.3,0.88,0.08,0.88,DARK_WOOD,true)
    addBox(9.0,0.65,5.0,1.65,0.14,1.2,WOOD,true)
    for (x <- Seq(8.35,9.65); z <- Seq(4.55,5.45))
      addBox(x,0.30,z,0.13,0.60,0.13,DARK_WOOD,true)
    addBox(15.0,0.90,2.0,1.0,1.8,0.65,DARK_WOOD,true)
    addBox(18.6,0.90,8.6,1.05,1.8,0.65,WOOD,true)
    addBox(4.0,0.90,14.8,1.1,1.8,0.9,DARK_WOOD,true)
    addBox(18.5,0.38,14.7,1.3,0.72,0.8,DARK_WOOD,true)

    // Stairs: 10 actual polygonal steps; the collision floor follows the ramp.
    for (i <- 0 until 10) {
      val h = (i+1)*0.30
      val zc = 6.5 + (i+0.5)*0.40
      addBox(15.8,h/2,zc,3.0,h,0.42,new Color(87,63,46),false)
    }
    // Solid side walls keep the player on the stairs.
    addBox(14.20,1.45,8.5,0.18,2.9,4.2,DARK_WOOD,true)
    addBox(17.40,1.45,8.5,0.18,2.9,4.2,DARK_WOOD,true)
    // Ground floor wall prevents walking through the underside of the upper wing.
    addWallZ(10.5,17.45,23.75,Seq(),0.0,2.8,WALL_ALT)

    // Upper floor platform/wing. Top is y = 3.0.
    addBox(19.0,2.90,14.05,9.4,0.20,7.10,new Color(71,56,43),false)
    // Upper-level walls.
    addWallX(14.30,10.55,17.62,Seq((13.35,14.65)),3.0,2.8,WALL_ALT)
    addWallX(23.75,10.50,17.65,Seq(),3.0,2.8,WALL_ALT)
    addWallZ(10.50,17.35,23.75,Seq(),3.0,2.8,WALL_ALT)
    addWallZ(17.62,14.30,23.75,Seq(),3.0,2.8,WALL_ALT)
    addWallX(18.50,10.55,17.60,Seq((12.75,14.05)),3.0,2.8,WALL)
    addWallZ(14.00,18.50,23.70,Seq((20.0,21.3)),3.0,2.8,WALL)
    addDoor(14.30,14.0,1.3,"X",1.0,3.0,2.65,false,false,"Upper door")
    addDoor(18.50,13.4,1.3,"X",-1.0,3.0,2.65,false,false,"Upper hall door")
    addDoor(20.65,14.0,1.3,"Z",1.0,3.0,2.65,false,false,"Upper room door")

    // Upper-floor furniture at y=3.
    addBox(20.8,3.35,12.1,1.5,0.7,0.9,WOOD,true)
    addBox(22.1,3.55,15.5,1.0,1.1,0.7,DARK_WOOD,true)

    // Wall windows: dark glass plus pale frames.
    addBox(3.0,2.0,0.24,1.4,0.8,0.08,new Color(28,43,51),false)
    addBox(14.0,2.0,0.24,1.4,0.8,0.08,new Color(28,43,51),false)
    addBox(10.0,2.0,0.24,0.62,0.72,0.08,new Color(52,41,31),false)
  }

  // ------------------------ Floor & collision ------------------------

  def inStair(x: Double, z: Double): Boolean =
    x >= 14.30 && x <= 17.30 && z >= 6.50 && z <= 10.50

  def floorY(x: Double, z: Double, upper: Boolean): Double = {
    if (inStair(x,z)) 3.0 * clamp((z-6.50)/4.0,0.0,1.0)
    else if (upper) 3.0
    else 0.0
  }

  def updateLevel(upper: Boolean, x: Double, z: Double): Boolean = {
    if (inStair(x,z)) {
      if (z >= 10.46) true
      else if (z <= 6.54) false
      else upper
    } else upper
  }

  def boxBlocks(b: Box, x: Double, z: Double, upper: Boolean): Boolean = {
    val feet = floorY(x,z,upper)
    val bodyBottom = feet + 0.05
    val bodyTop = feet + 1.65
    val bBottom = b.y - b.sy/2
    val bTop = b.y + b.sy/2
    val vertical = bodyTop > bBottom && bodyBottom < bTop
    vertical && math.abs(x-b.x) < b.sx/2 + 0.19 &&
      math.abs(z-b.z) < b.sz/2 + 0.19
  }

  def doorBlocks(d: Door, x: Double, z: Double, upper: Boolean): Boolean = {
    val feet = floorY(x,z,upper)
    val overlapsY = feet + 1.65 > d.yBase && feet < d.yBase + d.height
    if (!overlapsY || d.angle > 0.92) false
    else if (d.plane == "X")
      math.abs(x-d.x) < 0.34 && math.abs(z-d.z) < d.width/2 + 0.20
    else
      math.abs(z-d.z) < 0.34 && math.abs(x-d.x) < d.width/2 + 0.20
  }

  def blocked(x: Double, z: Double, upper: Boolean): Boolean = {
    if (x < 0.35 || x > 23.65 || z < 0.35) return true
    if (z > 17.95) {
      val exitPassage = doors.exists(d => d.isExit && d.angle > 1.0 &&
        x > 19.35 && x < 21.65 && z < 19.1)
      if (!exitPassage) return true
    }
    solids.exists(b => boxBlocks(b,x,z,upper)) || doors.exists(d => doorBlocks(d,x,z,upper))
  }

  def movePlayer(dx: Double, dz: Double): Unit = {
    val nx = px + dx
    val levelX = updateLevel(onUpperFloor,nx,pz)
    if (!blocked(nx,pz,levelX)) { px = nx; onUpperFloor = levelX }

    val nz = pz + dz
    val levelZ = updateLevel(onUpperFloor,px,nz)
    if (!blocked(px,nz,levelZ)) { pz = nz; onUpperFloor = levelZ }
  }

  // ----------------------------- Gameplay -----------------------------

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

  def nearestDoor(): Option[Door] = {
    val feet = floorY(px,pz,onUpperFloor)
    val nearby = doors.filter(d =>
      dist(px,pz,d.x,d.z) < 1.75 &&
      feet + 1.65 > d.yBase && feet < d.yBase + d.height
    )
    if (nearby.isEmpty) None else Some(nearby.minBy(d => dist(px,pz,d.x,d.z)))
  }

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

    if (hidden) {
      hidden = false
      say("YOU LEFT THE WARDROBE.")
      return
    }

    if (dist(px,pz,wardrobeX,wardrobeZ) < 1.25 && !onUpperFloor) {
      hidden = true
      say("HIDDEN. PRESS E TO LEAVE THE WARDROBE.")
      return
    }

    if (dist(px,pz,vaseX,vaseZ) < 1.25 && !vaseFalling && !vaseBroken && !onUpperFloor) {
      vaseFalling = true
      vaseVel = 0.0
      say("THE VASE IS FALLING...",1.5)
      try { Toolkit.getDefaultToolkit.beep() } catch { case _: Throwable => () }
      return
    }

    nearestDoor() match {
      case Some(d) =>
        if (d.locked && !hasKey) {
          say("LOCKED. FIND THE KEY FIRST!")
        } else {
          d.target = if (d.target > 0.5) 0.0 else 1.35
          if (d.isExit && d.target > 0.5) say("EXIT DOOR OPENING...")
          else say(if (d.target > 0.5) d.name.toUpperCase + " OPENING" else d.name.toUpperCase + " CLOSING")
        }
      case None => say("NOTHING TO INTERACT WITH HERE.")
    }
  }

  def resetDay(): Unit = {
    day += 1
    if (day > 5) {
      gameOver = true
      say("DAY 5 FINISHED - GAME OVER",20.0)
    } else {
      px = 3.2; pz = 4.15; yaw = math.Pi; pitch = 0.0
      onUpperFloor = false; hidden = false; hasKey = false
      doors.foreach(d => { d.angle = 0.0; d.target = 0.0 })
      enemyX = 20.5; enemyZ = 3.2; enemyUpper = false
      enemyTargetX = enemyX; enemyTargetZ = enemyZ
      vaseY = 0.9; vaseVel = 0.0; vaseFalling = false; vaseBroken = false
      noiseTimer = 0.0; wakeProgress = 0.0
      say("DAY " + day + " - YOU WOKE UP AGAIN!",4.0)
      started = true
    }
  }

  def resetGame(): Unit = {
    px = 3.2; pz = 4.15; yaw = math.Pi; pitch = 0.0
    onUpperFloor = false; wakeProgress = 0.0
    started = false; gameOver = false; escaped = false; hidden = false
    hasKey = false; flashlight = true; showMap = false; day = 1
    doors.foreach(d => { d.angle = 0.0; d.target = 0.0 })
    vaseY = 0.9; vaseVel = 0.0; vaseFalling = false; vaseBroken = false
    noiseTimer = 0.0; enemyX = 20.5; enemyZ = 3.2; enemyUpper = false
    enemyTargetX = enemyX; enemyTargetZ = enemyZ; aiTimer = 0.0; patrolIndex = 0
    message = "DAY 1 - PRESS ENTER TO WAKE UP"; messageTimer = 5.0
  }

  def wake(): Unit = {
    started = true
    wakeProgress = 0.0
    say("DAY 1. GET OUT OF THE BEDROOM.",4.0)
  }

  def playerEyeY: Double = floorY(px,pz,onUpperFloor) + 0.55 + 1.05*wakeProgress

  def updateDoors(dt: Double): Unit = {
    doors.foreach { d =>
      val speed = 1.9*dt
      if (d.angle < d.target) d.angle = math.min(d.target,d.angle+speed)
      else if (d.angle > d.target) d.angle = math.max(d.target,d.angle-speed)
    }
  }

  def walkableGrid(x: Double, z: Double, upper: Boolean): Boolean = !blocked(x,z,upper)

  def gridPathStep(sx: Double, sz: Double, tx: Double, tz: Double,
                   upper: Boolean): (Double,Double) = {
    val cell = 0.8
    val w = 30; val h = 23
    def gx(x: Double) = clamp((x/cell).toInt,0,w-1).toInt
    def gz(z: Double) = clamp((z/cell).toInt,0,h-1).toInt
    val start = gz(sz)*w + gx(sx)
    val goal = gz(tz)*w + gx(tx)
    if (start == goal) return (tx,tz)
    val prev = Array.fill(w*h)(-1)
    val q = mutable.Queue[Int]()
    prev(start) = start; q.enqueue(start)
    val ds = Array((1,0),(-1,0),(0,1),(0,-1))
    var checks = 0
    while (q.nonEmpty && prev(goal) == -1 && checks < w*h) {
      val cur = q.dequeue()
      val cx = cur%w; val cz = cur/w
      for ((dx,dz) <- ds) {
        val nx = cx+dx; val nz = cz+dz
        if (nx>=0 && nz>=0 && nx<w && nz<h) {
          val ni = nz*w+nx
          if (prev(ni) == -1 && walkableGrid(nx*cell+cell/2,nz*cell+cell/2,upper)) {
            prev(ni)=cur; q.enqueue(ni)
          }
        }
      }
      checks += 1
    }
    if (prev(goal) == -1) return (tx,tz)
    var cur = goal
    while (prev(cur) != start && cur != start && prev(cur) >= 0) cur = prev(cur)
    ((cur%w)*cell+cell/2,(cur/w)*cell+cell/2)
  }

  def updateEnemy(dt: Double): Unit = {
    if (hidden || gameOver || escaped) return
    val dPlayer = dist(px,pz,enemyX,enemyZ)
    var tx = enemyX; var tz = enemyZ
    var targetUpper = enemyUpper

    if (noiseTimer > 0.0) {
      // A ground-floor noise can draw the enemy down from the gallery.
      if (enemyUpper && !inStair(enemyX,enemyZ)) { tx = 15.8; tz = 10.30; targetUpper = true }
      else if (enemyUpper && inStair(enemyX,enemyZ)) { tx = 15.8; tz = 6.25; targetUpper = false }
      else { tx = noiseX; tz = noiseZ; targetUpper = false }
    } else if (dPlayer < 10.5) {
      // The pursuer uses the stair opening to travel between the two levels.
      if (onUpperFloor && !enemyUpper) {
        if (enemyZ < 6.45 || enemyX < 14.3 || enemyX > 17.3) { tx = 15.8; tz = 6.70 }
        else { tx = 15.8; tz = 10.75 }
        targetUpper = false
      } else if (!onUpperFloor && enemyUpper) {
        if (enemyZ > 10.55 || enemyX < 14.3 || enemyX > 17.3) { tx = 15.8; tz = 10.30 }
        else { tx = 15.8; tz = 6.25 }
        targetUpper = true
      } else {
        tx = px; tz = pz; targetUpper = onUpperFloor
      }
    } else {
      val p = patrol(patrolIndex)
      tx = p._1; tz = p._2; targetUpper = false
      if (dist(enemyX,enemyZ,tx,tz)<0.8) patrolIndex = (patrolIndex+1)%patrol.length
    }

    aiTimer += dt
    if (aiTimer >= 0.55) {
      aiTimer = 0.0
      val step = gridPathStep(enemyX,enemyZ,tx,tz,enemyUpper)
      enemyTargetX = step._1; enemyTargetZ = step._2
    }

    val dx = enemyTargetX-enemyX; val dz = enemyTargetZ-enemyZ
    val len = math.sqrt(dx*dx+dz*dz)
    if (len > 0.06) {
      val step = math.min(len,0.95*dt)
      val nx = enemyX + dx/len*step
      val nz = enemyZ + dz/len*step
      val nxLevel = updateLevel(enemyUpper,nx,enemyZ)
      if (!blocked(nx,enemyZ,nxLevel)) { enemyX=nx; enemyUpper=nxLevel }
      val nzLevel = updateLevel(enemyUpper,enemyX,nz)
      if (!blocked(enemyX,nz,nzLevel)) { enemyZ=nz; enemyUpper=nzLevel }
    }

    if (!hidden && enemyUpper == onUpperFloor &&
        dist(px,pz,enemyX,enemyZ)<0.62) resetDay()
  }

  def updateGame(): Unit = {
    val dt = 0.033
    updateDoors(dt)
    if (!started || gameOver || escaped) return
    if (messageTimer > 0.0) messageTimer -= dt
    if (noiseTimer > 0.0) noiseTimer -= dt
    wakeProgress = math.min(1.0,wakeProgress+dt*0.65)

    if (vaseFalling && !vaseBroken) {
      vaseVel += 9.8*dt
      vaseY -= vaseVel*dt
      if (vaseY <= 0.12) {
        vaseY=0.12; vaseBroken=true; vaseFalling=false
        noiseX=vaseX; noiseZ=vaseZ; noiseTimer=12.0
        say("CRASH! THE ENEMY HEARD THAT!",4.0)
      }
    }

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

    if (!hasKey && !onUpperFloor && dist(px,pz,keyX,keyZ)<0.55) {
      hasKey=true; say("KEY COLLECTED. FIND THE EXIT!",3.5)
    }

    val exit = doors.find(_.isExit)
    if (exit.isDefined && exit.get.angle>1.0 && !onUpperFloor &&
        px>19.4 && px<21.6 && pz>18.0) {
      escaped=true; say("YOU ESCAPED THE OLD HOUSE!",20.0)
    }

    updateEnemy(dt)
  }

  // ---------------------- Camera & depth renderer ----------------------

  def camera(v: V3): CV = {
    val dx=v.x-px; val dy=v.y-playerEyeY; val dz=v.z-pz
    val cx=dx*math.cos(yaw)-dz*math.sin(yaw)
    val forward=dx*math.sin(yaw)+dz*math.cos(yaw)
    val cp=math.cos(pitch); val sp=math.sin(pitch)
    CV(cx,dy*cp-forward*sp,dy*sp+forward*cp)
  }

  def clipNear(poly0: Vector[CV]): Vector[CV] = {
    val out=ArrayBuffer[CV]()
    var i=0
    while (i<poly0.length) {
      val a=poly0(i); val b=poly0((i+1)%poly0.length)
      val ia=a.z>=NEAR; val ib=b.z>=NEAR
      if (ia && ib) out += b
      else if (ia && !ib) {
        val t=(NEAR-a.z)/(b.z-a.z)
        out += CV(a.x+(b.x-a.x)*t,a.y+(b.y-a.y)*t,NEAR)
      } else if (!ia && ib) {
        val t=(NEAR-a.z)/(b.z-a.z)
        out += CV(a.x+(b.x-a.x)*t,a.y+(b.y-a.y)*t,NEAR)
        out += b
      }
      i+=1
    }
    out.toVector
  }

  def edge(ax: Double, ay: Double, bx: Double, by: Double,
           px0: Double, py0: Double): Double =
    (px0-ax)*(by-ay)-(py0-ay)*(bx-ax)

  def rasterTriangle(a: CV,b: CV,c: CV,color: Color,w: Int,h: Int): Unit = {
    val ax=w/2.0+a.x*FOCAL/a.z; val ay=h/2.0-a.y*FOCAL/a.z
    val bx=w/2.0+b.x*FOCAL/b.z; val by=h/2.0-b.y*FOCAL/b.z
    val cx=w/2.0+c.x*FOCAL/c.z; val cy=h/2.0-c.y*FOCAL/c.z
    val area=edge(ax,ay,bx,by,cx,cy)
    if (math.abs(area)<0.00001) return

    val minX=math.max(0,math.floor(math.min(ax,math.min(bx,cx))).toInt)
    val maxX=math.min(w-1,math.ceil(math.max(ax,math.max(bx,cx))).toInt)
    val minY=math.max(0,math.floor(math.min(ay,math.min(by,cy))).toInt)
    val maxY=math.min(h-1,math.ceil(math.max(ay,math.max(by,cy))).toInt)
    if (minX>maxX || minY>maxY) return

    val rgb=color.getRGB
    val invA=1.0/a.z; val invB=1.0/b.z; val invC=1.0/c.z
    var y=minY
    while (y<=maxY) {
      var x=minX
      val py0=y+0.5
      while (x<=maxX) {
        val px0=x+0.5
        val wa=edge(bx,by,cx,cy,px0,py0)/area
        val wb=edge(cx,cy,ax,ay,px0,py0)/area
        val wc=1.0-wa-wb
        if (wa >= -0.00001 && wb >= -0.00001 && wc >= -0.00001) {
          val q=wa*invA+wb*invB+wc*invC
          val idx=y*w+x
          if (q>zBuffer(idx)) { zBuffer(idx)=q; pixels(idx)=rgb }
        }
        x+=1
      }
      y+=1
    }
  }

  def dynamicGeometry(): ArrayBuffer[Tri] = {
    val out=ArrayBuffer[Tri]()
    doors.foreach(d => out ++= makeDoorTriangles(d))

    // Key is a small low-poly gold object.
    if (!hasKey) {
      out ++= boxTriangles(Box(keyX,0.20,keyZ,0.42,0.06,0.06,new Color(236,190,42),false))
      out ++= boxTriangles(Box(keyX-0.22,0.20,keyZ,0.13,0.09,0.11,new Color(250,213,66),false))
    }

    // Vase and broken fragments.
    if (!vaseBroken) {
      out ++= boxTriangles(Box(vaseX,vaseY,vaseZ,0.24,0.34,0.24,new Color(118,64,48),false))
      out ++= boxTriangles(Box(vaseX,vaseY+0.17,vaseZ,0.16,0.05,0.16,new Color(155,100,68),false))
    } else {
      out ++= boxTriangles(Box(vaseX-0.12,0.06,vaseZ,0.18,0.05,0.12,new Color(118,64,48),false))
      out ++= boxTriangles(Box(vaseX+0.13,0.05,vaseZ+0.04,0.14,0.04,0.09,new Color(155,100,68),false))
    }

    // Original simple low-poly pursuer (not the Granny character model).
    val ey=if (enemyUpper) 3.0 else floorY(enemyX,enemyZ,false)
    out ++= boxTriangles(Box(enemyX,ey+1.02,enemyZ,0.50,1.12,0.36,new Color(65,17,22),false))
    out ++= boxTriangles(Box(enemyX,ey+1.78,enemyZ,0.42,0.43,0.42,new Color(174,151,127),false))
    out ++= boxTriangles(Box(enemyX,ey+1.99,enemyZ,0.47,0.14,0.47,new Color(28,22,20),false))
    out ++= boxTriangles(Box(enemyX-0.32,ey+1.04,enemyZ,0.17,0.82,0.19,new Color(94,61,47),false))
    out ++= boxTriangles(Box(enemyX+0.32,ey+1.04,enemyZ,0.17,0.82,0.19,new Color(94,61,47),false))
    out ++= boxTriangles(Box(enemyX-0.14,ey+0.31,enemyZ,0.19,0.60,0.22,new Color(35,28,27),false))
    out ++= boxTriangles(Box(enemyX+0.14,ey+0.31,enemyZ,0.19,0.60,0.22,new Color(35,28,27),false))
    out
  }

  def lightColor(t: Tri): Color = {
    val cx=(t.a.x+t.b.x+t.c.x)/3.0
    val cz=(t.a.z+t.b.z+t.c.z)/3.0
    val dx=cx-px; val dz=cz-pz
    val d=math.sqrt(dx*dx+dz*dz)
    val dot=if (d<0.001) 1.0 else (dx*math.sin(yaw)+dz*math.cos(yaw))/d
    val light=if (flashlight) 0.18+0.82*math.max(0.0,dot+0.12)/(1.0+d*0.025)
              else 0.16
    shade(t.color,light)
  }

  def renderWorld(w: Int,h: Int): Unit = {
    if (image==null || image.getWidth!=w || image.getHeight!=h) {
      image=new BufferedImage(w,h,BufferedImage.TYPE_INT_RGB)
      pixels=image.getRaster.getDataBuffer.asInstanceOf[DataBufferInt].getData
      zBuffer=new Array[Double](w*h)
    }
    val bg=image.createGraphics()
    bg.setPaint(new GradientPaint(0f,0f,new Color(9,11,15),0f,(h*0.5).toFloat,new Color(47,40,34)))
    bg.fillRect(0,0,w,h)
    bg.setPaint(new GradientPaint(0f,(h*0.5).toFloat,new Color(47,39,31),0f,h.toFloat,new Color(8,8,10)))
    bg.fillRect(0,h/2,w,h/2)
    bg.dispose()
    java.util.Arrays.fill(zBuffer,0.0)

    val dyn=dynamicGeometry()
    val all=triangles ++ dyn
    all.foreach { t =>
      val clipped=clipNear(Vector(camera(t.a),camera(t.b),camera(t.c)))
      if (clipped.length>=3) {
        val col=lightColor(t)
        var i=1
        while (i<clipped.length-1) {
          rasterTriangle(clipped(0),clipped(i),clipped(i+1),col,w,h)
          i+=1
        }
      }
    }
  }

  def drawMap(g: Graphics2D,w: Int): Unit = {
    val ox=w-176; val oy=100; val scale=6.0
    g.setColor(new Color(0,0,0,180)); g.fillRect(ox-6,oy-6,150,126)
    g.setColor(new Color(125,112,96))
    solids.foreach { b =>
      if (b.y<2.0) g.fillRect(ox+(b.x-b.sx/2).toInt*scale.toInt,
        oy+(b.z-b.sz/2).toInt*scale.toInt,math.max(1,(b.sx*scale).toInt),math.max(1,(b.sz*scale).toInt))
    }
    g.setColor(Color.RED); g.fillOval(ox+(enemyX*scale).toInt-3,oy+(enemyZ*scale).toInt-3,7,7)
    g.setColor(Color.CYAN); g.fillOval(ox+(px*scale).toInt-3,oy+(pz*scale).toInt-3,7,7)
  }

  def drawHUD(g: Graphics2D,w: Int,h: Int): Unit = {
    g.setColor(new Color(0,0,0,175)); g.fillRect(0,0,w,58)
    g.setFont(new Font("Arial",Font.BOLD,16)); g.setColor(new Color(245,222,184))
    g.drawString("DAY "+day+"   KEY: "+(if(hasKey) "YES" else "NO")+
      "   FLOOR: "+(if(onUpperFloor) "UPSTAIRS" else "GROUND"),14,23)
    g.setFont(new Font("Arial",Font.PLAIN,13)); g.setColor(Color.WHITE)
    g.drawString("WASD Move | Mouse drag Look | SHIFT Run | E Interact/Open door | F Flashlight | M Map | R Restart",14,45)
    g.setColor(new Color(0,0,0,180)); g.fillRect(0,h-39,w,39)
    if (messageTimer>0.0) {
      g.setFont(new Font("Arial",Font.BOLD,16)); g.setColor(new Color(255,225,170))
      g.drawString(message,14,h-14)
    }
    if (!hidden && enemyUpper==onUpperFloor && dist(px,pz,enemyX,enemyZ)<5.0) {
      g.setFont(new Font("Arial",Font.BOLD,15)); g.setColor(new Color(255,70,60))
      g.drawString("SOMETHING IS NEAR...",w-235,82)
    }
    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(90,255,125) else new Color(255,75,60))
      val title=if(escaped) "YOU ESCAPED!" else "GAME OVER"
      g.drawString(title,(w-g.getFontMetrics.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+46)
    }
  }

  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(59,23,20)))
    g.fillRect(0,0,w,h)
    g.setColor(new Color(25,20,18)); g.fillRect(80,85,w-160,h-155)
    g.setColor(new Color(65,45,33)); g.fillRect(130,120,w-260,h-225)
    g.setColor(new Color(32,24,22)); g.fillRect(160,h-170,260,55)
    g.setColor(new Color(100,80,65)); g.fillRect(180,h-195,220,32)
    g.setColor(new Color(40,24,18)); g.fillRect(w-270,150,90,250)
    g.setColor(new Color(210,165,68)); g.fillOval(w-211,275,11,11)
    g.setFont(new Font("Arial",Font.BOLD,45)); g.setColor(new Color(222,211,194))
    val title="OLD HOUSE 3D"; g.drawString(title,(w-g.getFontMetrics.stringWidth(title))/2,70)
    g.setFont(new Font("Arial",Font.BOLD,21)); g.setColor(new Color(255,95,75))
    val d="DAY 1"; g.drawString(d,(w-g.getFontMetrics.stringWidth(d))/2,h-105)
    g.setFont(new Font("Arial",Font.BOLD,20)); g.setColor(new Color(250,220,160))
    val p="PRESS ENTER TO WAKE UP"; g.drawString(p,(w-g.getFontMetrics.stringWidth(p))/2,h-62)
  }

  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 {
        renderWorld(getWidth,getHeight)
        g.drawImage(image,0,0,null)
        g.setColor(new Color(230,230,215,170))
        g.drawLine(getWidth/2-5,getHeight/2,getWidth/2+5,getHeight/2)
        g.drawLine(getWidth/2,getHeight/2-5,getWidth/2,getHeight/2+5)
        drawHUD(g,getWidth,getHeight)
        if (showMap) drawMap(g,getWidth)
      }
      g.dispose()
    }
  }

  def start(): Unit = {
    buildHouse(); resetGame()
    SwingUtilities.invokeLater(new Runnable {
      def run(): Unit = {
        frame=new JFrame("OLD HOUSE 3D - Scala Horror Prototype")
        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()
            if (k==KeyEvent.VK_LEFT) yaw -= 0.08
            if (k==KeyEvent.VK_RIGHT) yaw += 0.08
          }
          override def keyReleased(e: KeyEvent): Unit = keys.remove(e.getKeyCode)
        })
        panel.addMouseListener(new MouseAdapter {
          override def mousePressed(e: MouseEvent): Unit = { lastMouseX=e.getX; lastMouseY=e.getY; panel.requestFocusInWindow() }
          override def mouseReleased(e: MouseEvent): Unit = { lastMouseX = -1; lastMouseY = -1 }
        })
        panel.addMouseMotionListener(new MouseMotionAdapter {
          override def mouseDragged(e: MouseEvent): Unit = {
            if (started && !gameOver && !escaped && lastMouseX>=0) {
              val dx=e.getX-lastMouseX; val dy=e.getY-lastMouseY
              yaw += dx*0.007; pitch=clamp(pitch-dy*0.004,-0.55,0.55)
              lastMouseX=e.getX; lastMouseY=e.getY
            }
          }
        })
        panel.addFocusListener(new java.awt.event.FocusAdapter {
          override def focusLost(e: java.awt.event.FocusEvent): Unit = keys.clear()
        })
        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()
      }
    })
  }
}

OldHouse3DImproved.start()