package snake package web import scala.scalajs.js import scala.scalajs.js.Dynamic.global import scala.scalajs.js.typedarray.Uint32Array import scala.util.control.NonFatal /** * Browser glue: turns a canvas into a Platform. Impure by nature; not part of the exercises' logic. * Snake, Pong, the replay viewer and Life share it; only Snake performs `LoadBest` and `SaveBest`, so only Snake * touches storage, and only Life does anything with clicks. * `js.Dynamic` lets us call browser APIs by name without type facades (and without extra libraries). */ final class BrowserPlatform(canvas: js.Dynamic) extends Platform: private val ctx = canvas.getContext("2d") private val window = global.window // Keys that scroll the page should only steer the game. private val prevent = Set("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " ") private def listen(kind: String, cb: String => Unit): Unit = val handler: js.Function1[js.Dynamic, Unit] = e => val key = e.key.asInstanceOf[String] if prevent(key) then e.preventDefault() cb(key) window.addEventListener(kind, handler) def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit = ctx.fillStyle = color ctx.fillRect(x, y, w, h) def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit = ctx.fillStyle = color ctx.font = s"${size}px monospace" ctx.textAlign = align ctx.fillText(text, x, y) def randomSeed(): Long = val buf = new Uint32Array(1) global.crypto.getRandomValues(buf) buf(0).toLong // m10: where the best score survives a reload. private val bestKey = "pure-snake-best" // Storage can be missing, blocked (privacy settings) or hold anything: all of that reads as 0, and nothing throws. // `window.localStorage` is read inside the `try`, because some browsers throw as soon as it is touched. def loadBest(): Int = try val raw = window.localStorage.getItem(bestKey) if raw == null || js.isUndefined(raw) then 0 else // Only what saveBest writes counts: decimal digits, at most Int.MaxValue (so all three tracks read alike). val s = raw.asInstanceOf[String] if s.matches("[0-9]{1,10}") && s.toDouble <= Int.MaxValue then s.toDouble.toInt else 0 catch case NonFatal(_) => 0 // A record that cannot be stored is lost; the game goes on. def saveBest(score: Int): Unit = try window.localStorage.setItem(bestKey, score.toString) catch case NonFatal(_) => () // ignored on purpose // t is the number of milliseconds since the first animation frame. def startClock(onTime: Long => Unit): Unit = def frame(start: Option[Double]): js.Function1[Double, Unit] = now => val t0 = start.getOrElse(now) onTime((now - t0).toLong) window.requestAnimationFrame(frame(Some(t0))) window.requestAnimationFrame(frame(None)) def onKeyDown(cb: String => Unit): Unit = listen("keydown", cb) def onKeyUp(cb: String => Unit): Unit = listen("keyup", cb) // m13: clicks on the canvas (not the window), converted to canvas pixels. The listener is added only when a runtime // asks for clicks. The page may show the canvas at another size than its width x height (CSS scaling, zoom) and draws // a border around it, so the window coordinates are made relative to the drawing area (bounding rectangle minus the // border, clientLeft/clientTop) and then scaled by the canvas size over its shown size (clientWidth/clientHeight). def onClick(cb: (Int, Int) => Unit): Unit = val handler: js.Function1[js.Dynamic, Unit] = e => val r = canvas.getBoundingClientRect() def axis(client: String, start: String, border: String, size: String, shown: String): Int = val offset = e.selectDynamic(client).asInstanceOf[Double] - r.selectDynamic(start).asInstanceOf[Double] - canvas.selectDynamic(border).asInstanceOf[Double] math.floor(offset * canvas.selectDynamic(size).asInstanceOf[Double] / canvas.selectDynamic(shown).asInstanceOf[Double]).toInt cb(axis("clientX", "left", "clientLeft", "width", "clientWidth"), axis("clientY", "top", "clientTop", "height", "clientHeight")) canvas.addEventListener("click", handler)