├── .gitignore
├── README.md
├── bsconfig.json
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── components
│ ├── App2k.css
│ ├── App2k.re
│ ├── Board.css
│ ├── Board.re
│ ├── EventLayer.re
│ ├── Footer.css
│ ├── Footer.re
│ ├── GameOver.css
│ ├── GameOver.re
│ ├── Title.css
│ └── Title.re
├── index.css
├── index.re
├── lib
│ ├── gameLogic.re
│ ├── gameLogic.rei
│ └── gamelogic_test.re
└── registerServiceWorker.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # bucklescript
13 | /lib
14 | /types
15 | .merlin
16 |
17 | # misc
18 | .DS_Store
19 | .env.local
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 |
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | .vscode
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is another implementation of the Game [2048](http://2048game.com/) in
2 | Reason React. It was my first Reason React project, and is basically a fork of
3 | [LIU9293's implementation](https://github.com/LIU9293/reason-react-2048).
4 |
5 | ## Key Observations
6 |
7 | * Reason React is really easy to pickup. I am not much more than a javascript
8 | tinkerer, yet I was able to develop this, starting from
9 | [Reason Scripts](https://github.com/reasonml-community/reason-scripts) within
10 | 5-6 hours. The syntax proves very familiar for someone who knows javascript.
11 | It's readable and straightforward.
12 | * It was jawdroppingly powerful
13 | * Pattern Matching is amazing! I was hooked from the first one I wrote. It
14 | allows the programmer to focus on the logic, as the implementation becomes
15 | very easy.
16 | * Encouraged by the toolset, I found myself quickly migrating to more
17 | functional-style programming. I avoided loops, and found myself writing
18 | **recursive code**!
19 | * Tuples are great too. I loved wrapping-together a few variables and treating
20 | them as a unit. Although I found the parentheses syntax kinda confusing.
21 | There were times when I had 3-4 open or closed parentheses side-by-side for
22 | a simple callback function.
23 | * I _loved_ the typechecking. I haven't programmed with types since the Java
24 | era. I can't believe that there was a bullet-proof typesystem that didn't
25 | require annotating everything. It just worked. I was floored when the
26 | typechecking found errors within my JSX. **Wowzers**.
27 | * I did have a number of challenges that took a little while to work-through
28 | * ~~I found the module system a bit confusing. I couldn't figure-out how to
29 | create private helper-functions without putting them within the definition
30 | of the public function. That ended-up working for this project, however it
31 | makes it impossible to share these helper functions within the module, and
32 | it made unit testing harder.~~
33 | [Module Signatures](https://reasonml.github.io/guide/language/module#signatures)
34 | solve this ompletely
35 | * Jest doesn't fully support Bucklescript, yet. I worked mostly with
36 | linked-lists, but those aren't well supported by Jest. I usually had to
37 | convert an list to an array for testing purposes.
38 | * Given its newness, ReasonML there aren't a ton of Reason example code and
39 | Stack Overflow ansers out there. But there's a ton for OCAML, which maps 1:1
40 | to reason. The [playground](https://reasonml.github.io/try) proved
41 | invaluable, as I would paste-in OCAML and instantly get out Reason.
42 | - Reason / Bucklescript / React is HEAVY. This very light applicaiton is 58kb
43 | gzipped.... My code is a few hundred lines... THat's a lot of library code.
44 | I was somewhat surprised to see how much of it was Bucklescript code to
45 | handle language features like Linked Lists and Currying.
46 |
47 | All-in-all, it's been a very fun experience. And I'm amazed with the power. I'm
48 | going to look for opportunities to use Reason, instead of javascript, going
49 | forward.
50 |
51 | ## Todos
52 |
53 | * [ ] Fix keyboard bindings.... Right now the user has to click first. A bit
54 | stuck. **Could use help here from any react experts**
55 | * [x] Detect game-over
56 | * [x] Gestures
57 | * [x] Delay adding new cells after transform for a fraction of a second
58 | * [x] Retitle the page
59 |
--------------------------------------------------------------------------------
/bsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reason-scripts",
3 | "sources": [
4 | {
5 | "dir": "src",
6 | "subdirs": ["components", "lib"]
7 | }
8 | ],
9 | "bs-dependencies": ["reason-react", "bs-jest"],
10 | "reason": {
11 | "react-jsx": 2
12 | },
13 | "refmt": 3
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "re2048",
3 | "version": "0.1.0",
4 | "private": false,
5 | "dependencies": {
6 | "react": "^16.1.1",
7 | "react-dom": "^16.1.1",
8 | "reason-scripts": "0.7.0"
9 | },
10 | "homepage": "https://sevenseat.github.io/rr-2048",
11 | "keywords": [],
12 | "author": "John Schweikert",
13 | "license": "MIT",
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/sevenseat/rr-2048.git"
17 | },
18 | "scripts": {
19 | "analyze": "source-map-explorer build/static/js/main.*",
20 | "format": "npm run format:refmt; npm run format:prettier",
21 | "format:refmt": "refmt --in-place src/**/*.re",
22 | "format:prettier":
23 | "prettier --single-quote --write src/**/*.{js,jsx,json,css} *.json",
24 | "start": "react-scripts start",
25 | "predeploy": "npm run build",
26 | "deploy": "gh-pages -d build",
27 | "build": "react-scripts build",
28 | "test": "react-scripts test --env=jsdom",
29 | "test:coverage": "npm test -- --coverage",
30 | "eject": "react-scripts eject",
31 | "precommit": "lint-staged",
32 | "prepare": "npm link bs-platform"
33 | },
34 | "lint-staged": {
35 | "src/**/*.{re,rei}": ["refmt --in-place", "git add"],
36 | "src/**/*.{js,jsx,json,css}": ["prettier --single-quote --write", "git add"]
37 | },
38 | "devDependencies": {
39 | "bs-jest": "^0.2.0",
40 | "gh-pages": "^1.1.0",
41 | "husky": "^0.14.3",
42 | "lint-staged": "^5.0.0",
43 | "prettier": "^1.8.2",
44 | "reason-react": "^0.3.0",
45 | "source-map-explorer": "^1.5.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sevenseat/rr-2048/6777ef5802c6f48f3f66017f35c7036a56584496/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | Reason React 2048
24 |
25 |
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/App2k.css:
--------------------------------------------------------------------------------
1 | .App {
2 | height: 100%;
3 | width: 100%;
4 | background-color: #495057;
5 | }
6 |
7 | .App-intro {
8 | font-size: large;
9 | }
10 |
11 | .game-area {
12 | margin: 0px auto;
13 | width: 280px;
14 | }
15 |
16 | .github_link {
17 | appearance: none;
18 | color: #c5f6fa;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/App2k.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./App2k.css')|}];
2 |
3 | type action =
4 | | UserEvent(GameLogic.direction)
5 | | AddCell
6 | | Restart;
7 |
8 | type state = {
9 | canUpdate: bool,
10 | board: list(list(int)),
11 | score: int,
12 | gameOver: bool
13 | };
14 |
15 | let genState = (board, canUpdate) => {
16 | board,
17 | score: GameLogic.score(board),
18 | gameOver: GameLogic.gameIsOver(board),
19 | canUpdate
20 | };
21 |
22 | let component = ReasonReact.reducerComponent("App2k");
23 |
24 | let make = (_children) => {
25 | ...component,
26 | initialState: () => genState(GameLogic.make(), true),
27 | reducer: (action, state) =>
28 | switch (action, state.canUpdate) {
29 | | (UserEvent(direction), true) =>
30 | ReasonReact.UpdateWithSideEffects(
31 | genState(GameLogic.shift(direction, state.board), false),
32 | ((self) => ignore(Js.Global.setTimeout(self.reduce(() => AddCell), 100)))
33 | )
34 | | (UserEvent(_), false) => ReasonReact.NoUpdate
35 | | (AddCell, _) => ReasonReact.Update(genState(GameLogic.addCell(state.board), true))
36 | | (Restart, _) => ReasonReact.Update(genState(GameLogic.make(), true))
37 | },
38 | render: ({state, reduce}) =>
39 | UserEvent(direction)))>
40 | Restart)) />
41 |
42 |
43 | Restart)) />
44 |
45 | };
46 |
--------------------------------------------------------------------------------
/src/components/Board.css:
--------------------------------------------------------------------------------
1 | .board {
2 | width: 280px;
3 | height: 280px;
4 | padding: 10px;
5 | touch-action: none;
6 | border-radius: 6px;
7 | background: #bbada0;
8 | }
9 |
10 | .row {
11 | margin-bottom: 10px;
12 | height: 57.5px;
13 | }
14 |
15 | .row :last-child {
16 | margin-right: 0;
17 | }
18 |
19 | .cell {
20 | width: 57.5px;
21 | height: 57.5px;
22 | margin-right: 10px;
23 | float: left;
24 | border-radius: 3px;
25 | background: rgba(238, 228, 218, 0.35);
26 | }
27 |
28 | .card {
29 | position: absolute;
30 | top: -1px;
31 | left: -1px;
32 | height: 59.5px;
33 | width: 59.5px;
34 | border-radius: 3px;
35 | font-weight: bold;
36 | font-size: 20px;
37 | line-height: 59.5px;
38 | text-align: center;
39 | color: white;
40 | background-color: transparent;
41 | color: #776e65;
42 | transition: all 0.1s ease;
43 | }
44 |
45 | .card_number_2 {
46 | background: #eee4da;
47 | }
48 |
49 | .card_number_4 {
50 | background: #ede0c8;
51 | }
52 |
53 | .card_number_8 {
54 | color: #f9f6f2;
55 | background: #f2b179;
56 | }
57 |
58 | .card_number_16 {
59 | color: #f9f6f2;
60 | background: #f59563;
61 | }
62 |
63 | .card_number_32 {
64 | color: #f9f6f2;
65 | background: #f67c5f;
66 | }
67 |
68 | .card_number_64 {
69 | color: #f9f6f2;
70 | background: #f65e3b;
71 | }
72 |
73 | .card_number_128 {
74 | color: #f9f6f2;
75 | background: #edcf72;
76 | }
77 |
78 | .card_number_256 {
79 | color: #f9f6f2;
80 | background: #edcc61;
81 | }
82 |
83 | .card_number_512 {
84 | color: #f9f6f2;
85 | background: #edc850;
86 | }
87 |
88 | .card_number_1024 {
89 | color: #f9f6f2;
90 | background: #edc53f;
91 | }
92 |
93 | .card_number_2048 {
94 | color: #f9f6f2;
95 | background: #edc22e;
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/Board.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./Board.css')|}];
2 |
3 | module Card = {
4 | let component = ReasonReact.statelessComponent("Card");
5 | let make = (~value, _children) => {
6 | ...component,
7 | render: (_self) =>
8 | switch value {
9 | | 0 =>
10 | | value =>
11 |
12 | (ReasonReact.stringToElement({j|$value|j}))
13 |
14 | }
15 | };
16 | };
17 |
18 | let component = ReasonReact.statelessComponent("Board");
19 |
20 | let make = (~board, _children) => {
21 | ...component,
22 | render: (_self) => {
23 | let renderCells = (row) =>
24 | List.map((value) =>
, row)
25 | |> Array.of_list
26 | |> ReasonReact.arrayToElement;
27 | let renderRows =
28 | List.map((row) => (renderCells(row))
, board)
29 | |> Array.of_list
30 | |> ReasonReact.arrayToElement;
31 | renderRows
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/EventLayer.re:
--------------------------------------------------------------------------------
1 | type touchData = {
2 | x: float,
3 | y: float,
4 | time: float
5 | };
6 |
7 | type action =
8 | | TouchStart(touchData)
9 | | TouchEnd(touchData)
10 | | KeyDown(int);
11 |
12 | type state = {touchStart: option(touchData)};
13 |
14 | let component = ReasonReact.reducerComponent("EventLayer");
15 |
16 | let getTouchData = (touchesType, event) => {
17 | let touch = touchesType(event)##item(0);
18 | {x: touch##screenX, y: touch##screenY, time: Js.Date.now()}
19 | };
20 |
21 | let getGesture = (start_, end_) => {
22 | let min = 4000.0;
23 | let speedDown = (end_.y -. start_.y) *. 10000.0 /. (end_.time -. start_.time);
24 | let speedRight = (end_.x -. start_.x) *. 10000.0 /. (end_.time -. start_.time);
25 | let isHoriz = abs_float(speedRight) > abs_float(speedDown);
26 | switch (
27 | isHoriz,
28 | (abs_float(speedRight) -. min > 0.0, speedRight > 0.0),
29 | (abs_float(speedDown) -. min > 0.0, speedDown > 0.0)
30 | ) {
31 | | (true, (true, true), _) => Some(GameLogic.Right)
32 | | (true, (true, false), _) => Some(GameLogic.Left)
33 | | (false, _, (true, true)) => Some(GameLogic.Down)
34 | | (false, _, (true, false)) => Some(GameLogic.Up)
35 | | _ => None
36 | }
37 | };
38 |
39 | let make = (~onAction, ~className=?, children) => {
40 | let keyReducer = (direction) =>
41 | ReasonReact.UpdateWithSideEffects({touchStart: None}, (_self) => onAction(direction));
42 | {
43 | ...component,
44 | initialState: () => {touchStart: None},
45 | reducer: (action, state) =>
46 | switch (action, state.touchStart) {
47 | | (KeyDown(37), _) => keyReducer(GameLogic.Left)
48 | | (KeyDown(38), _) => keyReducer(GameLogic.Up)
49 | | (KeyDown(39), _) => keyReducer(GameLogic.Right)
50 | | (KeyDown(40), _) => keyReducer(GameLogic.Down)
51 | | (KeyDown(_), _) => ReasonReact.NoUpdate
52 | | (TouchStart(td), _) => ReasonReact.Update({touchStart: Some(td)})
53 | | (TouchEnd(_td), None) => ReasonReact.NoUpdate
54 | | (TouchEnd(td), Some(start)) =>
55 | switch (getGesture(start, td)) {
56 | | Some(direction) =>
57 | ReasonReact.UpdateWithSideEffects({touchStart: None}, ((_self) => onAction(direction)))
58 | | None => ReasonReact.NoUpdate
59 | }
60 | },
61 | render: ({reduce}) =>
62 | KeyDown(ReactEventRe.Keyboard.which(e))))
64 | onTouchStart=(reduce((e) => TouchStart(getTouchData(ReactEventRe.Touch.targetTouches, e))))
65 | onTouchEnd=(reduce((e) => TouchEnd(getTouchData(ReactEventRe.Touch.changedTouches, e))))
66 | onTouchMove=((e) => ReactEventRe.Touch.preventDefault(e))
67 | ?className
68 | tabIndex=0>
69 | (ReasonReact.arrayToElement(children))
70 |
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/Footer.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | width: 280px;
3 | font-size: 13px;
4 | text-align: left;
5 | color: #dee2e6;
6 | margin: 30px auto 0;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/Footer.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./Footer.css')|}];
2 |
3 | let component = ReasonReact.statelessComponent("Notice");
4 |
5 | let make = (_) => {
6 | ...component,
7 | render: (_self) =>
8 |
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/GameOver.css:
--------------------------------------------------------------------------------
1 | .end_layer {
2 | position: fixed;
3 | display: flex;
4 | height: 100vh;
5 | width: 100vw;
6 | left: 0;
7 | top: 0;
8 | background-color: rgba(73, 80, 87, 1);
9 | justify-content: center;
10 | align-items: center;
11 | flex-direction: column;
12 | padding: 0 0 0 20px;
13 | text-align: center;
14 | animation: showUp 0.3s forwards ease;
15 | }
16 |
17 | @keyframes showUp {
18 | from {
19 | transform: scale(0);
20 | opacity: 0;
21 | }
22 | to {
23 | transform: scale(1);
24 | opacity: 1;
25 | }
26 | }
27 |
28 | .message {
29 | font-size: 30px;
30 | margin: 20px 0 20px 0;
31 | color: #f8f9fa;
32 | }
33 |
34 | .to_github {
35 | margin: 20px auto;
36 | }
37 |
38 | .replay {
39 | margin: 20px auto;
40 | }
41 |
42 | .center {
43 | justify-content: center;
44 | margin: 0 20px !important;
45 | }
46 |
47 | .replay-button {
48 | width: 120px;
49 | height: 40px;
50 | background: #f1f3f5;
51 | border-radius: 3px;
52 | color: #495057;
53 | appearance: none;
54 | box-shadow: none;
55 | border: none;
56 | font-size: 13px;
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/GameOver.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./GameOver.css')|}];
2 |
3 | let component = ReasonReact.statelessComponent("Result");
4 |
5 | let make = (~gameOver, ~score, ~onReplay, _) => {
6 | ...component,
7 | render: (_self) =>
8 | switch gameOver {
9 | | false => ReasonReact.nullElement
10 | | true =>
11 |
12 |
(ReasonReact.stringToElement("Game Over!"))
13 |
14 |
15 |
(ReasonReact.stringToElement("Score"))
16 |
(ReasonReact.stringToElement(string_of_int(score)))
17 |
18 |
19 |
24 |
25 |
26 | (ReasonReact.stringToElement("Try again"))
27 |
28 |
29 |
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/Title.css:
--------------------------------------------------------------------------------
1 | .App-header {
2 | padding: 40px 0 30px;
3 | width: 280px;
4 | margin: auto;
5 | color: #f1f3f5;
6 | }
7 |
8 | .scoreArea {
9 | color: #f8f9fa;
10 | display: flex;
11 | flex-direction: row;
12 | }
13 |
14 | .scoreAreaInner {
15 | display: flex;
16 | flex-direction: column;
17 | margin-right: 30px;
18 | }
19 |
20 | .score {
21 | font-size: 26px;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Title.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./Title.css')|}];
2 |
3 | let component = ReasonReact.statelessComponent("Title");
4 |
5 | let make = (~score, ~onReplay, _children) => {
6 | ...component,
7 | render: (_self) =>
8 |
9 |
(ReasonReact.stringToElement("RR 2048"))
10 |
11 |
12 |
(ReasonReact.stringToElement("Total"))
13 |
(ReasonReact.stringToElement(string_of_int(score)))
14 |
15 |
16 |
17 | };
18 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | #root {
6 | height: 100%;
7 | }
8 |
9 | html,
10 | body {
11 | margin: 0;
12 | padding: 0;
13 | height: 100%;
14 | font-family: sans-serif;
15 | overflow: hidden;
16 | }
17 |
18 | div {
19 | position: relative;
20 | display: block;
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.re:
--------------------------------------------------------------------------------
1 | [%bs.raw {|require('./index.css')|}];
2 |
3 | [@bs.module "./registerServiceWorker"] external register_service_worker : unit => unit = "default";
4 |
5 | ReactDOMRe.renderToElementWithId( , "root");
6 |
7 | register_service_worker();
8 |
--------------------------------------------------------------------------------
/src/lib/gameLogic.re:
--------------------------------------------------------------------------------
1 | type boardType = list(list(int));
2 |
3 | type direction =
4 | | Left
5 | | Right
6 | | Up
7 | | Down;
8 |
9 | type orientPhase =
10 | | Pre
11 | | Post;
12 |
13 | let all = (x) => {
14 | let rec xs = [x, ...xs];
15 | xs
16 | };
17 |
18 | let rec take = (n, ls) =>
19 | switch (n, ls) {
20 | | (_, []) => []
21 | | (0, _) => []
22 | | (_, [x, ...xs]) => [x, ...take(n - 1, xs)]
23 | };
24 |
25 | let padTrim = (e, n, ls) => take(n, ls @ all(e));
26 |
27 | let rec transpose = (ls) =>
28 | switch ls {
29 | | [] => []
30 | | [[], ..._] => []
31 | | ls => [List.map(List.hd, ls), ...transpose(List.map(List.tl, ls))]
32 | };
33 |
34 | let shiftLeft = (board) => {
35 | let rec shiftHelper = (ls) =>
36 | switch ls {
37 | | [] => []
38 | | [0, ...rest] => shiftHelper(rest)
39 | | [head, 0, ...rest] => shiftHelper([head, ...rest])
40 | | [head, next, ...rest] when head === next => [head + next, ...shiftHelper(rest)]
41 | | [head, ...rest] => [head, ...shiftHelper(rest)]
42 | };
43 | List.map((ls) => padTrim(0, List.length(ls), shiftHelper(ls)), board)
44 | };
45 |
46 | let orient = (direction, phase, board) =>
47 | switch (direction, phase) {
48 | | (Left, _) => board
49 | | (Right, _) => List.map(List.rev, board)
50 | | (Up, _) => board |> transpose
51 | | (Down, Pre) => board |> List.rev |> transpose
52 | | (Down, Post) => board |> transpose |> List.rev
53 | };
54 |
55 | let shift = (direction, board) =>
56 | board |> orient(direction, Pre) |> shiftLeft |> orient(direction, Post);
57 |
58 | let unFlatten = (n, list) => {
59 | let rec helper = (i, acc, ls) =>
60 | switch (i, ls) {
61 | | (_, []) => [List.rev(acc)]
62 | | (0, _) => [List.rev(acc)] @ helper(n, [], ls)
63 | | (_, [h, ...t]) => helper(i - 1, [h, ...acc], t)
64 | };
65 | helper(n, [], list)
66 | };
67 |
68 | let randomInt = (num) => Js.Math.random() *. float_of_int(num) |> Js.Math.floor_int;
69 |
70 | let addCell = (board) => {
71 | let flatBoard = List.flatten(board);
72 | let numZeros = List.(flatBoard |> filter((===)(0)) |> length);
73 | switch numZeros {
74 | | 0 => board
75 | | _ =>
76 | let newValue = [|2, 2, 2, 2, 4|][randomInt(5)];
77 | let newCellPos = randomInt(numZeros);
78 | let rec placeZero = (zerosFound, fb) =>
79 | switch (fb, zerosFound === newCellPos) {
80 | | ([], _) => []
81 | | ([0, ...t], true) => [newValue, ...placeZero(zerosFound + 1, t)]
82 | | ([0, ...t], false) => [0, ...placeZero(zerosFound + 1, t)]
83 | | ([h, ...t], _) => [h, ...placeZero(zerosFound, t)]
84 | };
85 | flatBoard |> placeZero(0) |> unFlatten(board |> List.hd |> List.length)
86 | }
87 | };
88 |
89 | let make = () =>
90 | [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] |> addCell |> addCell |> addCell;
91 |
92 | let score = (board) => List.(board |> map(fold_left((+), 0)) |> fold_left((+), 0));
93 |
94 | let gameIsOver = (board) =>
95 | List.(
96 | [Left, Up] |> for_all((direction) => shift(direction, board) |> flatten |> for_all((!==)(0)))
97 | );
98 |
--------------------------------------------------------------------------------
/src/lib/gameLogic.rei:
--------------------------------------------------------------------------------
1 | type boardType = list(list(int));
2 |
3 | type direction =
4 | | Left
5 | | Right
6 | | Up
7 | | Down;
8 |
9 | let shift: (direction, boardType) => boardType;
10 |
11 | let addCell: boardType => boardType;
12 |
13 | let make: unit => boardType;
14 |
15 | let score: boardType => int;
16 |
17 | let gameIsOver: boardType => bool;
18 |
--------------------------------------------------------------------------------
/src/lib/gamelogic_test.re:
--------------------------------------------------------------------------------
1 | open Jest;
2 |
3 | open Expect;
4 |
5 | open GameLogic;
6 |
7 | let board = [[0, 2], [4, 4]];
8 |
9 | let empty = [[]];
10 |
11 | describe(
12 | "shift",
13 | () => {
14 | test("empty", () => expect(shift(Up, [[]]) |> Array.of_list) |> toHaveLength(0));
15 | test("blanks", () => expect(shift(Left, [[1, 0, 0, 1]])) |> toEqual([[2, 0, 0, 0]]));
16 | test("Up", () => expect(shift(Up, board)) |> toEqual([[4, 2], [0, 4]]));
17 | test("Down", () => expect(shift(Down, board)) |> toEqual(board));
18 | test("Left", () => expect(shift(Left, board)) |> toEqual([[2, 0], [8, 0]]));
19 | test("Right", () => expect(shift(Right, board)) |> toEqual([[0, 2], [0, 8]]))
20 | }
21 | );
22 |
23 | describe(
24 | "score",
25 | () => {
26 | test("empty", () => expect(score([[]])) |> toEqual(0));
27 | test("board", () => expect(score(board)) |> toEqual(10))
28 | }
29 | );
30 |
31 | describe(
32 | "make",
33 | () => {
34 | test("x length", () => expect(make() |> Array.of_list) |> toHaveLength(4));
35 | test("y length", () => expect(make() |> List.hd |> Array.of_list) |> toHaveLength(4));
36 | test("score - min", () => expect(make() |> score) |> toBeGreaterThanOrEqual(6));
37 | test("score - max", () => expect(score(board)) |> toBeLessThanOrEqual(12))
38 | }
39 | );
40 |
41 | describe(
42 | "addCell",
43 | () => {
44 | test("empty", () => [[]] |> addCell |> expect |> toEqual([[]]));
45 | test(
46 | "adds one cell",
47 | () =>
48 | [[0, 0], [0, 0]]
49 | |> addCell
50 | |> List.flatten
51 | |> List.filter((!==)(0))
52 | |> List.length
53 | |> expect
54 | |> toEqual(1)
55 | );
56 | test(">=2", () => [[0, 0], [0, 0]] |> addCell |> score |> expect |> toBeGreaterThanOrEqual(2));
57 | test("<=2", () => [[0, 0], [0, 0]] |> addCell |> score |> expect |> toBeLessThanOrEqual(4));
58 | test(
59 | "full",
60 | () =>
61 | [[8, 8], [8, 8]]
62 | |> addCell
63 | |> List.flatten
64 | |> Array.of_list
65 | |> expect
66 | |> toEqual([|8, 8, 8, 8|])
67 | )
68 | }
69 | );
70 |
71 | describe(
72 | "gameIsOver",
73 | () => {
74 | test("empty", () => expect(gameIsOver([[]])) |> toEqual(true));
75 | test("gameover", () => expect(gameIsOver([[2, 4], [4, 8]])) |> toEqual(true));
76 | test("up/down", () => expect(gameIsOver([[2, 4], [2, 8]])) |> toEqual(false));
77 | test("left/right", () => expect(gameIsOver([[2, 4], [8, 8]])) |> toEqual(false))
78 | }
79 | );
80 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl);
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------