2048 на Erlang

    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 чтобы увидеть общий рейтинг. Выглядит это так.



    Использовать родной 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,
    
        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
                ),
    
                NewGrid = proplists:get_value(grid, NewJsonData),
                Grid = proplists:get_value(grid, PreparedJsonData),
    
                if
                    NewGrid =/= Grid -> %If changed - add new tile
                        
                        {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(NewGrid) of
                            true -> false;
                            fale -> true % Game over!
                        end,
                        Removed = proplists:delete(grid, proplists:delete(over, NewJsonData)),
                        {struct,[{ grid, addRandomTile(NewGrid) }, { 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
                ),
    
                NewGrid = proplists:get_value(grid, NewJsonData),
                Grid = proplists:get_value(grid, PreparedJsonData),
    
                if
                    NewGrid =/= Grid -> %If changed - add new tile
                        
                        {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(NewGrid) of
                            true -> false;
                            fale -> true % Game over!
                        end,
                        Removed = proplists:delete(grid, proplists:delete(over, NewJsonData)),
                        {struct,[{ grid, addRandomTile(NewGrid) }, { 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 закончен. С удовольствием отвечу на все вопросы.
    Поделиться публикацией

    Комментарии 19

      +1
      Не хватает лучшего результата, достигнутого ботом. Или проглядел?
        +10
        Суть в том, что бота я еще не писал. Только сервер закончил, где он играть будет =)
        +7
        Я создал специльный модуль «starter», который запускает все зависимости и приложение.

        Обычно, когда используется такой ручной старт (без релиза) своего приложения и зависимостей, то эту функцию start/0 включают в модуль приложения — erl2048_app.

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

        Из этого файла rebar создает .app файл, необходимый для запуска приложения через application:start/1.

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

        В вашем примере супервизор ни за чем не следит, т.к. следить не за чем. Непосредственно в вашем приложении нет никаких процессов — вся логика в приложении cowboy.
          0
          Спасибо. Теперь многое стало на свои места.
          0
          Чем вас mnesia не устроила в качестве DB?
            +1
            На самом деле — без разницы. И я уверен, что нужный интерфейс можно без проблем реализовать для любой СУБД.
            Может, я просто зажал ОЗУ. Постоянно на моем VPS не хватает. Поэтому стараюсь использовать postgres для всех проектов.
            Но в данной статье я хотел акцентировать внимание на логике, а не базе данных.
              +1
              Просто mnesia — часть OTP. Для того, чтобы ее использовать, не надо ничего писать, настраивать и устанавливать.
              0
              mnesia — это синоним слова «боль».
                0
                Что с ней не так? При использовании для хранения рекордов пользователей-то?
                  +1
                  В таких простых случаях обычно хватает ets/dets. Лично у меня остались крайне негативные впечатления от мнезии.
              0
              следующим шагом можно сделать, внедрение супервизоров, и по процессу на пользователя.
                0
                Если не ошибаюсь, ковбой и так делает отдельный процесс на пользователя.
                +2
                Поздравляю с первым проектом, но код конечно оставляет не лучшие впечатления. Книжечку бы вам почитать какую-нибудь.

                erl2048_app:start/2 должна возвращать Pid корневого супервизора приложения, у вас же возвращается Pid подключения к БД. Это очень плохо, т.к. будет некорректно работать application:stop и не будет детектиться падение процессов приложения.

                Вместо
                start() ->
                	application:start(ranch),
                	application:start(crypto),
                	application:start(cowlib),
                	application:start(cowboy),
                	application:start(inets),
                	application:start(mochiweb),
                	application:start(erl2048).
                

                лучше написать
                start() ->
                    % генератор списков
                    [application:start(App) || App <- [ranch,crypto, cowlib, cowboy,inets,  mochiweb, erl2048]].
                

                (причем inets, mochiweb вряд ли реально нужно стартовать) но более правильный подход — указать все эти приложения в том самом app.src файле и использовать функцию application:ensure_all_started/1

                % file: erl2048.app.src
                {applications, [
                    kernel,
                    stdlib,
                    ranch,crypto, cowlib, cowboy
                ]},
                % file: erl2048_app.erl
                start() -> application:ensure_all_started(erl2048).
                


                Как уже сказали, start/0 лучше перенести в erl2048_app.
                Для запуска обычно так делают:
                erl -pa ../erl2048/ebin -pa deps/*/ebin -s erl2048_app start -noshell -detached
                

                (см -s erl2048_app start).
                  0
                  На самом деле был бы очень признателен, если что-нибудь посоветуете. Потому что мне трудно было хоть что-то найти.
                    0
                    Посоветую что? Не понял вопроса.
                0
                Удивительно как игра заинтересовала всех. Я также не удержался и сделал свою копию, но больше по тому что в оригинале нельзя было играть на моем windows phone 7. А также хотелось испробовать AngularJS. Написал игру с нуля честно не подсматривая в исходники. Рейтинг кстати я сделал для контакта но к сожалению мое приложение не одобрили так как к тому времени когда я его доделал уже две игры находилось в каталоге :(
                  0
                  Ну вы можете отравить pull request для WP7.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое