2048 на Erlang

в 6:07, , рубрики: erlang, Erlang/OTP, game development, websockets, метки: , ,

imageНаверное на неделю игры 2048 на хабре уже не успеваю, но статья не столько о игре сколько о websocket сервере на Erlang. Небольшая предыстория. Когда начал играть в 2048, то просто не мог прекратить. В ущерб работе и семье. Поэтому принял решение, что играть за меня должен бот. Но загвоздка в том, что игра клиентская, из-за чего не ведется глобальный рейтинг и не так удобно играть без браузера. Поэтому я и решил сделать серверную часть, где был бы рейтинг. И где мог бы играть мой бот без браузера.

Отмечу, что это мой первый проект на Erlang. Много программистов боится Erlang, предполагая, что это сложно. Но на самом деле это не так. Плюс, я постараюсь высветлить моменты, которые не совсем очевидны новичку в Erlang.

Для упрощения много чего захардкожено. Но я всегда рад конструктивной критике и комментариям.
Ссылка на github — erl2048.
Ссылка на рабочий проект — erl2048. Но, думаю, под хабраэффектом проживет он недолго.

JavaScript

Как ни странно — начну с JS. Я не изменял оригинальные файлы, чтобы их можно было обновить с первичного репозитория, если понадобится. Я использовал:

  • main.css;
  • animframe_polyfill.js для requestAnimationFrame;
  • html_actuator.js для всех анимаций
  • keyboard_input_manager.js для событий клавиатуры, и, как показала практика, зря;

Я создал файл «main.js». Логика простая — браузер шлет на сервер события, и потом обновляет поле. Благо, animframe_polyfill создан таким образом, что принимает сформированный grid.

Что я добавил. Инициализация соединения:

var websocket = new Websocket(SERVER);
  websocket
  .connect()
  .done(function(){
    var myGame = new MyGame(websocket);    
  });

На скорую руку написал обертку над «Websocket». Она очень проста, чтобы приводить здесь исходный код.
Начало новой игры:

self.restart = function(evt){
  websocket.send(JSON.stringify({
    action:'start'
  }));
};

Сделать ход:
self.move = function(direction){
  // 0: up, 1: right, 2:down, 3: left
  if(!toMove){
    return false;
  }
  if(direction === 0){
    direction = 'up';
  }else if(direction === 1){
    direction = 'right';
  }else if(direction === 2){
    direction = 'down';
  }else if(direction === 3){
    direction = 'left';
  }
  websocket.send(JSON.stringify({
    action:'move',
    value: direction
  }));
};

И самый большой.

Обработка ответа сервера:

self.wsHandler = function(evt){
  var game = JSON.parse(evt.data);

  if(game.grid){

    var grid = {cells: []};
    game.grid.forEach(function (column, y) {
      var row = [];
      column.forEach(function (cell, x) {
        if(cell){
          if(cell.mergedFrom){
            cell.mergedFrom.forEach(function(tile){
              tile['x'] = x;
              tile['y'] = y;
            });
          }
          row.push({
            value:            cell.value,
            x:                x,
            y:                y,
            previousPosition: cell.previousPosition,
            mergedFrom:       cell.mergedFrom
          });
        }
      });
      grid.cells.push(row);
    });

    var scores = game.scores,
      bestScore = 0;
    if(scores && scores.length>0){
      bestScore = scores[0].score;

      while (scoresEl.firstChild) {
        scoresEl.removeChild(scoresEl.firstChild);
      }

      scores.forEach(function(score){
        var div = document.createElement('Div');
        var name = document.createElement('Div');
        var scoreEl = document.createElement('Div');

        div.setAttribute("class", 'score');
        name.setAttribute("class", 'name');
        scoreEl.setAttribute("class", 'score');

        name.appendChild(document.createTextNode(score.name));
        scoreEl.appendChild(document.createTextNode(score.score));

        div.appendChild(name);
        div.appendChild(scoreEl);
        scoresEl.appendChild(div);
      });
    }

    actuator.actuate(grid, {
      score:     game.score,
      bestScore: bestScore,
      score: game.score,
      won: game.won,
      over: game.over,
      keepPlaying: game.keepPlaying
    });
  }

  //playername actuator
  if(game.user){
    if(playername.value !== playername){
      playername.value = game.user.name;
    }
  }
};

