Created
December 12, 2016 23:33
-
-
Save cmontella/61f2b330659610b7b329a97795527ae2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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] | |
] | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment