# 2048 A version of [2048](https://gabrielecirulli.github.io/2048/) written in [Eve](http://witheve.com/). ## Setup This section sets up the records we will use to represent the game board and store the current state. * The `#game` record will store the overall game state. * `#axis` records represent the horizontal and vertical axis * `#direction` records map key presses to movement along an axis * `#colour` records map tile values to the colours to render them in ``` commit [#game, state: "init", seed: 0, score: 0] [#axis, idx: 0, max: 3] [#axis, idx: 1, max: 3] [#direction, key: 37, axis: 0, dir: -1] [#direction, key: 38, axis: 1, dir: -1] [#direction, key: 39, axis: 0, dir: 1] [#direction, key: 40, axis: 1, dir: 1] [#colour, value: 2, fg: "#776e65", bg: "#eee4da"] [#colour, value: 4, fg: "#776e65", bg: "#ede0c8"] [#colour, value: 8, fg: "#f9f6f2", bg: "#f2b179"] [#colour, value: 16, fg: "#f9f6f2", bg: "#f59563"] [#colour, value: 32, fg: "#f9f6f2", bg: "#f67c5f"] [#colour, value: 64, fg: "#f9f6f2", bg: "#f65e3b"] [#colour, value: 128, fg: "#f9f6f2", bg: "#edcf72"] [#colour, value: 256, fg: "#f9f6f2", bg: "#edcc61"] [#colour, value: 512, fg: "#f9f6f2", bg: "#edc850"] [#colour, value: 1024, fg: "#f9f6f2", bg: "#edc53f"] [#colour, value: 2048, fg: "#f9f6f2", bg: "#edc22e"] ``` So we can refer to a particular row or column, we create a `#line` record for each. ``` search axis = [#axis, max] idx = range[from: 0, to: max] bind [#line axis idx] ``` For each intersection of a horizontal and vertical line, we then create a `#cell` record to represent each avaiable postion on the grid. These cell records are constants; We will indicate if there is a number in a cell by creating a `#tile` record which references the cell. ``` search line0 = [#line axis: [#axis idx: 0]] line1 = [#line axis: [#axis idx: 1]] bind cell = [#cell line: line0, line: line1] ``` After the user has made a move, we need check if further moves are possible by merging or moving tiles. We create the following records that will be updated with this information after every move: - `[#can-merge, axis, after-move, value]`: `value` will be `true` if after move `after-move` completed a merge is possible on the specified `axis` - `[#can-move, axis, dir, after-move, value]`: `value` will be `true` if after move `after-move` completed a move is possible in the specified direction (`dir`) on the `axis`. ``` search not([#can-merge]) axis = [#axis] commit [#can-merge, axis, after-move: -1, value: false] ``` ``` search not([#can-move]) axis = [#axis] direction = [#direction dir] commit [#can-move, axis, dir, after-move: -1, value: false] ``` ## State changes This section is responsible for moving the game between states (recorded in `state` in the `[#game]` record). The game can be in any one of the following states: - `init`: Initialising - `adding`: Adding new tiles - `detect-moves`: Detecting possible moves and merges - `detect-completion`: Detecting whether the player has won or lost - `idle`: Awaiting a keypress from the user - `moving`: Moving tiles in response to a keypress - `won`: User has won - `lost`: User has lost (no further moves possible) ### ANY -> `adding` User has request a new game, so reset the game state, and indicate we want to add 2 new tiles. ``` search @session @event @browser [#click element: [#newgame]] [#time, frames] game = [#game] tile = if tile = [#tile] then tile else false can-move = [#can-move] can-merge = [#can-merge] commit tile := none can-move.after-move := -1 can-merge.after-move := -1 game <- [move: [idx: 0, axis: 0, dir: 0], seed: frames, add-count: 2, state: "adding", score: 0] ``` ### `adding` -> `detect-moves` When we have finished adding new tiles, check what moves are available. ``` search game = [#game, state: "adding", add-count = 0] commit game.state := "detect-moves" ``` ### `detect-moves` -> `detect-completion` After we have calculated the possible moves we are ready to determine if the game has ended. ``` search game = [#game, state: "detect-moves", move] count[given: [#can-move]] = count[given: [#can-move, after-move = move.idx]] commit game.state := "detect-completion" ``` ### `detect-completion` -> `won`/`lost`/`idle` The game is won if there is a 2048 tile present and is lost if the grid is full and there are no possible merges. ``` search game = [#game, state: "detect-completion"] state = if count[given: [#tile, value: 2048]] > 0 then "won" else if count[given: [#tile]] < count[given: [#cell]] then "idle" else if count[given: [#can-merge, value = true]] > 0 then "idle" else "lost" commit game.state := state ``` ### `idle` -> `moving` If the user pressed a direction key and there is a possible move for that direction, record the new move and begin `moving`. ``` search @event [#keyup, key] search game = [#game, state: "idle", move] [#direction, key, axis: axis-idx, dir] [#can-move, axis: [#axis, idx: axis-idx], dir, value = true] commit game <- [state: "moving", move: [idx: move.idx + 1, axis: axis-idx, dir]] ``` ### `moving` -> `adding` After a move has complete, add a new tile. ``` search game = [#game, state: "moving", move] moved-tiles = count[given: [#tile, last-move = move.idx]] all-tiles = count[given: [#tile]] moved-tiles = all-tiles commit game <- [state: "adding", add-count: 1] ``` ## Logic This section contains the main game code that specifies what happens in each of the game states. ### `detect-moves` First we determine if a merge is possible on either axis. A merge is possible if there are two tiles on the same line with the same value and there is no tile inbetween them. We use `after-move` to indicate that we have calculated the `#can-merge` for the current move. ``` search game = [#game, state: "detect-moves", move] axis = [#axis] other-axis = [#axis] != axis can-merge = [#can-merge, axis: other-axis, after-move < move.idx] value = if cell = [#cell, line, line: [#line, axis: other-axis, idx]] tile = [#tile, cell: cell, value] [#tile, cell: [#cell, line, line: [#line, axis: other-axis, idx: idx1]], value] idx1 > idx not( [#tile, cell: [#cell, line, line: [#line, axis: other-axis, idx: idx2]]] idx < idx2 < idx1 ) then true else false commit can-merge <- [value, after-move: move.idx] ``` Once we have calculated `#can-merge` for an axis, we can determine if there are any possible moves on the axis in either direction. A move is possible for a particular direction if there is a merge available or there is an empty cell on the same line as a tile in the direction of movement. ``` search game = [#game, state: "detect-moves", move] [#direction, axis: axis-idx, dir] axis = [#axis, idx: axis-idx] other-axis = [#axis] != axis [#can-merge, axis: other-axis, after-move = move.idx] can-move = [#can-move, axis: other-axis, dir, after-move < move.idx] value = if [#can-merge, axis: other-axis, after-move: move.idx, value = true] then true else if cell = [#cell, line, line: [#line, axis: other-axis, idx]] tile = [#tile, cell: cell] next-cell = [#cell, line, line: [#line, axis: other-axis, idx: idx + dir]] not([#tile, cell: next-cell]) then true else false commit can-move <- [value, after-move: move.idx] ``` ### `adding` Add a new tile at a random free cell and decrease `add-count`. Adding will continue until `add-count` is zero. Since values in Eve are unordered sets it is little tricky to work out how to select a random cell from the list of free cells. To do this we use the `sort` function to associate an index with each of the avaiable cells. We can then constrain the index to be equal to a random number and use the associated cell as our tile location. ``` search game = [#game, state: "adding", move, seed, add-count] add-count > 0 cell = [#cell] not([#tile, cell]) num-free-cells = count[given: cell] sort[value: cell] = idx base-seed = seed + move.idx + add-count idx = 1 + round[value: random[seed: base-seed] * (num-free-cells - 1)] value = if random[seed: base-seed * 13] < 0.9 then 2 else 4 new-count = add-count - 1 commit [#tile, cell, value, last-move: move.idx, last-merge: -1] game.add-count := new-count ``` ### `moving` When a move has been requested, `game.move` specifies the direction of the requested move. The rules are: * Move the closest tiles to the edge in the direction of movement first * If a tile hits a tile with the same value, and that tile hasn't already been merged this turn, merge them * If a tile hits a tile with a different value, move it adjacent to it * On a merge, remove the hit tile, double the value on the moved tile and add this to the game score ``` search game = [#game, state: "moving", move] move-axis = [#axis, idx: move.axis, max: max-pos] other-axis = [#axis] != move-axis move-tile = [#tile, cell, last-move < move.idx] cell = [#cell, line, line: other-line] line = [#line, axis: move-axis, idx: cur-pos] // the tile will move to a different line on this axis other-line = [#line, axis: other-axis] // the tile will move along this line (i.e. remain on it) // Don't move a tile if there are tiles closer to the edge that haven't been moved yet not(move.dir < 0, cell-in-front = [#cell, line: [#line, axis: move-axis, idx < cur-pos], line: other-line], [#tile, cell: cell-in-front, last-move < move.idx] ) not(move.dir > 0, cell-in-front = [#cell, line: [#line, axis: move-axis, idx > cur-pos], line: other-line], [#tile, cell: cell-in-front, last-move < move.idx] ) // Find the position of the tile we will hit (if any) if we move in the requested direction hit-pos = if move.dir < 0, block-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: tile-pos], line: other-line]] tile-pos < cur-pos then max[given: block-tile, value: tile-pos] else if move.dir > 0, block-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: tile-pos], line: other-line]] tile-pos > cur-pos then min[given: block-tile, value: tile-pos] else false // Depending on hit-pos, determine whether the tile will move to hit-pos, merge with another tile, or move // adjacent to a tile already at that position (new-cell, new-value, merge-tile, last-merge, score-inc) = if hit-pos = false, move.dir > 0 then ([#cell, line: [#line, axis: move-axis, idx: max-pos], line: other-line], move-tile.value, false, -1, 0) else if hit-pos = false then ([#cell, line: [#line, axis: move-axis, idx: 0], line: other-line], move-tile.value, false, -1, 0) else if hit-tile = [#tile, cell: [#cell, line: [#line, axis: move-axis, idx: hit-pos], line: other-line], value: hit-value, last-merge] hit-value = move-tile.value last-merge != move.idx then ([#cell, line: [#line, axis: move-axis, idx: hit-pos], line: other-line], move-tile.value * 2, hit-tile, move.idx, hit-value * 2) else ([#cell, line: [#line, axis: move-axis, idx: hit-pos - move.dir], line: other-line], move-tile.value, false, -1, 0) // Update the game score new-score = game.score + sum[given: new-cell, value: score-inc] commit move-tile <- [cell: new-cell, value: new-value, last-move: move.idx, last-merge] merge-tile := none game.score := new-score ``` ## Rendering This section renders the game on the browser. Add score caption, new game button and a parent SVG element to contain the game grid. ``` search width = 250 height = 250 [#game, score, state] [#axis, idx: 0, max: max0] [#axis, idx: 1, max: max1] status = if state = "won" then "WON" else if state = "lost" then "LOST" else "" bind @browser [#p, sort: 1, text: "Score {{score}} {{status}}"] [#svg, sort: 2, viewBox: "0 0 {{max0 + 1.25}} {{max1 + 1.25}}", width, height, children: [#rect, rx: 0.1, ry: 0.1, x:0, y:0, width: max0 + 1.25, height: max1 + 1.25, fill: "#bbada0"] [#g, #grid, transform: "translate(0.125, 0.125)"] ] [#p, sort: 3, children: [#button, #newgame, style: [width: "10em"], text: "New Game"] ] ``` We set up a `viewBox` on the parent SVG element to modify the coordinate system so that we can just position cells with our line indexes. We also apply a transform when rendering each cell so that it is scaled down slightly to provide a border and the origin is the centre of the cell. ``` search @browser grid = [#grid] search axis0 = [#axis, idx: 0] axis1 = [#axis, idx: 1] cell = [#cell, line: [axis: axis0, idx: idx0], line: [axis: axis1, idx: idx1]] (x, y, fill, text-col, text) = if [#tile, cell, value] [#colour, value, bg, fg] then (idx0, idx1, bg, fg, value) else (idx0, idx1, "#cdc1b4", "", "") bind @browser grid.children += [#g, transform: "translate({{x + 0.5}},{{y + 0.5}}) scale(0.85)", children: [#rect, rx: 0.05, ry: 0.05, x:-0.5, y:-0.5, width: 1, height: 1, fill] [#text, x:0, y:0, font-size: 0.4, font-weight: "bold", fill: text-col, dominant-baseline: "middle", text-anchor: "middle", text] ] ```