Как видно, игра полностью зависит от сервера, потому что все расчеты происходят там. Не так как, например, в моей игре Крестики нолики, где логика дублируется.
На самом деле, не понял, зачем в оригинале используется x и y в Tile, поэтому сервер обходится без них. А на клиенте уже дописываю, чтобы actuator сьел.
Также с сервера приходит список топ10 лучших игроков. Это нововведение моей версии. И еще игрок может изменять свой ник. Никаких регистраций и защит. Ввел имя и играй. Нужно навести на квадратик с best score чтобы увидеть общий рейтинг. Выглядит это так.

2048 на Erlang

Использовать родной keyboard_input_manager не очень хорошо. Потому что теперь в поле ввода никнейма можно вводить не все символы. Но вы можете вставить свой ник с буфера обмена.
Плюс, я реализовал не весь функционал. Часть, что отвечает за «проигрыш» пока закрыта заглушкой, но это не очень влияет на игровой процесс. И продолжить игру после выигрыша пока нет возможности. Но выиграть еще не получилось.

Erlang

Эта часть будет более детально расписана. Для начала нужно установить rebar. Сделать это можно отсюда. Rebar может сгенерировать начальные файлы, но я их создавал вручную.
«rebar.config» — используется для автоматического скачивания и сборки зависимостей.

Скрытый текст

% The next option is required so we can use lager.  
{erl_opts, [{parse_transform, lager_transform}]}.  
{lib_dirs,["deps"]}.  
% Our dependencies.  
{deps, [    
    {'lager', ".*", {  
        git, "git://github.com/basho/lager.git", "master"}  
    },
    {'cowboy', ".*", {  
        git, "git://github.com/extend/cowboy.git", "master"}  
    },
    {'mochiweb', ".*", {
    	git, "git://github.com/mochi/mochiweb.git", "master"}
    },
    {'sqlite3', ".*", {
    	git, "git://github.com/alexeyr/erlang-sqlite3.git", "master"}
    }
]}.  

# rebar g-d
# rebar co

Чтобы скачать и собрать зависимости. Возможно понадобится установить «libsqlite3-dev» для sqlite драйвера.

Для запуска сервера я использую:

# rebar compile skip_deps=true; erl -pa ebin deps/*/ebin -eval 'starter:start().' -noshell -detached

После этого игра будет доступна на 8080 порту. На самом деле, научится запускать проект было самым сложным. Дальше — легче. Я создал специльный модуль «starter», который запускает все зависимости и приложение.

-module(starter).
-export([start/0]).

start() ->
	application:start(ranch),
	application:start(crypto),
	application:start(cowlib),
	application:start(cowboy),
	application:start(inets),
	application:start(mochiweb),
	application:start(erl2048).

Теперь рассмотрю содержимое директории «src». Первое — файл «erl2048.app.src». Не знаю, на самом деле, для чего он нужен, но добавил и свой проект на всякий случай.

Скрытый текст

{application, erl2048, [
{description, "2048 game server."},
{vsn, "1"},
{modules, []},
{registered, [erl2048_sup]},
{applications, [
kernel,
stdlib,
cowboy
]},
{mod, {erl2048_app, []}},
{env, []}
]}.
erl2048_sup.erl

%% Feel free to use, reuse and abuse the code in this file.

%% @private
-module(erl2048_sup).
-behaviour(supervisor).

%% API.
-export([start_link/0]).

%% supervisor.
-export([init/1]).

%% API.

