Last active
May 21, 2026 10:54
-
-
Save rbobillot/49ba78556a6f2762bfb5071865abf7a0 to your computer and use it in GitHub Desktop.
Scala 2048 TUI using Layoutz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //> using dep xyz.matthieucourt::layoutz:0.7.0 | |
| //> using dep io.getkyo::kyo-core:1.0.0-RC2 | |
| import layoutz.{Text as UiText, *} | |
| import kyo.* | |
| type Grid = Vector[Vector[Int]] | |
| case class Cell(row: Int, col: Int) | |
| case class TileMotion(from: Cell, to: Cell, value: Int) | |
| enum Direction: | |
| case Up, Down, Left, Right | |
| def slide(grid: Grid): SlideResult = | |
| val normalised = Direction.toSlideLeftView(this, grid) | |
| val result = | |
| if this == Direction.Down then GridEngine.slideRight(normalised) | |
| else GridEngine.slideLeft(normalised) | |
| Direction.fromSlideLeftView(this, result) | |
| private object Direction: | |
| private def toSlideLeftView(direction: Direction, grid: Grid): Grid = | |
| direction match | |
| case Direction.Left => grid | |
| case Direction.Right => grid.map(_.reverse) | |
| case Direction.Up => GridEngine.transpose(grid) | |
| case Direction.Down => GridEngine.transpose(grid) | |
| private def fromSlideLeftView(direction: Direction, | |
| result: SlideResult | |
| ): SlideResult = | |
| direction match | |
| case Direction.Left => result | |
| case Direction.Right => | |
| result | |
| .mapGrid(_.map(_.reverse)) | |
| .mapCells(cell => Cell(cell.row, Config.Size - 1 - cell.col)) | |
| case Direction.Up | Direction.Down => | |
| result | |
| .mapGrid(GridEngine.transpose) | |
| .mapCells(cell => Cell(cell.col, cell.row)) | |
| case class SlideResult( | |
| grid: Grid, | |
| score: Int, | |
| mergedSources: Map[Cell, Int], | |
| motions: List[TileMotion] | |
| ): | |
| def mapGrid(f: Grid => Grid): SlideResult = copy(grid = f(grid)) | |
| def mapCells(f: Cell => Cell): SlideResult = | |
| copy( | |
| mergedSources = mergedSources.map { case (cell, source) => | |
| f(cell) -> source | |
| }, | |
| motions = motions.map(motion => | |
| motion.copy(from = f(motion.from), to = f(motion.to)) | |
| ) | |
| ) | |
| case class GameState( | |
| grid: Grid, | |
| score: Int, | |
| best: Int, | |
| won: Boolean, | |
| gameOver: Boolean, | |
| tick: Int = 0, | |
| spawnCell: Option[Cell] = None, | |
| slideMotions: List[TileMotion] = Nil, | |
| mergedSources: Map[Cell, Int] = Map.empty, | |
| lastGained: Int = 0, | |
| pendingMoves: List[Direction] = Nil | |
| ) | |
| enum GameEvent: | |
| case Move(direction: Direction) | |
| case Restart | |
| case Tick | |
| case Quit | |
| private case class Rgb(r: Int, g: Int, b: Int): | |
| def toColor: Color = Color.True(r, g, b) | |
| private case class TileColors(number: Color, tile: Color) | |
| private case class TileLineStyle( | |
| background: Color, | |
| foreground: Color, | |
| text: String, | |
| bold: Boolean | |
| ) | |
| private object Theme: | |
| enum NamedColor(val rgb: Rgb): | |
| case DarkText extends NamedColor(Rgb(119, 110, 101)) | |
| case LightText extends NamedColor(Rgb(249, 246, 242)) | |
| case EmptyCell extends NamedColor(Rgb(205, 193, 180)) | |
| case BoardBg extends NamedColor(Rgb(187, 173, 160)) | |
| case BoardBorder extends NamedColor(Rgb(143, 130, 118)) | |
| case MergeFlash extends NamedColor(Rgb(255, 255, 255)) | |
| case Tile2 extends NamedColor(Rgb(238, 228, 218)) | |
| case Tile4 extends NamedColor(Rgb(237, 224, 200)) | |
| case Tile8 extends NamedColor(Rgb(242, 177, 121)) | |
| case Tile16 extends NamedColor(Rgb(245, 149, 99)) | |
| case Tile32 extends NamedColor(Rgb(246, 124, 95)) | |
| case Tile64 extends NamedColor(Rgb(246, 94, 59)) | |
| case Tile128 extends NamedColor(Rgb(237, 207, 114)) | |
| case Tile256 extends NamedColor(Rgb(237, 204, 97)) | |
| case Tile512 extends NamedColor(Rgb(237, 200, 80)) | |
| case Tile1024 extends NamedColor(Rgb(237, 197, 63)) | |
| case Tile2048 extends NamedColor(Rgb(237, 194, 46)) | |
| case TileBeyond extends NamedColor(Rgb(60, 58, 50)) | |
| def toColor: Color = rgb.toColor | |
| enum Tile(val tileValue: Int, numberColor: NamedColor, tileColor: NamedColor): | |
| case `0` extends Tile(0, NamedColor.DarkText, NamedColor.EmptyCell) | |
| case `2` extends Tile(2, NamedColor.DarkText, NamedColor.Tile2) | |
| case `4` extends Tile(4, NamedColor.DarkText, NamedColor.Tile4) | |
| case `8` extends Tile(8, NamedColor.LightText, NamedColor.Tile8) | |
| case `16` extends Tile(16, NamedColor.LightText, NamedColor.Tile16) | |
| case `32` extends Tile(32, NamedColor.LightText, NamedColor.Tile32) | |
| case `64` extends Tile(64, NamedColor.LightText, NamedColor.Tile64) | |
| case `128` extends Tile(128, NamedColor.LightText, NamedColor.Tile128) | |
| case `256` extends Tile(256, NamedColor.LightText, NamedColor.Tile256) | |
| case `512` extends Tile(512, NamedColor.LightText, NamedColor.Tile512) | |
| case `1024` extends Tile(1024, NamedColor.LightText, NamedColor.Tile1024) | |
| case `2048` extends Tile(2048, NamedColor.LightText, NamedColor.Tile2048) | |
| def colors: TileColors = | |
| TileColors(numberColor.toColor, tileColor.toColor) | |
| object Tile: | |
| private val byValue: Map[Int, Tile] = | |
| values.map(tile => tile.tileValue -> tile).toMap | |
| private val beyondColors: TileColors = | |
| TileColors(NamedColor.LightText.toColor, NamedColor.TileBeyond.toColor) | |
| def forValue(value: Int): TileColors = | |
| byValue.get(value).fold(beyondColors)(_.colors) | |
| /** Runs Kyo effects from Layoutz's synchronous update/init hooks. */ | |
| private object KyoFx: | |
| import AllowUnsafe.embrace.danger | |
| def io[A](computation: A < Sync): A = | |
| Sync.Unsafe.evalOrThrow(computation) | |
| def spawn(computation: Unit < Sync): Unit = | |
| KyoApp.Unsafe | |
| .runAndBlock(1.second)(Fiber.initUnscoped(computation).map(_ => ())) | |
| .getOrThrow | |
| def logEventAsync(message: String): Unit = | |
| spawn(Console.printLineErr(message).map(_ => ())) | |
| private object Config: | |
| val Size = 4 | |
| val CellW = 6 | |
| val CellH = 3 | |
| val GapW = 1 | |
| val GapV = 1 | |
| val BoardPad = 1 | |
| val BoardW = BoardPad + Size * CellW + (Size - 1) * GapW + BoardPad | |
| val AnimPulsePeriod = 6 | |
| val SlideAnimFrames = 10 | |
| val SpawnAnimFrames = 10 | |
| val MergeFlashFrames = 8 | |
| val ScoreFlashFrames = 10 | |
| val AnimFrames = SlideAnimFrames + math.max(SpawnAnimFrames, MergeFlashFrames) | |
| val TickMs = 10 | |
| val WinTile = 2048 | |
| val InitialSpawns = 2 | |
| val SpawnTwoWeight = 9 | |
| val SpawnRollSize = 10 | |
| val EmptyValue = 0 | |
| val BoardBg = Theme.NamedColor.BoardBg.toColor | |
| val BoardBorder = Theme.NamedColor.BoardBorder.toColor | |
| val MergeFlash = Theme.NamedColor.MergeFlash.toColor | |
| private object GridEngine: | |
| import Config.* | |
| private val NeighborOffsets = List(Cell(0, 1), Cell(1, 0)) | |
| def empty: Grid = | |
| Vector.fill(Size)(Vector.fill(Size)(EmptyValue)) | |
| def withCell(grid: Grid, cell: Cell, value: Int): Grid = | |
| setCell(grid, cell, value) | |
| def freshGrid: Grid < Sync = | |
| Loop(InitialSpawns, empty) { case (remaining, grid) => | |
| if remaining <= 0 then Loop.done(grid) | |
| else | |
| addRandomTile(grid).map { case (next, _) => | |
| Loop.continue(remaining - 1, next) | |
| } | |
| } | |
| def addRandomTile(grid: Grid): (Grid, Option[Cell]) < Sync = | |
| freeCells(grid) match | |
| case Nil => (grid, None) | |
| case cells => | |
| for | |
| idx <- Random.nextInt(cells.size) | |
| roll <- Random.nextInt(SpawnRollSize) | |
| yield | |
| val cell = cells(idx) | |
| val value = if roll < SpawnTwoWeight then 2 else 4 | |
| (setCell(grid, cell, value), Some(cell)) | |
| def canMove(grid: Grid): Boolean = | |
| freeCells(grid).nonEmpty || hasMergeableNeighbor(grid) | |
| def slideLeft(grid: Grid): SlideResult = | |
| val rowResults = grid.zipWithIndex.map { case (row, rowIndex) => | |
| mergeRowLeft(row, rowIndex) | |
| } | |
| SlideResult( | |
| grid = rowResults.map(_.row), | |
| score = rowResults.map(_.score).sum, | |
| mergedSources = rowResults.map(_.mergedSources).reduce(_ ++ _), | |
| motions = rowResults.flatMap(_.motions).toList | |
| ) | |
| def slideRight(grid: Grid): SlideResult = | |
| slideLeft(grid.map(_.reverse)) | |
| .mapGrid(_.map(_.reverse)) | |
| .mapCells(cell => Cell(cell.row, Size - 1 - cell.col)) | |
| def transpose(grid: Grid): Grid = | |
| Vector.tabulate(Size, Size)((row, col) => grid(col)(row)) | |
| private case class RowMergeResult( | |
| row: Vector[Int], | |
| score: Int, | |
| mergedSources: Map[Cell, Int], | |
| motions: List[TileMotion] | |
| ) | |
| private def mergeRowLeft(row: Vector[Int], rowIndex: Int): RowMergeResult = | |
| val sources = row.zipWithIndex.collect { | |
| case (value, col) if value != EmptyValue => (col, value) | |
| }.toList | |
| val (mergedRow, score, mergesByCol, motions) = mergeTiles(sources, rowIndex) | |
| RowMergeResult( | |
| row = mergedRow, | |
| score = score, | |
| mergedSources = mergesByCol.map { case (col, source) => | |
| Cell(rowIndex, col) -> source | |
| }, | |
| motions = motions | |
| ) | |
| private def setCell(grid: Grid, cell: Cell, value: Int): Grid = | |
| grid.updated(cell.row, grid(cell.row).updated(cell.col, value)) | |
| private def freeCells(grid: Grid): List[Cell] = | |
| for | |
| row <- grid.indices.toList | |
| col <- grid(row).indices | |
| if grid(row)(col) == EmptyValue | |
| yield Cell(row, col) | |
| private def hasMergeableNeighbor(grid: Grid): Boolean = | |
| grid.indices.exists: row => | |
| grid(row).indices.exists: col => | |
| NeighborOffsets.exists: offset => | |
| val neighbor = Cell(row + offset.row, col + offset.col) | |
| inBounds(neighbor) && grid(row)(col) == grid(neighbor.row)( | |
| neighbor.col | |
| ) | |
| private def inBounds(cell: Cell): Boolean = | |
| cell.row >= 0 && cell.row < Size && cell.col >= 0 && cell.col < Size | |
| private case class MergeAcc( | |
| rowIndex: Int, | |
| remaining: List[(Int, Int)], | |
| merged: List[(Int, Int)], | |
| score: Int, | |
| mergedSources: Map[Int, Int], | |
| motions: List[TileMotion] | |
| ): | |
| private def destCol: Int = merged.length | |
| private def dest: Cell = Cell(rowIndex, destCol) | |
| def toResult: (Vector[Int], Int, Map[Int, Int], List[TileMotion]) = | |
| ( | |
| Vector.from(merged.reverse.map(_._2)).padTo(Size, EmptyValue), | |
| score, | |
| mergedSources, | |
| motions.reverse | |
| ) | |
| def slide(fromCol: Int, value: Int, rest: List[(Int, Int)]): MergeAcc = | |
| copy( | |
| remaining = rest, | |
| merged = (destCol, value) :: merged, | |
| motions = TileMotion(Cell(rowIndex, fromCol), dest, value) :: motions | |
| ) | |
| def mergePair( | |
| fromCol: Int, | |
| value: Int, | |
| nextCol: Int, | |
| nextValue: Int, | |
| rest: List[(Int, Int)] | |
| ): MergeAcc = | |
| copy( | |
| remaining = rest, | |
| merged = (destCol, value * 2) :: merged, | |
| score = score + value * 2, | |
| mergedSources = mergedSources + (destCol -> value), | |
| motions = TileMotion(Cell(rowIndex, fromCol), dest, value) :: | |
| TileMotion(Cell(rowIndex, nextCol), dest, nextValue) :: | |
| motions | |
| ) | |
| private def mergeTiles( | |
| sources: List[(Int, Int)], | |
| rowIndex: Int | |
| ): (Vector[Int], Int, Map[Int, Int], List[TileMotion]) = | |
| Loop(MergeAcc(rowIndex, sources, Nil, 0, Map.empty, Nil)) { acc => | |
| acc.remaining match | |
| case Nil => | |
| Loop.done(acc.toResult) | |
| case (fromCol, value) :: (nextCol, nextValue) :: rest if value == nextValue => | |
| Loop.continue(acc.mergePair(fromCol, value, nextCol, nextValue, rest)) | |
| case (fromCol, value) :: rest => | |
| Loop.continue(acc.slide(fromCol, value, rest)) | |
| }.eval | |
| private object Input: | |
| private def keyChar(key: Key, lower: Char, upper: Char): Boolean = | |
| key == Key.Char(lower) || key == Key.Char(upper) | |
| val eventFor: PartialFunction[Key, GameEvent] = | |
| case Key.Up => GameEvent.Move(Direction.Up) | |
| case Key.Down => GameEvent.Move(Direction.Down) | |
| case Key.Left => GameEvent.Move(Direction.Left) | |
| case Key.Right => GameEvent.Move(Direction.Right) | |
| case key if keyChar(key, 'r', 'R') => GameEvent.Restart | |
| case key if keyChar(key, 'q', 'Q') => GameEvent.Quit | |
| case Key.Escape => GameEvent.Quit | |
| private object TextLayout: | |
| def center(text: String, width: Int): String = | |
| if text.length >= width then text.take(width) | |
| else | |
| val padding = width - text.length | |
| val left = padding / 2 | |
| s"${" " * left}$text${" " * (padding - left)}" | |
| def interleave[A](separator: A, items: Seq[A]): Seq[A] = | |
| items.flatMap(item => Seq(separator, item)).drop(1) | |
| def surround[A](leading: A, items: Seq[A], trailing: A): Seq[A] = | |
| leading +: items :+ trailing | |
| private object BoardRenderer: | |
| import Config.* | |
| import Theme.{NamedColor, Tile} | |
| import TextLayout.{center, interleave, surround} | |
| private def wrapAnsi(color: Color, text: String): String = | |
| if color.code.isEmpty then text | |
| else s"\u001b[${color.code}m$text\u001b[0m" | |
| private def bgSpan(text: String, | |
| width: Int, | |
| background: Color = BoardBg | |
| ): Element = | |
| center(text, width).bg(background) | |
| private def repeatLines(count: Int, line: Element): Element = | |
| Layout(List.fill(count)(line)) | |
| private def styledLine(text: String, style: TileLineStyle): Element = | |
| val base = center(text, CellW).bg(style.background).color(style.foreground) | |
| if style.bold then base.style(Style.Bold) else base | |
| private def tileBlock(style: TileLineStyle): Element = | |
| val blankLine = styledLine("", style.copy(text = "")) | |
| val valueLine = styledLine(style.text, style) | |
| Layout(List.fill(CellH)(blankLine).updated(CellH / 2, valueLine)) | |
| private def tileLineStyle(value: Int, | |
| position: Cell, | |
| state: GameState | |
| ): TileLineStyle = | |
| val tile = Tile.forValue(value) | |
| val label = if value == EmptyValue then "" else value.toString | |
| val postAge = state.tick - SlideAnimFrames | |
| val animating = state.slideMotions.nonEmpty && state.tick < SlideAnimFrames | |
| if !animating && | |
| state.spawnCell.contains(position) && | |
| postAge >= 0 && | |
| postAge < SpawnAnimFrames | |
| then | |
| val growChar = | |
| SpinnerStyle.Grow.frames(postAge % SpinnerStyle.Grow.frames.length) | |
| val pulseBg = | |
| if postAge % AnimPulsePeriod < AnimPulsePeriod / 2 then tile.tile | |
| else MergeFlash | |
| TileLineStyle( | |
| background = pulseBg, | |
| foreground = tile.number, | |
| text = if value == EmptyValue then growChar else label, | |
| bold = true | |
| ) | |
| else if !animating && | |
| state.mergedSources | |
| .get(position) | |
| .exists(_ => postAge >= 0 && postAge < MergeFlashFrames) | |
| then | |
| val sourceValue = state.mergedSources(position) | |
| val sourceTile = Tile.forValue(sourceValue) | |
| val flashTile = | |
| if postAge % AnimPulsePeriod < AnimPulsePeriod / 2 then sourceTile | |
| else tile | |
| TileLineStyle(flashTile.tile, flashTile.number, label, bold = true) | |
| else | |
| TileLineStyle(tile.tile, tile.number, label, bold = value != EmptyValue) | |
| private def paddedRow(items: Seq[Element]): Element = | |
| tightRow(surround(sidePad, items, sidePad)*) | |
| private def sidePad: Element = | |
| repeatLines(CellH, bgSpan("", BoardPad)) | |
| private def horizontalGap: Element = | |
| repeatLines(CellH, bgSpan("", GapW)) | |
| private def verticalGap: Element = | |
| repeatLines(GapV, bgSpan("", BoardW)) | |
| private def boardFrame(content: Element): Element = new Element: | |
| def render: String = | |
| val lines = content.render.split('\n').toList | |
| val width = lines.map(realLength).maxOption.getOrElse(BoardW) | |
| val border = "─" * width | |
| val body = lines.map: line => | |
| val padding = width - realLength(line) | |
| s"│$line${" " * padding}│" | |
| wrapAnsi(BoardBorder, s"╭$border╮\n${body.mkString("\n")}\n╰$border╯") | |
| private def motionCell(motion: TileMotion, progress: Double): Cell = | |
| Cell( | |
| (motion.from.row + (motion.to.row - motion.from.row) * progress).round.toInt, | |
| (motion.from.col + (motion.to.col - motion.from.col) * progress).round.toInt | |
| ) | |
| private def slidingGrid(state: GameState): Grid = | |
| val progress = (state.tick + 1).toDouble / SlideAnimFrames | |
| state.slideMotions.foldLeft(GridEngine.empty) { (grid, motion) => | |
| GridEngine.withCell(grid, motionCell(motion, progress), motion.value) | |
| } | |
| private def displayGrid(state: GameState): Grid = | |
| if state.slideMotions.nonEmpty && state.tick < SlideAnimFrames then | |
| slidingGrid(state) | |
| else state.grid | |
| private def tileElement(value: Int, | |
| position: Cell, | |
| state: GameState | |
| ): Element = | |
| tileBlock(tileLineStyle(value, position, state)) | |
| def board(state: GameState): Element = | |
| val values = displayGrid(state) | |
| val rows = values.zipWithIndex.map { case (gridRow, rowIndex) => | |
| val tiles = gridRow.zipWithIndex.map { case (value, colIndex) => | |
| tileElement(value, Cell(rowIndex, colIndex), state) | |
| } | |
| paddedRow(interleave(horizontalGap, tiles)) | |
| } | |
| val layoutGrid = Layout( | |
| List.fill(BoardPad)(verticalGap) ++ | |
| interleave(verticalGap, rows) ++ | |
| List.fill(BoardPad)(verticalGap) | |
| ) | |
| boardFrame(layoutGrid) | |
| private def statPanel(label: String, value: Element): Element = | |
| statusCard( | |
| label.color(Color.BrightBlack).style(Style.Bold), | |
| value | |
| ).border(Border.Round).color(BoardBg) | |
| private def highlightedScore(value: Int, active: Boolean): Element = | |
| val color = if active then Color.BrightYellow else Color.BrightWhite | |
| value.toString.color(color).style(Style.Bold) | |
| def sidebar(state: GameState): Element = | |
| val postAge = state.tick - SlideAnimFrames | |
| val scoreActive = | |
| state.lastGained > 0 && postAge >= 0 && postAge < ScoreFlashFrames | |
| val gainFlash = | |
| if scoreActive then | |
| s"+${state.lastGained}".color(Color.BrightGreen).style(Style.Bold) | |
| else UiText("") | |
| layout( | |
| statPanel("SCORE", highlightedScore(state.score, scoreActive)), | |
| br, | |
| statPanel("BEST", highlightedScore(state.best, active = false)), | |
| br, | |
| gainFlash | |
| ) | |
| def statusLine(state: GameState): Element = | |
| if state.gameOver then | |
| row( | |
| spinner("Game over", state.tick / 2, SpinnerStyle.Moon) | |
| .color(Color.BrightRed), | |
| " Press R to restart.".color(Color.BrightRed).style(Style.Bold) | |
| ) | |
| else if state.won then | |
| "You reached 2048! Keep going or press R." | |
| .color(Color.BrightYellow) | |
| .style(Style.Bold) | |
| else "Slide the tiles to reach 2048!".color(Color.BrightBlack) | |
| def render(state: GameState): Element = | |
| layout( | |
| banner("2048".style(Style.Bold).color(Color.BrightYellow)) | |
| .border(Border.Round), | |
| br, | |
| columns(board(state), sidebar(state)), | |
| br, | |
| box("Status")(statusLine(state)) | |
| .border(Border.Round) | |
| .color(Color.BrightCyan), | |
| br, | |
| section("Controls")( | |
| ul( | |
| "Arrow Keys — slide tiles", | |
| "R — new game", | |
| "Q or Esc — quit" | |
| ) | |
| ) | |
| ) | |
| object Game2048 extends LayoutzApp[GameState, GameEvent]: | |
| import Config.* | |
| private def freshState(grid: Grid): GameState = | |
| GameState(grid = grid, score = 0, best = 0, won = false, gameOver = false) | |
| private def notifyTransitions(before: GameState, after: GameState): Unit = | |
| if !before.won && after.won then | |
| KyoFx.logEventAsync(s"[2048] You reached $WinTile! Score: ${after.score}") | |
| if !before.gameOver && after.gameOver then | |
| KyoFx.logEventAsync(s"[2048] Game over. Final score: ${after.score}") | |
| if after.lastGained >= 128 then | |
| KyoFx.logEventAsync(s"[2048] +${after.lastGained} (total ${after.score})") | |
| private def withNotifications(before: GameState, | |
| after: GameState | |
| ): GameState = | |
| notifyTransitions(before, after) | |
| after | |
| private def clearAnimation(state: GameState): GameState = | |
| state.copy( | |
| spawnCell = None, | |
| slideMotions = Nil, | |
| mergedSources = Map.empty, | |
| lastGained = 0 | |
| ) | |
| private def isAnimating(state: GameState): Boolean = | |
| state.slideMotions.nonEmpty && state.tick < AnimFrames | |
| private def enqueueMove(state: GameState, direction: Direction): GameState = | |
| state.copy(pendingMoves = state.pendingMoves :+ direction) | |
| private def drainMoves(state: GameState): GameState = | |
| state.pendingMoves match | |
| case direction :: rest => | |
| val next = applyMove(state.copy(pendingMoves = rest), direction) | |
| if next.slideMotions.nonEmpty then next | |
| else drainMoves(next) | |
| case Nil => state | |
| private def handleMove(state: GameState, direction: Direction): GameState = | |
| if isAnimating(state) then enqueueMove(state, direction) | |
| else applyMove(state, direction) | |
| private def hasWon(grid: Grid): Boolean = | |
| grid.exists(_.exists(_ >= WinTile)) | |
| private def applyMove(state: GameState, direction: Direction): GameState = | |
| val result = direction.slide(state.grid) | |
| if result.grid == state.grid then state | |
| else | |
| val (grid, spawn) = KyoFx.io(GridEngine.addRandomTile(result.grid)) | |
| val score = state.score + result.score | |
| withNotifications( | |
| state, | |
| state.copy( | |
| grid = grid, | |
| score = score, | |
| best = math.max(state.best, score), | |
| won = state.won || hasWon(grid), | |
| gameOver = !GridEngine.canMove(grid), | |
| tick = 0, | |
| spawnCell = spawn, | |
| slideMotions = result.motions, | |
| mergedSources = result.mergedSources, | |
| lastGained = result.score | |
| ) | |
| ) | |
| private def onTick(state: GameState): GameState = | |
| if state.slideMotions.isEmpty then | |
| if state.pendingMoves.nonEmpty then drainMoves(state) | |
| else state | |
| else | |
| val nextTick = state.tick + 1 | |
| if nextTick > AnimFrames then | |
| drainMoves(clearAnimation(state.copy(tick = 0))) | |
| else state.copy(tick = nextTick) | |
| def init: (GameState, Cmd[GameEvent]) = | |
| (freshState(KyoFx.io(GridEngine.freshGrid)), Cmd.setTitle("2048")) | |
| def update(event: GameEvent, state: GameState): (GameState, Cmd[GameEvent]) = | |
| event match | |
| case GameEvent.Quit => | |
| KyoFx.logEventAsync(s"[2048] Quit — score ${state.score}, best ${state.best}") | |
| (state, Cmd.exit) | |
| case GameEvent.Tick => (onTick(state), Cmd.none) | |
| case GameEvent.Restart => | |
| KyoFx.logEventAsync("[2048] New game") | |
| (freshState(KyoFx.io(GridEngine.freshGrid)), Cmd.none) | |
| case GameEvent.Move(direction) if state.gameOver => (state, Cmd.none) | |
| case GameEvent.Move(direction) => | |
| (handleMove(state, direction), Cmd.none) | |
| def subscriptions(state: GameState): Sub[GameEvent] = | |
| Sub.batch( | |
| Sub.time.everyMs(TickMs, GameEvent.Tick), | |
| Sub.onKeyPress(Input.eventFor.lift) | |
| ) | |
| def view(state: GameState): Element = BoardRenderer.render(state) | |
| @main def go = | |
| import AllowUnsafe.embrace.danger | |
| Sync.Unsafe.evalOrThrow: | |
| for | |
| _ <- Console.printLineErr( | |
| "[2048] Kyo effects on — RNG, async stderr events. Arrow keys to play." | |
| ) | |
| _ <- Sync.defer: | |
| run( | |
| quitKey = Key.Unknown(-1), | |
| showQuitMessage = false | |
| ) | |
| yield () |
rbobillot
commented
May 21, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment