├── .gitignore ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── html │ ├── index.css │ └── index.html └── scala │ ├── game2048 │ └── Board.scala │ └── webapp │ └── Game2048Webapp.scala └── test └── scala └── game2048 ├── BoardTest.scala └── RowTest.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | target/ 3 | project/project/ 4 | project/target/ 5 | node_modules/ 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scalajs-react-2048 2 | This is [2048](http://gabrielecirulli.github.io/2048/) game clone made using [scalajs-react](https://github.com/japgolly/scalajs-react) in just 350 lines of Scala and 250 lines of CSS. [Scala.js](http://www.scala-js.org/) is ordinary Scala code, but it compiles to JavaScript which is great. Yay! 3 | 4 | Demo: http://fijolekprojects.github.io/scalajs-react-2048/ 5 | 6 | ### How it looks 7 | 8 | ![Game sample](http://i.imgur.com/9hCKLeA.png) 9 | 10 | ### How it works 11 | ##### Backend 12 | Game logic is implemented in `game2048.Board` class. It's based on idea that making a move in game is kind of the same despite direction, so 'the real logic' has to be implemented only once, for one direction and rest is pretty trivial. I decided to implement `shiftLeft` method, which 'shifts' row to the left. In that situation `shiftRight` implementation is as simple as `reverse.shiftLeft.reverse`. The same holds for moving up and down, but instead of rows one needs to operate on whole board (because there's no 'up' and 'down' in row dimension), so for example `moveUp` (which moves whole board up) translates to `transpose.moveLeft.transpose`, where transpose is the same as [transposing a matrix](https://en.wikipedia.org/wiki/Transpose). 13 | 14 | ##### Frontend 15 | Handling user input, managing game state and rendering takes place in `webapp.Game2048Webapp` and all of it is controlled by nice Scala interface for [React](https://facebook.github.io/react/) [scalajs-react](https://github.com/japgolly/scalajs-react). This class may look complicated at first, but it doesn't really do that much. The most important thing is setting CSS classes in `createTile` method, because all animations and tiles positions are represented using CSS. 16 | 17 | ### Building 18 | Run 19 | ``` 20 | sbt ~fastOptJS 21 | ``` 22 | Game should be at [http://localhost:12345/src/main/html/index.html](http://localhost:12345/src/main/html/index.html) with 'reload on change' feature on. 23 | 24 | ### Credits 25 | The game was originally created by [gabrielecirulli](http://gabrielecirulli.github.io/2048/). 26 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.lihaoyi.workbench.Plugin._ 2 | 3 | enablePlugins(ScalaJSPlugin) 4 | 5 | workbenchSettings 6 | 7 | name := "scala-2048" 8 | 9 | version := "0.1" 10 | 11 | scalaVersion := "2.11.7" 12 | 13 | libraryDependencies ++= { 14 | Seq( 15 | "org.scalaz" %% "scalaz-core" % "7.1.3", 16 | "com.chuusai" %% "shapeless" % "2.2.4", 17 | "com.nicta" %% "rng" % "1.3.0", 18 | "org.scalacheck" % "scalacheck_2.11" % "1.12.4" % "test", 19 | "org.scalatest" % "scalatest_2.11" % "2.2.4" % "test" 20 | ) 21 | } 22 | 23 | resolvers += "snapshots" at "https://oss.sonatype.org/content/repositories/snapshots" 24 | 25 | resolvers += "releases" at "https://oss.sonatype.org/content/groups/scala-tools" 26 | 27 | scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature", "-Xfatal-warnings") 28 | 29 | scalaJSStage in Global := FastOptStage 30 | 31 | libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "0.8.0" 32 | 33 | libraryDependencies += "com.github.japgolly.fork.scalaz" %%% "scalaz-core" % "7.1.3" 34 | 35 | libraryDependencies += "com.github.japgolly.fork.nicta" %%% "rng" % "1.3.0" 36 | 37 | libraryDependencies += "com.github.japgolly.scalajs-react" % "core_sjs0.6_2.11" % "0.10.1" 38 | 39 | // React JS itself (Note the filenames, adjust as needed, eg. to remove addons.) 40 | jsDependencies += "org.webjars.npm" % "react" % "0.14.2" / "react-with-addons.js" commonJSName "React" minified "react-with-addons.min.js" 41 | 42 | jsDependencies += "org.webjars.npm" % "react-dom" % "0.14.2" / "react-dom.js" commonJSName "ReactDOM" minified "react-dom.min.js" dependsOn "react-with-addons.js" 43 | 44 | bootSnippet := "webapp.Game2048Webapp().main();" 45 | 46 | refreshBrowsers <<= refreshBrowsers.triggeredBy(fastOptJS in Compile) 47 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.7 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.5") 2 | 3 | resolvers += "spray repo" at "http://repo.spray.io" 4 | 5 | resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" 6 | 7 | addSbtPlugin("com.lihaoyi" % "workbench" % "0.2.3") -------------------------------------------------------------------------------- /src/main/html/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #776e65; 3 | font-family: "Clear Sans", "Helvetica Neue", Arial, sans-serif; 4 | background: #faf8ef; 5 | } 6 | h1 { 7 | font-size: 80px; 8 | font-weight: bold; 9 | margin: 0; 10 | display: block; 11 | float: left; 12 | } 13 | .container { 14 | margin: 0 auto; 15 | width: 500px; 16 | height: 500px; 17 | } 18 | 19 | .score-container { 20 | position: relative; 21 | display: inline-block; 22 | background: #bbada0; 23 | padding: 15px 25px; 24 | font-size: 25px; 25 | height: 25px; 26 | line-height: 47px; 27 | font-weight: bold; 28 | border-radius: 3px; 29 | color: white; 30 | margin-top: 8px; 31 | margin-left: 200px; 32 | text-align: center; 33 | } 34 | .score-container:after { 35 | position: absolute; 36 | width: 100%; 37 | top: 10px; 38 | left: 0; 39 | text-transform: uppercase; 40 | font-size: 13px; 41 | line-height: 13px; 42 | text-align: center; 43 | color: #eee4da; 44 | } 45 | 46 | @-webkit-keyframes move-up { 0% { top: 25px; opacity: 1; } 100% { top: -50px; opacity: 0; } } 47 | @keyframes moz-move-up { 0% { top: 25px; opacity: 1; } 100% { top: -50px; opacity: 0; } } 48 | @keyframes move-up { 0% { top: 25px; opacity: 1; } 100% { top: -50px; opacity: 0; } } 49 | 50 | .score-container .score-addition { 51 | position: absolute; 52 | right: 30px; 53 | font-size: 25px; 54 | line-height: 25px; 55 | font-weight: bold; 56 | color: rgba(119, 110, 101, 0.9); 57 | z-index: 100; 58 | -webkit-animation: move-up 600ms ease-in; -moz-animation: move-up 600ms ease-in; animation: move-up 600ms ease-in; 59 | -webkit-animation-fill-mode: both; -moz-animation-fill-mode: both; animation-fill-mode: both; 60 | } 61 | 62 | .score-container:after { 63 | content: "Score"; } 64 | 65 | .board { 66 | width: 500px; 67 | height: 500px; 68 | position: relative; 69 | background: #bbada0; 70 | padding: 15px; 71 | border-radius: 6px; 72 | margin-top: 70px; 73 | -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; 74 | } 75 | 76 | @-webkit-keyframes fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } 77 | @-moz-keyframes fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } 78 | @keyframes fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } 79 | 80 | .game-message { 81 | display: none; 82 | position: absolute; 83 | top: 0; 84 | right: 0; 85 | bottom: 0; 86 | left: 0; 87 | background: rgba(238, 228, 218, 0.73); 88 | z-index: 100; 89 | padding-top: 40px; 90 | text-align: center; 91 | -webkit-animation: fade-in 800ms ease 1200ms; -moz-animation: fade-in 800ms ease 1200ms; animation: fade-in 800ms ease 1200ms; 92 | -webkit-animation-fill-mode: both; -moz-animation-fill-mode: both; animation-fill-mode: both; 93 | } 94 | 95 | .restart-button { 96 | display: inline-block; 97 | background: #8f7a66; 98 | border-radius: 3px; 99 | padding: 0 20px; 100 | text-decoration: none; 101 | color: #f9f6f2; 102 | height: 40px; 103 | line-height: 42px; 104 | cursor: pointer; 105 | text-align: center; 106 | font-weight: bold; 107 | float: right; 108 | margin-top: 20px; 109 | } 110 | 111 | .game-message.game-over { 112 | display: block; 113 | } 114 | 115 | .game-message p { 116 | font-size: 60px; 117 | font-weight: bold; 118 | height: 60px; 119 | line-height: 60px; 120 | margin-top: 20%; 121 | } 122 | 123 | .game-message.game-won { 124 | background: rgba(237, 194, 46, 0.5); 125 | color: #f9f6f2; 126 | display: block; 127 | } 128 | 129 | .grid-container { 130 | position: absolute; 131 | z-index: 1; 132 | } 133 | 134 | .tile-container { 135 | position: absolute; 136 | z-index: 2; 137 | } 138 | 139 | .tile { 140 | position: absolute; 141 | -webkit-transition: 100ms ease-in-out; -moz-transition: 100ms ease-in-out; transition: 100ms ease-in-out; 142 | -webkit-transition-property: -webkit-transform; -moz-transition-property: -moz-transform; transition-property: transform; 143 | } 144 | 145 | .tile, .tile .tile-inner { 146 | width: 107px; 147 | height: 107px; 148 | line-height: 107px; 149 | } 150 | 151 | .tile .tile-inner { 152 | border-radius: 3px; 153 | background: #eee4da; 154 | text-align: center; 155 | font-weight: bold; 156 | z-index: 10; 157 | font-size: 55px; 158 | } 159 | 160 | .grid-cell { 161 | width: 106.25px; 162 | height: 106.25px; 163 | margin-right: 15px; 164 | margin-bottom: 15px; 165 | float: left; 166 | border-radius: 3px; 167 | background: rgba(238, 228, 218, 0.35); 168 | } 169 | 170 | .moving { 171 | -webkit-transform: translateX(350px); -moz-transform: translateX(350px); transform: translateX(350px); 172 | -webkit-transition: 170ms ease-in-out; -moz-transition: 170ms ease-in-out; transition: 170ms ease-in-out; 173 | -webkit-transition-property: -webkit-transform; -moz-transition-property: -moz-transform; transition-property: transform; 174 | } 175 | 176 | @-webkit-keyframes appear { 0% { opacity: 0; -webkit-transform: scale(0); } 100% { opacity: 1; -webkit-transform: scale(1); } } 177 | @-moz-keyframes appear { 0% { opacity: 0; -moz-transform: scale(0); } 100% { opacity: 1; -moz-transform: scale(1); } } 178 | @keyframes appear { 0% { opacity: 0; transform: scale(0); } 100% { opacity: 1; transform: scale(1); } } 179 | .new .tile-inner { 180 | -webkit-animation: appear 200ms ease 100ms; -moz-animation: appear 200ms ease 100ms; animation: appear 200ms ease 100ms; 181 | -webkit-animation-fill-mode: forwards; -moz-animation-fill-mode: forwards; animation-fill-mode: forwards; 182 | -webkit-animation-delay: 0.15s; -moz-animation-delay: 0.15s; animation-delay: 0.15s; 183 | -webkit-transform: scale(0); -moz-transform: scale(0); transform: scale(0); 184 | } 185 | 186 | @-webkit-keyframes pop { 0% { -webkit-transform: scale(0); } 50% { -webkit-transform: scale(1.2); } 100% { -webkit-transform: scale(1); } } 187 | @-moz-keyframes pop { 0% { -moz-transform: scale(0); } 50% { -moz-transform: scale(1.2); } 100% { -moz-transform: scale(1); } } 188 | @keyframes pop { 0% { transform: scale(0); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } 189 | 190 | .merged .tile-inner { 191 | z-index: 20; 192 | -webkit-animation: pop 200ms ease 100ms; -moz-animation: pop 200ms ease 100ms; animation: pop 200ms ease 100ms; 193 | -webkit-animation-fill-mode: backwards; -moz-animation-fill-mode: backwards; animation-fill-mode: backwards; 194 | } 195 | 196 | .tile-2 .tile-inner { background: #eee4da; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0), inset 0 0 0 1px rgba(255, 255, 255, 0); } 197 | .tile-4 .tile-inner { background: #ede0c8; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0), inset 0 0 0 1px rgba(255, 255, 255, 0); } 198 | .tile-8 .tile-inner { color: #f9f6f2; background: #f2b179; } 199 | .tile-16 .tile-inner { color: #f9f6f2; background: #f59563; } 200 | .tile-32 .tile-inner { color: #f9f6f2; background: #f67c5f; } 201 | .tile-64 .tile-inner { color: #f9f6f2; background: #f65e3b; } 202 | .tile-128 .tile-inner { color: #f9f6f2; background: #edcf72; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.2381), inset 0 0 0 1px rgba(255, 255, 255, 0.14286); font-size: 45px; } 203 | .tile-256 .tile-inner { color: #f9f6f2; background: #edcc61; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.31746), inset 0 0 0 1px rgba(255, 255, 255, 0.19048); font-size: 45px; } 204 | .tile-512 .tile-inner { color: #f9f6f2; background: #edc850; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.39683), inset 0 0 0 1px rgba(255, 255, 255, 0.2381); font-size: 45px; } 205 | .tile-1024 .tile-inner { color: #f9f6f2; background: #edc53f; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.47619), inset 0 0 0 1px rgba(255, 255, 255, 0.28571); font-size: 35px; } 206 | .tile-2048 .tile-inner { color: #f9f6f2; background: #edc22e; box-shadow: 0 0 30px 10px rgba(243, 215, 116, 0.55556), inset 0 0 0 1px rgba(255, 255, 255, 0.33333); font-size: 35px; } 207 | 208 | .tile.tile-position-col-1-row-1{ -webkit-transform: translate(0px, 0px); transform: translate(0px, 0px); } 209 | .tile.tile-position-col-1-row-2{ -webkit-transform: translate(0px, 121px); transform: translate(0px, 121px); } 210 | .tile.tile-position-col-1-row-3{ -webkit-transform: translate(0px, 242px); transform: translate(0px, 242px); } 211 | .tile.tile-position-col-1-row-4{ -webkit-transform: translate(0px, 363px); transform: translate(0px, 363px); } 212 | .tile.tile-position-col-2-row-1{ -webkit-transform: translate(121px, 0px); transform: translate(121px, 0px); } 213 | .tile.tile-position-col-2-row-2{ -webkit-transform: translate(121px, 121px); transform: translate(121px, 121px); } 214 | .tile.tile-position-col-2-row-3{ -webkit-transform: translate(121px, 242px); transform: translate(121px, 242px); } 215 | .tile.tile-position-col-2-row-4{ -webkit-transform: translate(121px, 363px); transform: translate(121px, 363px); } 216 | .tile.tile-position-col-3-row-1{ -webkit-transform: translate(242px, 0px); transform: translate(242px, 0px); } 217 | .tile.tile-position-col-3-row-2{ -webkit-transform: translate(242px, 121px); transform: translate(242px, 121px); } 218 | .tile.tile-position-col-3-row-3{ -webkit-transform: translate(242px, 242px); transform: translate(242px, 242px); } 219 | .tile.tile-position-col-3-row-4{ -webkit-transform: translate(242px, 363px); transform: translate(242px, 363px); } 220 | .tile.tile-position-col-4-row-1{ -webkit-transform: translate(363px, 0px); transform: translate(363px, 0px); } 221 | .tile.tile-position-col-4-row-2{ -webkit-transform: translate(363px, 121px); transform: translate(363px, 121px); } 222 | .tile.tile-position-col-4-row-3{ -webkit-transform: translate(363px, 242px); transform: translate(363px, 242px); } 223 | .tile.tile-position-col-4-row-4{ -webkit-transform: translate(363px, 363px); transform: translate(363px, 363px); } 224 | 225 | .game-explanation { 226 | margin-top: 50px; } 227 | p { 228 | margin-top: 0; 229 | margin-bottom: 10px; 230 | line-height: 1.65; } 231 | a { 232 | color: #776e65; 233 | font-weight: bold; 234 | text-decoration: underline; 235 | cursor: pointer; } 236 | 237 | hr { 238 | border: none; 239 | border-bottom: 1px solid #d8d4d0; 240 | margin-top: 20px; 241 | margin-bottom: 30px; } -------------------------------------------------------------------------------- /src/main/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | scalajs-react-2048 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 |

17 | HOW TO PLAY: 18 | Use your arrow or 19 | WSAD keys to move the tiles. 20 | When two tiles with the same number touch, they merge into one! 21 |

22 |
23 |

24 | This game is a clone of famous 2048 25 | game and it was made using scala.js 26 | and scalajs-react. 27 |

28 |
29 |

Sources can be found on 30 | github. 31 |

32 |
33 |
34 |
35 | 36 |
37 | 38 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/main/scala/game2048/Board.scala: -------------------------------------------------------------------------------- 1 | package game2048 2 | 3 | import com.nicta.rng.Rng 4 | import game2048.Board.GameStatesAfterMove.{UserWon, GameOver} 5 | import game2048.Board.{Index, AdditionalScore} 6 | import game2048.Tiles.{EmptyTile, NonEmptyTile} 7 | 8 | import scalaz.NonEmptyList 9 | 10 | object Board { 11 | type Index = Int 12 | type AdditionalScore = Int 13 | 14 | val rows = 4 15 | val cols = 4 16 | private val zero = Board((1 to rows).toList.map(_ => Row(List.fill(cols)(EmptyTile)))) 17 | val lastTileValue = 2048 18 | 19 | sealed trait Direction 20 | object Directions { 21 | case object Left extends Direction 22 | case object Right extends Direction 23 | case object Up extends Direction 24 | case object Down extends Direction 25 | case object None extends Direction 26 | } 27 | 28 | sealed trait GameStateAfterMove 29 | object GameStatesAfterMove{ 30 | case class BoardChanged(additionalScore: AdditionalScore, board: Rng[Board]) extends GameStateAfterMove 31 | case object NothingChanged extends GameStateAfterMove 32 | case class GameOver(additionalScore: AdditionalScore, lastBoardState: Board) extends GameStateAfterMove 33 | case class UserWon(additionalScore: AdditionalScore, lastBoardState: Board) extends GameStateAfterMove 34 | } 35 | 36 | def createBoardStartPosition: Rng[Board] = Board.zero.nextBoard.flatMap(_.nextBoard) 37 | } 38 | 39 | case class Board(rows: List[Row]) { 40 | import Board._ 41 | 42 | override def toString() = { "\n" + rows.map(_.tiles).mkString("\n") } 43 | 44 | def nextBoard: Rng[Board] = { 45 | val newTileValueRng = Rng.chooseint(0, 10).map { n => if (n > 2) 2 else 4 } 46 | for { 47 | rc <- emptyTileIndices 48 | (r, c) = rc 49 | newTile <- newTileValueRng 50 | } yield updateAt(r, c)(NonEmptyTile(newTile)(isNew = true, id = Tiles.incrAndGetCounter)) 51 | } 52 | 53 | def moveAndCreateNewTile(d: Direction): GameStateAfterMove = { 54 | val (additionalScore, boardAfterMove) = move(d) 55 | val gameStateAfterMove = if (boardAfterMove == this) { 56 | GameStatesAfterMove.NothingChanged 57 | } else { 58 | GameStatesAfterMove.BoardChanged(additionalScore, boardAfterMove.nextBoard) 59 | } 60 | if (boardAfterMove.isCompleted) UserWon(additionalScore, boardAfterMove) 61 | else if (nextMoveIsPossible) gameStateAfterMove /*fixme this is kind of a bug, there should be smth like if (boardAfterMove.nextBoard.nextMoveIsPossible)*/ 62 | else GameOver(additionalScore, boardAfterMove) 63 | } 64 | 65 | private def isCompleted: Boolean = this.rows.flatMap(_.tiles).exists(_.value == lastTileValue) 66 | 67 | private def nextMoveIsPossible: Boolean = { 68 | List(Directions.Left, Directions.Right, Directions.Up, Directions.Down).exists { dir => 69 | val (_, boardAfterMove) = move(dir) 70 | boardAfterMove != this 71 | } 72 | } 73 | 74 | def move(d: Direction): (AdditionalScore, Board) = d match { 75 | case Directions.Left => moveLeft 76 | case Directions.Right => moveRight 77 | case Directions.Up => moveUp 78 | case Directions.Down => moveDown 79 | case Directions.None => (0, this) 80 | } 81 | 82 | private def emptyTileIndices: Rng[(Index, Index)] = { 83 | val indices = allEmptyIndices 84 | Rng.oneofL(NonEmptyList.nel(indices.head, indices.tail)) 85 | } 86 | 87 | private def allEmptyIndices: List[(Index, Index)] = for { 88 | (r, rowIndex) <- rows.zipWithIndex 89 | (f, columnIndex) <- r.tiles.zipWithIndex 90 | if f == EmptyTile 91 | } yield (rowIndex, columnIndex) 92 | 93 | private def moveLeft: (AdditionalScore, Board) = rowsToBoard(rows.map(_.shiftLeft)) 94 | private def moveRight: (AdditionalScore, Board) = rowsToBoard(rows.map(_.shiftRight)) 95 | private def moveUp: (AdditionalScore, Board) = { 96 | val (newScore, leftTransposed) = transpose.moveLeft 97 | (newScore, leftTransposed.transpose) 98 | } 99 | 100 | private def moveDown: (AdditionalScore, Board) = { 101 | val (newScore, rightTransposed) = transpose.moveRight 102 | (newScore, rightTransposed.transpose) 103 | } 104 | 105 | private def rowsToBoard(shiftedRows: List[(AdditionalScore, Row)]): (AdditionalScore, Board) = { 106 | val (score, movedRows) = shiftedRows.unzip 107 | (score.sum, Board(movedRows)) 108 | } 109 | 110 | private def transpose: Board = Board(rows.map(_.tiles).transpose.map(Row.apply)) 111 | 112 | private def updateAt(row: Index, column: Index)(f: Tile): Board = { 113 | this.copy(rows = rows.updated(row, Row(rows(row).tiles.updated(column, f)))) 114 | } 115 | } 116 | 117 | sealed trait Tile { 118 | def value: Int 119 | def isNew: Boolean 120 | def asOld: Tile = this match { 121 | case tile: NonEmptyTile => NonEmptyTile(tile.value)(isNew = false, id=tile.id) 122 | case EmptyTile => EmptyTile 123 | } 124 | def isMerged: Boolean 125 | def id: Long 126 | } 127 | 128 | object Tiles { 129 | var counter: Long = 0 /*shame on me*/ 130 | def incrAndGetCounter = { 131 | counter += 1 132 | counter 133 | } 134 | 135 | case class NonEmptyTile(override val value: Int) 136 | (override val isNew: Boolean = false, override val isMerged: Boolean = false, override val id: Long) extends Tile 137 | case object EmptyTile extends Tile { 138 | override def value: Int = 0 139 | override def isNew: Boolean = false 140 | override def isMerged: Boolean = false 141 | override def id: Long = -1 142 | } 143 | } 144 | 145 | case class Row(tiles: List[Tile]) { 146 | 147 | private val tilesCount = tiles.size 148 | 149 | def shiftLeft: (AdditionalScore, Row) = { 150 | val (firstScore, firstRow) = this.slideLeft.mergeAt(0) 151 | val (scndScore, scndRow) = firstRow.mergeAt(1) 152 | val (thrdScore, thrdRow) = scndRow.mergeAt(2) 153 | (firstScore + scndScore + thrdScore, thrdRow.slideLeft) 154 | } 155 | 156 | def shiftRight: (AdditionalScore, Row) = { 157 | val (newScore, row) = this.reverse.shiftLeft 158 | (newScore, row.reverse) 159 | } 160 | 161 | private def slideLeft: Row = { 162 | val nonEmptyTiles = tiles.filterNot(_ == EmptyTile) 163 | Row(complementWithEmptyTiles(nonEmptyTiles)) 164 | } 165 | 166 | private def complementWithEmptyTiles(nonEmptyTiles: List[Tile]): List[Tile] = { 167 | nonEmptyTiles ++ List.fill(tilesCount - nonEmptyTiles.size)(EmptyTile) 168 | } 169 | 170 | private def reverse = this.copy(tiles = this.tiles.reverse) 171 | 172 | private def mergeAt(i: Index): (AdditionalScore, Row) = { 173 | val (newScore, neighbours) = merge(this.tiles(i), this.tiles(i + 1)) 174 | (newScore, Row(tiles.take(i) ++ neighbours.toList ++ tiles.drop(i + 2))) 175 | } 176 | 177 | private def merge(tile: Tile, neighbour: Tile): (AdditionalScore, (Tile, Tile)) = { 178 | val twiceTileValue = 2 * tile.value 179 | (tile, neighbour) match { 180 | case (a: NonEmptyTile, b: NonEmptyTile) if a == b => (twiceTileValue, (NonEmptyTile(twiceTileValue)(isMerged = true, id = a.id), EmptyTile)) 181 | case _ => (0, (tile.asOld, neighbour.asOld)) 182 | } 183 | } 184 | 185 | implicit class RichTuple[A](t: (A, A)) { 186 | def toList = List(t._1, t._2) 187 | } 188 | } -------------------------------------------------------------------------------- /src/main/scala/webapp/Game2048Webapp.scala: -------------------------------------------------------------------------------- 1 | package webapp 2 | 3 | import java.util.Date 4 | 5 | import game2048.Board.GameStatesAfterMove._ 6 | import game2048.Board.{AdditionalScore, Direction, Directions, Index} 7 | import game2048.Tiles.NonEmptyTile 8 | import game2048.{Board, Tile, Tiles} 9 | import japgolly.scalajs.react._ 10 | import japgolly.scalajs.react.vdom.prefix_<^._ 11 | import org.scalajs.dom 12 | import org.scalajs.dom.document 13 | import org.scalajs.dom.ext.{KeyCode, _} 14 | import org.scalajs.dom.raw.KeyboardEvent 15 | 16 | import scala.scalajs.js.JSApp 17 | import scala.scalajs.js.annotation.JSExport 18 | 19 | object Game2048Webapp extends JSApp { 20 | object CssClasses { 21 | val mergedClass = "merged" 22 | val newClass = "new" 23 | } 24 | 25 | case class GameBoardState(board: Board, additionalScore: AdditionalScore, newGame: Boolean = false, isGameOver: Boolean = false, userWon: Boolean = false) 26 | 27 | case class BoardBackend($: BackendScope[Unit, GameBoardState]) { 28 | def registerMoveBoardEventHandler() = Callback { 29 | dom.window.onkeydown = (e: KeyboardEvent) => onKeyDownHandler(e).runNow() 30 | } 31 | 32 | private def onKeyDownHandler(e: KeyboardEvent): Callback = { 33 | readDirection(e).map { dir => 34 | $.modState { boardState => 35 | if (boardState.isGameOver || boardState.userWon) boardState.copy(additionalScore = 0) 36 | else moveBoard(dir, boardState.board) 37 | } 38 | }.getOrElse(Callback.empty) 39 | } 40 | 41 | private def readDirection(e: KeyboardEvent): Option[Board.Direction] = e.keyCode match { 42 | case (KeyCode.A | KeyCode.Left) => Option(Directions.Left) 43 | case (KeyCode.D | KeyCode.Right) => Option(Directions.Right) 44 | case (KeyCode.W | KeyCode.Up) => Option(Directions.Up) 45 | case (KeyCode.S | KeyCode.Down) => Option(Directions.Down) 46 | case _ => None 47 | } 48 | 49 | private def moveBoard(dir: Direction, board: Board): GameBoardState = board.moveAndCreateNewTile(dir) match { 50 | case BoardChanged(additionalScore, changedBoard) => GameBoardState(changedBoard.run.unsafePerformIO(), additionalScore) 51 | case NothingChanged => GameBoardState(board, 0) 52 | case GameOver(additionalScore, lastBoardState) => GameBoardState(lastBoardState, additionalScore, isGameOver = true) 53 | case UserWon(additionalScore, lastBoardState) => GameBoardState(lastBoardState, additionalScore, userWon = true) 54 | } 55 | 56 | val gridContainer = (1 to Board.rows).map { _ => <.span((1 to Board.cols).map { _ => <.div(^.className := "grid-cell")}) } 57 | 58 | def render(boardState: GameBoardState) = { 59 | val boardTemplate = createBoardTemplate(boardState.board) 60 | val boardKey = if (boardState.newGame) Some(^.key := new Date().getTime) else None 61 | val gameOverMessage = if (boardState.isGameOver) List(<.div(^.className := "game-message game-over", <.p("Game over!"))) else Nil 62 | val gameWonMessage = if (boardState.userWon) List(<.div(^.className := "game-message game-won", <.p("You win!"))) else Nil 63 | <.div( 64 | <.h1(2048), 65 | boardKey, 66 | scoreBoard(boardState.additionalScore), 67 | <.div(<.a(^.className := "restart-button", "New Game"), ^.onClick --> restartBoard), 68 | <.div(^.className := "board", 69 | gameOverMessage, 70 | gameWonMessage, 71 | <.div(^.className := "grid-container", gridContainer), 72 | <.div(^.className := "tile-container", boardTemplate) 73 | ) 74 | ) 75 | } 76 | 77 | private def createBoardTemplate(board: Board): List[ReactTagOf[dom.Element]] = { 78 | for { 79 | (row, rowIndex) <- board.rows.zip(Stream.from(1)) 80 | (tile, colIndex) <- row.tiles.zip(Stream.from(1)) 81 | } yield createTile(tile, rowIndex, colIndex) 82 | }.flatten 83 | 84 | private def createTile(tile: Tile, rowIndex: Index, colIndex: Index): Option[ReactTagOf[dom.Element]] = { 85 | val baseTileParams = List(^.className := s"tile tile-${tile.value} tile-position-col-$colIndex-row-$rowIndex", ^.key := s"${tile.id}") 86 | val tileInner = <.div(^.className := "tile-inner", tile.value) 87 | tile match { 88 | case f: NonEmptyTile if f.isNew => Option(<.div(baseTileParams, ^.className := CssClasses.newClass, tileInner)) 89 | case f: NonEmptyTile if f.isMerged => Option(<.div(baseTileParams, ^.className := CssClasses.mergedClass, tileInner)) 90 | case f: NonEmptyTile => Option(<.div(baseTileParams, tileInner)) 91 | case Tiles.EmptyTile => None 92 | } 93 | } 94 | 95 | private def restartBoard() = $.setState($.getInitialState(()).copy(newGame = true)) 96 | 97 | def removeMergeAndNewClasses() = Callback { 98 | org.scalajs.dom.setTimeout(() => { 99 | val node = ReactDOM.findDOMNode($) 100 | val nodeList = node.getElementsByClassName(CssClasses.mergedClass) ++ node.getElementsByClassName(CssClasses.newClass) 101 | val elements = nodeList.map(_.cast[dom.Element]) 102 | elements.foreach { e => 103 | e.classList.remove(CssClasses.mergedClass) 104 | e.classList.remove(CssClasses.newClass) 105 | } 106 | }, 250) 107 | } 108 | } 109 | 110 | val GameBoard = ReactComponentB[Unit]("GameBoard") 111 | .initialState(GameBoardState(Board.createBoardStartPosition.run.unsafePerformIO(), 0)) 112 | .renderBackend[BoardBackend] 113 | .componentDidMount(_.backend.registerMoveBoardEventHandler()) 114 | .componentDidUpdate(_.$.backend.removeMergeAndNewClasses()) 115 | .buildU 116 | 117 | type Score = Int 118 | case class ScoreBackend($: BackendScope[Board.AdditionalScore, Score]) { 119 | def render(additionalScore: Board.AdditionalScore) = { 120 | val currentScore = $.state.runNow() 121 | val scoreContainer = <.div(^.className := "score-container", ^.key := new Date().getTime, s"$currentScore") 122 | if (additionalScore > 0) scoreContainer(<.div(^.className := "score-addition", s"+$additionalScore")) 123 | else scoreContainer(<.div(^.className := "score-addition")) 124 | } 125 | 126 | def incrementScore(additionalScore: Board.AdditionalScore) = $.modState(currentScore => currentScore + additionalScore) 127 | } 128 | 129 | val scoreBoard = ReactComponentB[Board.AdditionalScore]("Score") 130 | .initialState(0) 131 | .renderBackend[ScoreBackend] 132 | .componentWillReceiveProps { self => self.$.backend.incrementScore(self.nextProps) } 133 | .build 134 | 135 | @JSExport 136 | override def main(): Unit = { 137 | ReactDOM.render(GameBoard(), document.getElementById("game-board")) 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /src/test/scala/game2048/BoardTest.scala: -------------------------------------------------------------------------------- 1 | package game2048 2 | 3 | import com.nicta.rng.Rng 4 | import game2048.Board.{Directions, GameStatesAfterMove} 5 | import org.scalatest.prop.TableDrivenPropertyChecks 6 | import org.scalatest.{FlatSpec, Matchers} 7 | 8 | 9 | class BoardTest extends FlatSpec with Matchers with TableDrivenPropertyChecks{ 10 | import scala.language.implicitConversions 11 | implicit def intToField(i: Int): Tile = { 12 | if (i == 0) Tiles.EmptyTile 13 | else Tiles.NonEmptyTile(i)(id = 0) 14 | } 15 | 16 | it should "move board in different directions" in { 17 | val rowsBeforeMove = List( 18 | Row(List(0, 0, 0, 0)), 19 | Row(List(0, 0, 2, 0)), 20 | Row(List(0, 0, 2, 2)), 21 | Row(List(2, 2, 2, 2)) 22 | ) 23 | val rowsAfterLeftMove = List( 24 | Row(List(0, 0, 0, 0)), 25 | Row(List(2, 0, 0, 0)), 26 | Row(List(4, 0, 0, 0)), 27 | Row(List(4, 4, 0, 0)) 28 | ) 29 | val rowsAfterRightMove = List( 30 | Row(List(0, 0, 0, 0)), 31 | Row(List(0, 0, 0, 2)), 32 | Row(List(0, 0, 0, 4)), 33 | Row(List(0, 0, 4, 4)) 34 | ) 35 | val rowsAfterUpMove = List( 36 | Row(List(2, 2, 4, 4)), 37 | Row(List(0, 0, 2, 0)), 38 | Row(List(0, 0, 0, 0)), 39 | Row(List(0, 0, 0, 0)) 40 | ) 41 | val rowsAfterMoveDown = List( 42 | Row(List(0, 0, 0, 0)), 43 | Row(List(0, 0, 0, 0)), 44 | Row(List(0, 0, 2, 0)), 45 | Row(List(2, 2, 4, 4)) 46 | ) 47 | 48 | Board(rowsBeforeMove).move(Directions.Left)._2 shouldBe Board(rowsAfterLeftMove) 49 | Board(rowsBeforeMove).move(Directions.Right)._2 shouldBe Board(rowsAfterRightMove) 50 | Board(rowsBeforeMove).move(Directions.Up)._2 shouldBe Board(rowsAfterUpMove) 51 | Board(rowsBeforeMove).move(Directions.Down)._2 shouldBe Board(rowsAfterMoveDown) 52 | 53 | val anotherRowsBeforeMove = List( 54 | Row(List(0, 0, 0, 0)), 55 | Row(List(0, 0, 0, 0)), 56 | Row(List(0, 0, 2, 0)), 57 | Row(List(0, 0, 2, 0)) 58 | ) 59 | 60 | val anotherRowsAfterUpMove = List( 61 | Row(List(0, 0, 4, 0)), 62 | Row(List(0, 0, 0, 0)), 63 | Row(List(0, 0, 0, 0)), 64 | Row(List(0, 0, 0, 0)) 65 | ) 66 | 67 | Board(anotherRowsBeforeMove).move(Directions.Up)._2 shouldBe Board(anotherRowsAfterUpMove) 68 | } 69 | 70 | it should "determine if game is over" in { 71 | val rows = List( 72 | Row(List(4, 2, 8, 4)), 73 | Row(List(2, 4, 2, 8)), 74 | Row(List(4, 2, 8, 4)), 75 | Row(List(8, 4, 2, 8)) 76 | ) 77 | 78 | Board(rows).moveAndCreateNewTile(Directions.Left) shouldBe GameStatesAfterMove.GameOver(0, Board(rows)) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/scala/game2048/RowTest.scala: -------------------------------------------------------------------------------- 1 | package game2048 2 | 3 | import org.scalatest.prop.TableDrivenPropertyChecks 4 | import org.scalatest.{Matchers, FlatSpec} 5 | 6 | class RowTest extends FlatSpec with Matchers with TableDrivenPropertyChecks { 7 | import scala.language.implicitConversions 8 | def intToField(i: Int): Tile = { 9 | if (i == 0) Tiles.EmptyTile 10 | else Tiles.NonEmptyTile(i)(id = 0) 11 | } 12 | implicit def listOfIntToListOfField(ints: List[Int]): List[Tile] = { 13 | ints.map(intToField) 14 | } 15 | 16 | it should "shift row to the left and right" in { 17 | val rows = Table( 18 | ("before shifting", "after right shift", "after left shift"), 19 | (List(0, 0, 0, 0), List(0, 0, 0, 0), List(0, 0, 0, 0)), 20 | (List(0, 0, 2, 0), List(0, 0, 0, 2), List(2, 0, 0, 0)), 21 | (List(0, 0, 2, 2), List(0, 0, 0, 4), List(4, 0, 0, 0)), 22 | (List(2, 2, 2, 2), List(0, 0, 4, 4), List(4, 4, 0, 0)), 23 | (List(0, 2, 2, 2), List(0, 0, 2, 4), List(4, 2, 0, 0)), 24 | (List(2, 0, 0, 2), List(0, 0, 0, 4), List(4, 0, 0, 0)), 25 | (List(0, 2, 2, 0), List(0, 0, 0, 4), List(4, 0, 0, 0)), 26 | (List(4, 2, 2, 0), List(0, 0, 4, 4), List(4, 4, 0, 0)), 27 | (List(4, 0, 4, 2), List(0, 0, 8, 2), List(8, 2, 0, 0)) 28 | ) 29 | forAll(rows) { (beforeShift, expectedAfterRightShift, expectedAfterLeftShift) => 30 | Row(beforeShift).shiftRight._2.tiles.map(_.value) shouldBe Row(expectedAfterRightShift).tiles.map(_.value) 31 | Row(beforeShift).shiftLeft._2.tiles.map(_.value) shouldBe Row(expectedAfterLeftShift).tiles.map(_.value) 32 | } 33 | } 34 | } 35 | --------------------------------------------------------------------------------