-spec start_link() -> {ok, pid()}.
start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

%% supervisor.

init([]) ->
    Procs = [],
    {ok, {{one_for_one, 10, 10}, Procs}}.

Я так понимаю, что эта штука следит, чтобы приложение не падало и перезапускает в случае надобности. Взял из примера — решил оставить.

Теперь главный файл приложения — «erl2048_app.erl».

Скрытый текст

%% Feel free to use, reuse and abuse the code in this file.

%% @private
-module(erl2048_app).
-behaviour(application).

%% API.
-export([start/2]).
-export([stop/1]).

%% API.
start(_Type, _Args) ->
    Dispatch = cowboy_router:compile([
        {'_', [
            {"/", cowboy_static, {file, "../client/index.html"}},
            {"/websocket", ws_handler, []},
            {"/static/[...]", cowboy_static, {dir, "../client/static"}}
        ]}
    ]),
    {ok, _} = cowboy:start_http(http, 100, [{port, 8080}],
        [{env, [{dispatch, Dispatch}]}]),
    {ok, _} = db:start_link(),
    erl2048_sup:start_link().

stop(_State) ->
    {ok, _} = db:stop(),
    ok.

Здесь я уже могу кое-что объяснить. Во-первых, компилируются роуты для cowboy. Потом запускается cowboy и подключение к базе данных.
В роли субд выступает sqlite. Я рассматривал еще Postgresql, mongoDB и Redis. Но остановился на sqlite, так как он самый простой. Плюс хранит данные после перезапуска. Но, думаю, создаст большую нагрузку на приложение из-за чего оно скорее ляжет. Как бы там ни было — код модуля:

Скрытый текст

-module(db).

-export([start_link/0,stop/0]).
-export([insert/2, select/0, createUser/1, changeName/2]).

start_link() ->
    {ok, PID} = sqlite3:open(db, [{file, "db.sqlite3"}]),

    Tables = sqlite3:list_tables(db),

    case lists:member("scores", Tables) of false ->
        sqlite3:create_table(db, scores, [{id, integer, [{primary_key, [asc, autoincrement]}]}, {userid, integer}, {score, integer}])
    end,

    case lists:member("users", Tables) of false ->
        sqlite3:create_table(db, users, [{id, integer, [{primary_key, [asc, autoincrement]}]}, {name, text}])
    end,

    {ok, PID}.

stop() ->
    sqlite3:close(db).

select() ->
    Ret = sqlite3:sql_exec(db, "select users.name, scores.score from scores LEFT JOIN users ON (users.id = scores.userid) ORDER BY score desc;"),
    [{columns,_},{rows,Rows}] = Ret,
    formatScores(Rows).

insert(Score, Player) ->
    [{columns,_},{rows,Rows}] = sqlite3:sql_exec(db, "SELECT score FROM scores WHERE userid = ?", [{1,Player}]),
    DBScore = if
        length(Rows) > 0  -> element(1,hd(Rows));
        true -> 0
    end,
erlang:display({Score,DBScore}),

    if Score > DBScore ->
        sqlite3:delete(db, scores, {userid, Player}),
        sqlite3:write(db, scores, [{userid, Player}, {score, Score}]),
        sqlite3:sql_exec(db, "DELETE FROM scores WHERE id IN (SELECT id FROM scores ORDER BY score desc LIMIT 1 OFFSET 10)");
        true -> undefined
    end.

formatScores([]) ->
    [];
formatScores([{Name, Score} | Rows]) ->
    [{struct, [{name, Name},{score, Score}]} | formatScores(Rows)].

createUser(UserName) ->
    sqlite3:write(db, users, [{name, UserName}]).

changeName(Id, NewName) ->
    sqlite3:update(db, users, {id, Id}, [{name, NewName}]).

Перейдем к модулю, который обрабатывает websocket соединения.

ws_handler.erl

-module(ws_handler).
-behaviour(cowboy_websocket_handler).

