├── .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 | 
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 |
--------------------------------------------------------------------------------