-export([init/3]).
-export([websocket_init/3]).
-export([websocket_handle/3]).
-export([websocket_info/3]).
-export([websocket_terminate/3]).

init({tcp, http}, _Req, _Opts) ->
    {upgrade, protocol, cowboy_websocket}.

websocket_init(_TransportName, Req, _Opts) ->
    State = {struct, [ 
        { user, { struct, [{id, null},{name, <<"Player">>}] } } 
    ]},
    {ok, Req, State}.

websocket_handle({text, Msg}, Req, State) ->
    Message = mochijson2:decode(Msg, [{format, proplist}]),
    Action =  binary_to_list(proplists:get_value(<<"action">>, Message)),
    {NewState, Response} = case Action of
        "start" ->
            TmpState = game:init(State),
            {TmpState, TmpState};
        "move"  ->
            TmpState = game:move(list_to_atom(binary_to_list(proplists:get_value(<<"value">>, Message))), State),
            {TmpState, TmpState};
        "newName" ->
            NewName = proplists:get_value(<<"value">>, Message),
            JsonData = element(2, State),

            User = proplists:get_value(user, JsonData),
            {struct,UserJsonData} = User,

            Id = proplists:get_value(id, UserJsonData),

            db:changeName(Id, NewName),

            TmpState = {struct, [
                    { user, { struct, [ { name, NewName },{ id, Id } ] } }
                    | proplists:delete(user, JsonData)
                ]},
            {
                TmpState,
                {struct, [{ user, { struct, [ { name, NewName },{ id, Id } ] } }]}
            };
        _Else -> State
    end,
    {reply, {text, mochijson2:encode(Response)}, Req, NewState};

websocket_handle(_Data, Req, State) ->
    {ok, Req, State}.

websocket_info({send, Msg}, Req, State) ->
    {reply, {text, Msg}, Req, State};
websocket_info(_Info, Req, State) ->
    {ok, Req, State}.

websocket_terminate(_Reason, _Req, _State) ->
    ok.

Поначалу я не понимал как оно все устроено. Оказывается, все очень просто. Есть состояние, которое задается при установке соединения. И которое передается в каждый обработчик запроса для каждого клиента свое. Основной метод здесь это «websocket_handle». Он принимает сообщение и состояние а возвращает ответ и состояние.
Для общение используется формат JSON. В Erlang он представляется структурой типа:

{struct, [
  {key1, Value1},
  {key2, Value2},
  ....
]}

Теперь непосредственно файлы игры. Самый простой «tile.erl».

tile.erl

-module(tile).

-export([init/1, init/0, prepare/2]).

prepare(null, _) ->
    null;
prepare(Tile, { X, Y }) ->
    {
        struct,
        [
            {value, proplists:get_value(value, element(2, Tile))},
            {mergedFrom, null},
            {previousPosition, {struct, [{ x, X - 1},{ y, Y - 1 }]}}
        ]
    }.
init(Value) ->
    {
        struct,
        [
            {value, Value},
            {mergedFrom, null},
            {previousPosition, null}
        ]
    }.
init() ->
    init(2).

Только и умеет, что создавать новый тайл и сохранять предыдущую позицию.
«grid.erl» уже посложнее.

grid.erl

-module(grid).

-export([
    build/0,
    cellsAvailable/1,
    randomAvailableCell/1,
    insertTile/3,
    availableCells/1,
    cellContent/2,
    removeTile/2,
    moveTile/3,
    size/0,
    withinBounds/1,
    cellAvailable/2
]).

-define(SIZE, 4).

size() ->
    ?SIZE.

build() ->
    [[null || _ <- lists:seq(1, ?SIZE)] || _ <- lists:seq(1, ?SIZE)].

availableCells(Grid) ->
    lists:append(
        setY(
            availableCells(Grid, 1)
        )
    ).

availableCells([Grid | Tail ], N) when is_list(Grid) ->
    [{availableCells(Grid, 1), N} | availableCells(Tail, N +1)];
availableCells([Grid | Tail ], N) ->
    case Grid =:= null of
        true -> [ N | availableCells(Tail, N +1)];
        false ->  availableCells(Tail, N +1)
    end;
availableCells([], _) ->
    [].

setY([{Cell, Y}|Tail]) -> 
    [ setY(Cell, Y) | setY(Tail)];
setY([]) -> 
    [].
setY([Head | Tail], Y) ->
    [ {Head, Y} | setY(Tail, Y)];
setY([], _) ->
    [].

cellsAvailable(Grid) ->
    length(availableCells(Grid)) > 0.

randomAvailableCell(Grid) ->
    Cells = availableCells(Grid),
    lists:nth(random:uniform(length(Cells)) ,Cells).

insertTile({X, Y}, Tile, Grid) ->
    Row = lists:nth(Y,Grid),
    lists:sublist(Grid,Y - 1) ++ [ lists:sublist(Row,X - 1) ++ [Tile] ++ lists:nthtail(X,Row)] ++ lists:nthtail(Y,Grid).

cellContent({ X, Y }, Grid) ->
    case withinBounds({ X, Y }) of
        true -> lists:nth(X,lists:nth(Y,Grid));
        false -> null
    end.

removeTile({ X, Y }, Grid) ->
    insertTile({X, Y}, null, Grid).

moveTile(Cell, Cell, Grid) ->
    Grid;
moveTile(Cell, Next, Grid) ->
    insertTile(Next, grid:cellContent(Cell, Grid), removeTile(Cell, Grid)).

withinBounds({X, Y}) when
    (X > 0), (X =< ?SIZE), 
    (Y > 0), (Y =< ?SIZE) ->
    true;
withinBounds(_) ->
    false.

cellAvailable(Cell, Grid) ->
    case grid:withinBounds(Cell) of
        true -> cellContent(Cell, Grid) =:= null;
        false -> false
    end.

Обратите внимание на availableCells. В Erlang нужно по максимуму использовать рекурсию. Но здесь я сам себя перемудрил. Сначала сгенерировал лист, который в содержал листы с одной координатой и вторую координату. А потом вносил вторую к первой. Я решил больше так не делать. Остальные функции, думаю, очевидны.
И, основной файл игры. Так и называется «game.erl».

game.erl

-module(game).

-export([init/1, move/2]).

init(State) ->

    StateUser = proplists:get_value(user, element(2, State)),
    StateUserJsonData = element(2, StateUser),

    User = case proplists:get_value(id, StateUserJsonData) of
        null ->
            Name = proplists:get_value(name, StateUserJsonData),
            {rowid, Id} = db:createUser(Name),
            { struct, [{name, Name},{id, Id}]};
        _Else ->
            StateUser
    end,

    {
        struct,
        [
            {grid ,addStartTiles(grid:build())},
            {user , User},
            {score,0},
            {scores, db:select()},
            {won, false},
            {over, false},
            {keepPlaying, false}
        ]
    }.

addStartTiles(Grid, 0) -> 
    Grid;
addStartTiles(Grid, N) -> 
    NewGrid = addRandomTile(Grid),
    addStartTiles(NewGrid, N - 1).
addStartTiles(Grid) ->
    addStartTiles(Grid, 2).

addRandomTile(Grid) ->
    random:seed(now()),
    case grid:cellsAvailable(Grid) of
        true -> 
            case random:uniform(10) < 9 of
                true -> Tile = tile:init();
                false -> Tile = tile:init(grid:size())
            end,
            grid:insertTile(grid:randomAvailableCell(Grid), Tile, Grid);
        false -> Grid
    end.

getVector(left) ->
    { -1, 0 };
getVector(up) ->
    { 0,  -1 };
getVector(right) ->
    { 1,  0 };
getVector(down) ->
    { 0,  1 }.

buildTraversals() ->
    Traver = lists:seq(1, grid:size()),
    { Traver, Traver }.
buildTraversals({ 1 , _ }) ->
    { T1, T2} = buildTraversals(),
    { lists:reverse(T1), T2 };
buildTraversals({ _ , 1 }) ->
    { T1, T2} = buildTraversals(),
    { T1, lists:reverse(T2) };
buildTraversals({ _ , _ }) ->
    buildTraversals().

prepareTiles( [{_Key, _Value} | _Tail ] ) ->
    JsonData = [{_Key, _Value} | _Tail ],
    [{ grid, prepareTiles(proplists:get_value(grid, JsonData)) } | proplists:delete(grid, JsonData) ];
prepareTiles( Grid ) ->
    prepareTiles( Grid, 1).
prepareTiles([], _) ->
    [];
prepareTiles([Row | Tail], Y) ->
    [ prepareTileY(Row, 1, Y) | prepareTiles(Tail, Y + 1)].
prepareTileY([], _, _) ->
    [];
prepareTileY([Cell | Tail], X, Y) ->
    [prepareTileX(Cell, X, Y) | prepareTileY(Tail, X + 1, Y) ].
prepareTileX(Tile, X, Y) ->
    tile:prepare(Tile, {X, Y}).

process_travesals_y([], _, _, JsonData) ->
    JsonData;
process_travesals_y(_, [], _, JsonData) ->
    JsonData;
process_travesals_y([ Y | Tail ], TraversalsX, Vector, JsonData) ->
    process_travesals_y(
        Tail,
        TraversalsX,
        Vector,
        process_travesals_y( Y, TraversalsX, Vector, JsonData)
    );
process_travesals_y(Y, [ X | Tail ], Vector, JsonData) ->
    process_travesals_y(Y, Tail, Vector, process_travesals_y( Y, X, Vector, JsonData ));
process_travesals_y( Y, X, Vector, JsonData ) ->
    moveTile({ X, Y }, Vector, JsonData).

findFarthestPosition({X, Y}, {VecX, VecY}, Grid) ->

    Next = { X + VecX, Y + VecY },

    case grid:cellAvailable(Next, Grid) of
        true -> 
            findFarthestPosition(Next, {VecX, VecY}, Grid);
        false -> 
            {
                {X, Y},
                Next % Used to check if a merge is required
            }
    end.

moveTile(Cell, Vector, JsonData) ->

    Grid = proplists:get_value(grid, JsonData),
    Tile = grid:cellContent(Cell, Grid),

    case Tile =:= null of
        true -> JsonData;
        false ->
            { Farthest, Next } = findFarthestPosition(Cell, Vector, Grid),

            {struct, CurrJsonData} = Tile,
            CurrValue = proplists:get_value(value, CurrJsonData),

            NextTile = if
                Next =:= null -> null;
                true ->
                    grid:cellContent(Next, Grid)
            end,

            {NextValue, NextMerged} = if
                NextTile =:= null -> {null, null};
                true ->
                    NextJsonData = element(2, NextTile),
                    {proplists:get_value(value, NextJsonData), proplists:get_value(mergedFrom, NextJsonData)}
            end,

            if  CurrValue =:= NextValue,
                NextMerged =:= null
                ->
                    MergedValue = CurrValue * 2,
                    Merged = {
                        struct,
                        [
                            {value, MergedValue},
                            {mergedFrom, [Tile,NextTile]},
                            {previousPosition, null}
                        ]
                    },
                    NewGrid = grid:insertTile(Next, Merged, grid:removeTile(Cell, Grid)),

                    % Update the score
                    Score = proplists:get_value(score, JsonData) + MergedValue,

                    % The mighty 2048 tile
                    Won = if
                        MergedValue =:= 2048 -> true;
                        true -> false
                    end,

                    Removed = proplists:delete(score, proplists:delete(won, proplists:delete(grid, JsonData))),

                    [
                        {grid,NewGrid},
                        {won,Won},
                        {score,Score} |
                        Removed
                    ];
                true ->
                    [
                        {
                            grid,
                            grid:moveTile(Cell, Farthest, proplists:get_value(grid, JsonData))
                        }
                        | proplists:delete(grid, JsonData)
                    ]
            end
    end.

move(left, State) ->
    move(getVector(left), State);
move(right, State) -> 
    move(getVector(right), State);
move(up, State) -> 
    move(getVector(up), State);
move(down, State) -> 
    move(getVector(down), State);
move(Vector, State) ->
    {struct, JsonData} = State,

    case 
        proplists:get_value(over, JsonData) or (
            proplists:get_value(won, JsonData) and (not proplists:get_value(keepPlaying, JsonData))
        )
    of
        true -> State;
        _Else ->
            PreparedJsonData = updateBestScore(prepareTiles(JsonData)),

            { TraversalsX, TraversalsY } = buildTraversals(Vector),

            NewJsonData = process_travesals_y(
                TraversalsY,
                TraversalsX,
                Vector,
                PreparedJsonData
            ),

            if
                PreparedJsonData =/= NewJsonData -> %If changed - add new tile
                    Grid = proplists:get_value(grid, NewJsonData),
                    {struct, UserJsonData} = proplists:get_value(user, NewJsonData),

                    NewScore = proplists:get_value(score, NewJsonData),
                    Score = proplists:get_value(score, PreparedJsonData),

                    case NewScore > Score of true ->
                        db:insert(
                            proplists:get_value(score, NewJsonData),
                            proplists:get_value(id, UserJsonData)
                        );
                        _Else -> undefined
                    end,

                    Over = case movesAvailable(Grid) of
                        true -> false;
                        fale -> true % Game over!
                    end,
                    Removed = proplists:delete(grid, proplists:delete(over, NewJsonData)),
                    {struct,[{ grid, addRandomTile(Grid) }, { over, Over } | Removed ]};
                true -> %return state otherwise
                    {struct,PreparedJsonData}
            end
    end
.

movesAvailable(_) ->
    true.

updateBestScore(JsonData) ->
    [{ scores, db:select() } | proplists:delete(scores, JsonData) ].

Функция init — создает нового пользователя, если тот не был создан. Или берет из предыдущей игры.

init(State) ->

    StateUser = proplists:get_value(user, element(2, State)),
    StateUserJsonData = element(2, StateUser),

    User = case proplists:get_value(id, StateUserJsonData) of
        null ->
            Name = proplists:get_value(name, StateUserJsonData),
            {rowid, Id} = db:createUser(Name),
            { struct, [{name, Name},{id, Id}]};
        _Else ->
            StateUser
    end,

    {
        struct,
        [
            {grid ,addStartTiles(grid:build())},
            {user , User},
            {score,0},
            {scores, db:select()},
            {won, false},
            {over, false},
            {keepPlaying, false}
        ]
    }.

Основная функция — move. Отвечает за пересчет игрового поля. Здесь были труднощи, в основном из-зи недостатка опыта функционального программирования.

move(left, State) ->
    move(getVector(left), State);
move(right, State) -> 
    move(getVector(right), State);
move(up, State) -> 
    move(getVector(up), State);
move(down, State) -> 
    move(getVector(down), State);
move(Vector, State) ->
    {struct, JsonData} = State,

    case 
        proplists:get_value(over, JsonData) or (
            proplists:get_value(won, JsonData) and (not proplists:get_value(keepPlaying, JsonData))
        )
    of
        true -> State;
        _Else ->
            PreparedJsonData = updateBestScore(prepareTiles(JsonData)),

            { TraversalsX, TraversalsY } = buildTraversals(Vector),

            NewJsonData = process_travesals_y(
                TraversalsY,
                TraversalsX,
                Vector,
                PreparedJsonData
            ),

            if
                PreparedJsonData =/= NewJsonData -> %If changed - add new tile
                    Grid = proplists:get_value(grid, NewJsonData),
                    {struct, UserJsonData} = proplists:get_value(user, NewJsonData),

                    NewScore = proplists:get_value(score, NewJsonData),
                    Score = proplists:get_value(score, PreparedJsonData),

                    case NewScore > Score of true ->
                        db:insert(
                            proplists:get_value(score, NewJsonData),
                            proplists:get_value(id, UserJsonData)
                        );
                        _Else -> undefined
                    end,

                    Over = case movesAvailable(Grid) of
                        true -> false;
                        fale -> true % Game over!
                    end,
                    Removed = proplists:delete(grid, proplists:delete(over, NewJsonData)),
                    {struct,[{ grid, addRandomTile(Grid) }, { over, Over } | Removed ]};
                true -> %return state otherwise
                    {struct,PreparedJsonData}
            end
    end.

Например, чтобы узнать, совершился ли ход, я сравниваю старое состояние и новое. Не используется внешняя переменная как в JS варианте. Не знаю, уменьшит ли это производительность. И потом проверяю изменился ли счет, чтобы не делать лишних запросов к БД.
Вообще, при функциональном подходе, редко когда требуется передавать много параметров в функцию. Здесь наибольше меня смущает то, что я передаю TraversalsY, TraversalsX, Vector в process_travesals_y, хотя TraversalsY и TraversalsX и так зависят от Vector. Но решил пока оставить так.
Чтобы не повторять опыт «availableCells» функцию «process_travesals_y» я расписал больше, но теперь она отдельно идет по X и отдельно по Y. И в итоге для каждого ненулевого элемента игрового поля вызывает «moveTile». Которая, в принципе, практически полностью соответствует JS-оригиналу.

moveTile(Cell, Vector, JsonData) ->

    Grid = proplists:get_value(grid, JsonData),
    Tile = grid:cellContent(Cell, Grid),

    case Tile =:= null of
        true -> JsonData;
        false ->
            { Farthest, Next } = findFarthestPosition(Cell, Vector, Grid),

            {struct, CurrJsonData} = Tile,
            CurrValue = proplists:get_value(value, CurrJsonData),

            NextTile = if
                Next =:= null -> null;
                true ->
                    grid:cellContent(Next, Grid)
            end,

            {NextValue, NextMerged} = if
                NextTile =:= null -> {null, null};
                true ->
                    NextJsonData = element(2, NextTile),
                    {proplists:get_value(value, NextJsonData), proplists:get_value(mergedFrom, NextJsonData)}
            end,

            if  CurrValue =:= NextValue,
                NextMerged =:= null
                ->
                    MergedValue = CurrValue * 2,
                    Merged = {
                        struct,
                        [
                            {value, MergedValue},
                            {mergedFrom, [Tile,NextTile]},
                            {previousPosition, null}
                        ]
                    },
                    NewGrid = grid:insertTile(Next, Merged, grid:removeTile(Cell, Grid)),

                    % Update the score
                    Score = proplists:get_value(score, JsonData) + MergedValue,

                    % The mighty 2048 tile
                    Won = if
                        MergedValue =:= 2048 -> true;
                        true -> false
                    end,

                    Removed = proplists:delete(score, proplists:delete(won, proplists:delete(grid, JsonData))),

                    [
                        {grid,NewGrid},
                        {won,Won},
                        {score,Score} |
                        Removed
                    ];
                true ->
                    [
                        {
                            grid,
                            grid:moveTile(Cell, Farthest, proplists:get_value(grid, JsonData))
                        }
                        | proplists:delete(grid, JsonData)
                    ]
            end
    end.

На этом, думаю, рассказ об обработке websocket запросов посредством Erlang закончен. С удовольствием отвечу на все вопросы.

Автор: peinguin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js