Флаппи Бёрд: — Поехали

  • Tutorial

Это рассказ о том, как написать свою игру на Corona.
Уровень вхождения — минимальный (и ботаник с кафедры алгебры поймет).

Я напомню, что Corona — это движок для создания 2D игр на все платформы и, touch-touch, сегодня День космонавтики. Сюжет для игры выбран соответсвующий и, разумеется, мы повторяем за первым космонавтом
-Поехали!

Что получится за 2 часа программирования?


Вот что получилось у меня за 2 часа программирования.

Разумеется, на всю эту ботву у вас уйдет в 3 раза меньше времени, поскольку я программист линейный, как алгебра.


Узнали? Да, это игра адфззн ишкв из Вьетнама.

Напомню, что приложение приносило автору $50.000 в день. По-моему, неплохо.

Если присмотреться в моем клипе к осколкам столкновения птички со столбом, то видно надпись ХАБР. Эту картинку я использую в качестве текстуры для particle effect.

Установка инструмента Corona


Занимает 10 минут и прекрасно расписана во многих местах

Создание проекта gagarinbird


Запустите Corona на своей машине (у меня Mac) и выберите пункт меню Создать новый пустой проект.

В результате в указанном месте создается директория gagarinbird, в которой присутствует куча всевозможного полезного мусора, среди которого в дальнейшем мы будем редактировать один-единственный файл под названием
main.lua
Текстовый редактор — на ваше усмотрение. Язык программирования — Lua.

Прежде всего, нам понадобятся картинки и звуки для игры.

Как их заполучить, не мучая автора?
  • Скачиваем файл Flappy Bird [Dong Nguyen] (v1.2 LP os60)-Orbicos-ICPDA.rc309.ipa
  • Переименовываем в файл Flappy Bird [Dong Nguyen] (v1.2 LP os60)-Orbicos-ICPDA.rc309.zip
  • Разпаковываем файл Flappy Bird [Dong Nguyen] (v1.2 LP os60)-Orbicos-ICPDA.rc309.zip
  • Кликаем правой кнопкой мышки на файле Flap.app и выбираем пункт Show Package Content
  • Копируем картинки и звуки в нашу директорию gagarinbird


Для удобства сделайте поддиректорию gagarinbird/Assets и поместите сюда все картинки.
Аналогично делаем поддиректорию gagarinbird/Sounds и помещаем сюда все звуки.

Первые 2 строчки кода — проверка звуков Му


Вставьте в файл main.lua первые две строчки кода

dieSound = audio.loadSound( "Sounds/sfx_die.caf" )
audio.play( dieSound )

Нажмите в Corona (его многие называют Corona Simulator) 2 горячие кнопки /cmd/+R и наше приложение запустится в окне, похожем на телефон и издает звук смерти! Ай-ай, все работает.
Фон — девственно черный, но это не беда — мы только что научились играть любые звуки. Причем звучат они одинаково, что на Android, что на iPhone, что на (прости меня, Господи) Windows.

Загружаем звуки и оформляем все красиво


Делаем функцию loadSounds() и вызываем её

local function loadSounds()
  dieSound = audio.loadSound( "Sounds/sfx_die.caf" )
  hitSound = audio.loadSound( "Sounds/sfx_hit.caf" )
  pointSound = audio.loadSound( "Sounds/sfx_point.aif" )
  swooshingSound = audio.loadSound( "Sounds/sfx_swooshing.caf" )
  wingSound = audio.loadSound( "Sounds/sfx_wing.caf" )
  boomSound = audio.loadSound( "Sounds/sfx_boom.mp3" )
end

-- Start application point
loadSounds()

Загружаем картинку фона и обрабатываем tap-tap


Учимся рисовать картинку фона на весь экран телефона

  local function initBackGround()
  local ground = display.newImageRect( "Assets/ground.png", display.actualContentWidth, display.actualContentHeight )
  ground.x = display.contentCenterX
  ground.y = display.contentCenterY
end

-- Вызываем свежеиспеченную функцию
initBackGround()

Запустите (ctrl+R), получите примерно такую красивую картинку



Ура, теперь мы можем нарисовать любую картинку, разместить её в любом месте и трансформировать как хотим.

Например,

ground.rotation = 90

повернет картинку фона на 90 градусов.

Добавляем тыкание в экран.

  ground:addEventListener("tap", wing)

  local function wing()
    audio.play( wingSound )
  end

Мы добавили Listener, который ловит все нажатия на ground и вызывает при этом функцию wing().
Если посмотреть код, каждое тыкание в наш ground должно вызывать звук wing.caf.

Запускаем — все работает.

Физика полета и таймер


В Corona есть библиотека physics, с гравитацией и столкновениями. Но для нашей игры она избыточна. Добавление объектов (птицы, Земли и столбов) и настройка параметров потребует больше кода, чем простой запуск таймера (40 раз в секунду) с проверкой столкновений и динамики движения в гравитационном поле Земли. Земля в иллюминаторе. Пардон, отвлекся.

Сделаем динамику на языке машины времени.

-- запускаем таймер, который вызывается каждые 25 миллисекунд
gameLoopTimer = timer.performWithDelay( 25, gameLoop, 0 )

local function gameLoop()
    vBird = vBird + dt * g
    yBird = yBird + dt * vBird
 end 

40 раз в секунду вызывается наша функция gameLoop()

Динамика движения птицы записана в двух строках этой функции. Здесь g — ускорение свободного падения (для iPhone равна 800 пикселей в секунду за секунду)

dt = 0.025 — шаг по времени

uBird — скорость птицы по оси X
vBird — скорость птицы по оси Y

xBird — координата птицы по оси X
yBird — координата птицы по оси Y

Динамика движения столбов и поверхности еще проще — движение происходит лишь по оси X

  for i=1,3 do
      pipes[i].x = pipes[i].x + dt * uBird
  end
  

Состояния игры


Все кирпичи готовы, осталось все соединить в изящной быдло-программе. Итак, в нашей игре 4 состояния.

Состояние 0 (gameStatus=0) все замерло и готово к старту игры. Ждем нажатия на экран. При нажатии переходим в состояние 1.
Состояние 1 (gameStatus=1) птица летит, столбы движутся, гравитация работает. Нажатия на экран добавляют птице скорости строго вверх (vBird = jumpSpeed).
Состояние 2 (gameStatus=2) птица столкнулась с зеленым столбом и падает строго вниз, столбы стоят, гравитация работает. Нажатия на экран не влияет ни на что.
Состояние 3 (gameStatus=3) птица столкнулась с Землей, все замерло, подсчет очков и показ результата полета. Ожидаем нажатия для перехода в состояния 0.

В принципе, состояние 1 и 2 можно объединить, назначив в последнем горизонтальную скорость в 0, это дело вкуса.

Взмахи крыльями и спрайт-анимация


Наклон птицы пропорционален вектору скорости.
То есть равен арктангенсу угла.

  bird.rotation = math.atan(vBird/uBird)

Для анимации взмахов крыльями птицы при нажатии на экран используется спрайт-лист Corona. Это чуть сложнее, чем просто png картинка.



Сформируем кадры анимации в отдельный png файл. Размером 400 на 100 пикселей. Размер каждого внутреннего спрайта 100 на 100. То есть имеем 4 кадра.

Код инициализации птички следующий

local function setupBird()
  local options =
  {
      width = 100,
      height = 100,
      numFrames = 4,
      sheetContentWidth = 400,  -- width of original 1x size of entire sheet
      sheetContentHeight = 100  -- height of original 1x size of entire sheet
  }
  local imageSheet = graphics.newImageSheet( "Assets/bird.png", options )

  local sequenceData =
  {
      name="walking",
      start=1,
      count=3,
      time=300,
      loopCount = 2,   -- Optional ; default is 0 (loop indefinitely)
      loopDirection = "forward"    -- Optional ; values include "forward" or "bounce"
  }
  bird = display.newSprite( imageSheet, sequenceData )
  bird.x = xBird
  bird.y = yBird
end

Теперь, в момент нажатия на экран вызываем строчку кода

bird:play()

И птичка машет крыльями 2 раза подряд.

Столкновение и particle effect


Проверка столкновения элементарна.

Во-первых, проверка столкновения с поверхностью Земли

  if yBird>yLand then
      yBird = yLand
      crash()
  end

Во-вторых, проверка столкновения со столбами

  local function checkCollision(i)
  local dx = 40 -- взято из размеров картинки столба
  local dy = 50 -- взято из размеров картинки столба
  local boom = 0
  local x = pipes[i].x
  local y = pipes[i].y

  if xBird > (x-dx) and xBird < (x+dx) then
    if yBird > (y+dy) or yBird < (y-dy) then
      boom = 1
    end
  end
  return boom
end

Добавим particle effect в месте столкновения птицы со столбом.

Код выглядит громоздко, но меняя 20 параметров эффекта, можно получить удивительные взрывы, всполохи и огненные шары.

local function  setupExplosion()
  local dx = 31
  local p = "Assets/habra.png"
  local emitterParams = {
          startParticleSizeVariance = dx/2,
          startColorAlpha = 0.61,
          startColorGreen = 0.3031555,
          startColorRed = 0.08373094,
          yCoordFlipped = 0,
          blendFuncSource = 770,
          blendFuncDestination = 1,
          rotatePerSecondVariance = 153.95,
          particleLifespan = 0.7237,
          tangentialAcceleration = -144.74,
          startParticleSize = dx,
          textureFileName = p,
          startColorVarianceAlpha = 1,
          maxParticles = 128,
          finishParticleSize = dx/3,
          duration = 0.75,
          finishColorRed = 0.078,
          finishColorAlpha = 0.75,
          finishColorBlue = 0.3699196,
          finishColorGreen = 0.5443883,
          maxRadiusVariance = 172.63,
          finishParticleSizeVariance = dx/2,
          gravityy = 220.0,
          speedVariance = 258.79,
          tangentialAccelVariance = -92.11,
          angleVariance = -300.0,
          angle = -900.11
      }
      emitter = display.newEmitter(emitterParams )
      emitter:stop()
    end

Оставляю этот кусок кода без комментария — просто побалуйтесь в параметрами сами.

local function explosion()
  emitter.x = bird.x
  emitter.y = bird.y
  emitter:start()
end

Function explosion() вызываем в момент столкновения птички со столбом. Я не смог пикселизовать эффект в стиле всех картинок игры. Возможно у вас есть совет, как это сделать. Если что, то Scale не сработал.

Спасибо


Весь проект бесплатно без смс можно будет скачать с Corona Marketplace во время майских праздников. Код проходит модерацию.

Кроме прочего, я стал евангелистом Corona, получил первую зарплату и все это благодаря публикации на Хабре. Спасибо ресурсу и конечно вам, читатели. Алгебраистам тоже привет.

Код проекта


Код проекта в одном файле main.lua
-----------------------------------------------------------------------------------------
--
-- main.lua
--
-----------------------------------------------------------------------------------------

local gameStatus = 0

local yLand = display.actualContentHeight - 160
local hLand = 60
local xLand = display.contentCenterX

local yBird = display.contentCenterY-50
local xBird = display.contentCenterX-50

local wPipe = display.contentCenterX+10
local yReady = display.contentCenterY-140

local uBird = -200
local vBird = 0
local wBird = -320
local g = 800
local dt = 0.025

local score = 0
local bestScore = 0
local scoreStep = 5

local bird
local land
local title
local getReady
local gameOver
local emitter

local board
local scoreTitle
local bestTitle
local silver
local gold

local pipes = {}

local function loadSounds()
  dieSound = audio.loadSound( "Sounds/sfx_die.caf" )
  hitSound = audio.loadSound( "Sounds/sfx_hit.caf" )
  pointSound = audio.loadSound( "Sounds/sfx_point.aif" )
  swooshingSound = audio.loadSound( "Sounds/sfx_swooshing.caf" )
  wingSound = audio.loadSound( "Sounds/sfx_wing.caf" )
  boomSound = audio.loadSound( "Sounds/sfx_boom.mp3" )
end


local function calcRandomHole()
   return 60 + 20*math.random(10)
end

local function loadBestScore()
  local path = system.pathForFile( "bestscore.txt", system.DocumentsDirectory )

-- Open the file handle
  local file, errorString = io.open( path, "r" )

  if not file then
    -- Error occurred; output the cause
    print( "File error: " .. errorString )
  else
    -- Read data from file
    local contents = file:read( "*a" )
    -- Output the file contents
    bestScore = tonumber( contents )
    -- Close the file handle
    io.close( file )
end

file = nil
end

local function saveBestScore()
-- Path for the file to write
  local path = system.pathForFile( "bestscore.txt", system.DocumentsDirectory )
  local file, errorString = io.open( path, "w" )
  if not file then
    -- Error occurred; output the cause
    print( "File error: " .. errorString )
  else
      file:write( bestScore )
      io.close( file )
  end
  file = nil
end


local function setupBird()
  local options =
  {
      width = 70,
      height = 50,
      numFrames = 4,
      sheetContentWidth = 280,  -- width of original 1x size of entire sheet
      sheetContentHeight = 50  -- height of original 1x size of entire sheet
  }
  local imageSheet = graphics.newImageSheet( "Assets/bird.png", options )

  local sequenceData =
  {
      name="walking",
      start=1,
      count=3,
      time=300,
      loopCount = 2,   -- Optional ; default is 0 (loop indefinitely)
      loopDirection = "forward"    -- Optional ; values include "forward" or "bounce"
  }
  bird = display.newSprite( imageSheet, sequenceData )
  bird.x = xBird
  bird.y = yBird
end

local function prompt(tempo)
  bird:play()
end

local function initGame()
  score = 0
  scoreStep = 5
  title.text = score
  for i=1,3 do
    pipes[i].x = 400 + display.contentCenterX * (i-1)
    pipes[i].y =  calcRandomHole()
  end
  yBird = display.contentCenterY-50
  xBird = display.contentCenterX-50
  getReady.y = 0
  getReady.alpha = 1
  gameOver.y = 0
  gameOver.alpha = 0
  board.y = 0
  board.alpha = 0
  audio.play( swooshingSound )
  transition.to( bird, { time=300, x=xBird, y=yBird, rotation = 0 } )
  transition.to( getReady, { time=600, y=yReady, transition=easing.outBounce, onComplete=prompt   } )
end


local function wing()
  if gameStatus==0 then
    gameStatus=1
    getReady.alpha = 0
  end

  if gameStatus==1 then
    vBird = wBird
    bird:play()
    audio.play( wingSound )
  end

  if gameStatus==3 then
    gameStatus=0
    initGame()
  end
end

local function  setupExplosion()
  local dx = 31
  local p = "Assets/habra.png"
  local emitterParams = {
          startParticleSizeVariance = dx/2,
          startColorAlpha = 0.61,
          startColorGreen = 0.3031555,
          startColorRed = 0.08373094,
          yCoordFlipped = 0,
          blendFuncSource = 770,
          blendFuncDestination = 1,
          rotatePerSecondVariance = 153.95,
          particleLifespan = 0.7237,
          tangentialAcceleration = -144.74,
          startParticleSize = dx,
          textureFileName = p,
          startColorVarianceAlpha = 1,
          maxParticles = 128,
          finishParticleSize = dx/3,
          duration = 0.75,
          finishColorRed = 0.078,
          finishColorAlpha = 0.75,
          finishColorBlue = 0.3699196,
          finishColorGreen = 0.5443883,
          maxRadiusVariance = 172.63,
          finishParticleSizeVariance = dx/2,
          gravityy = 220.0,
          speedVariance = 258.79,
          tangentialAccelVariance = -92.11,
          angleVariance = -300.0,
          angle = -900.11
      }
      emitter = display.newEmitter(emitterParams )
      emitter:stop()
    end


local function explosion()
  emitter.x = bird.x
  emitter.y = bird.y
  emitter:start()
end



local function crash()
  gameStatus = 3
  audio.play( hitSound )
  gameOver.y = 0
  gameOver.alpha = 1
  transition.to( gameOver, { time=600, y=yReady, transition=easing.outBounce } )
  board.y = 0
  board.alpha = 1
  if score>bestScore then
    bestScore = score
    saveBestScore()
  end
  bestTitle.text = bestScore
  scoreTitle.text = score
  if score<10 then
    silver.alpha = 0
    gold.alpha = 0
  elseif score<50 then
    silver.alpha = 1
    gold.alpha = 0
  else
    silver.alpha = 0
    gold.alpha = 1
  end
  transition.to( board, { time=600, y=yReady+100, transition=easing.outBounce } )
end

local function collision(i)
  local dx = 40 -- horizontal space of hole
  local dy = 50 -- vertical space of hole
  local boom = 0
  local x = pipes[i].x
  local y = pipes[i].y

  if xBird > (x-dx) and xBird < (x+dx) then
    if yBird > (y+dy) or yBird < (y-dy) then
      boom = 1
    end
  end
  return boom
end

local function gameLoop()
  local eps = 10
  local leftEdge = -60
  if gameStatus==1 then
    xLand = xLand + dt * uBird
    if xLand<0 then
      xLand = display.contentCenterX*2+xLand
    end
    land.x = xLand
    for i=1,3 do
      local xb = xBird-eps
      local xOld = pipes[i].x
      local x = xOld + dt * uBird
      if x<leftEdge then
        x = wPipe*3+x
        pipes[i].y =  calcRandomHole()
      end
      if xOld > xb  and x <= xb then
        score = score + 1
        title.text = score
        if score==scoreStep then
          scoreStep = scoreStep + 5
          audio.play( pointSound )
        end
      end
      pipes[i].x = x
      if collision(i)==1 then
        explosion()
        audio.play( dieSound )
        gameStatus = 2
      end
    end
  end

  if gameStatus==1 or gameStatus==2 then
    vBird = vBird + dt * g
    yBird = yBird + dt * vBird
    if yBird>yLand-eps then
      yBird = yLand-eps
      crash()
    end
    bird.x = xBird
    bird.y = yBird
    if gameStatus==1 then
      bird.rotation =  -30*math.atan(vBird/uBird)
    else
      bird.rotation = vBird/8
    end
  end
end

local function setupLand()
  land = display.newImageRect( "Assets/land.png", display.actualContentWidth*2, hLand*2 )
  land.x = xLand
  land.y = yLand+hLand
end

local function setupImages()
  local ground = display.newImageRect( "Assets/ground.png", display.actualContentWidth, display.actualContentHeight )
  ground.x = display.contentCenterX
  ground.y = display.contentCenterY
  ground:addEventListener("tap", wing)

  for i=1,3 do
    pipes[i] = display.newImageRect( "Assets/pipe.png", 80, 1000 )
    pipes[i].x = 440 + wPipe * (i-1)
    pipes[i].y = calcRandomHole()
  end

  getReady = display.newImageRect( "Assets/getready.png", 200, 60 )
  getReady.x = display.contentCenterX
  getReady.y = yReady
  getReady.alpha = 0

  gameOver = display.newImageRect( "Assets/gameover.png", 200, 60 )
  gameOver.x = display.contentCenterX
  gameOver.y = 0
  gameOver.alpha = 0

  board = display.newGroup()
  local img = display.newImageRect(board, "Assets/board.png", 240, 140 )

  scoreTitle = display.newText(board, score, 80, -18, "Assets/troika.otf", 21)
  scoreTitle:setFillColor( 0.75, 0, 0 )
  bestTitle = display.newText(board, bestScore, 80, 24, "Assets/troika.otf", 21)
  bestTitle:setFillColor( 0.75, 0, 0 )

  silver = display.newImageRect(board, "Assets/silver.png", 44, 44 )
  silver.x = -64
  silver.y = 4

  gold = display.newImageRect(board, "Assets/gold.png", 44, 44 )
  gold.x = -64
  gold.y = 4

  board.x = display.contentCenterX
  board.y = 0
  board.alpha = 0

  local txt = {
    x=display.contentCenterX, y=10,
    text="",
    font="Assets/troika.otf",
    fontSize=35 }

  title = display.newText(txt)
  title:setFillColor( 1, 1, 1 )
end


-- Start application point
loadSounds()
setupImages()
setupBird()
setupExplosion()
setupLand()
initGame()
loadBestScore()
gameLoopTimer = timer.performWithDelay( 25, gameLoop, 0 )


Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 29
  • +3
    Каждый программист должен написать свой текстовой редактор Flappy Bird
    • +2

      Спасибо за статью. А почему не захотели вылить исходники на гитхаб?

      • –1
        Пароль забыл от гитхаба. Вспомню -выложу.
      • 0
        За два часа при знании работы с канвас, частицами, спрайтами и прочим?
        2 часа, насетапать окружение не хватит :)
        • 0
          Насколько я понимаю, имеется ввиду за два часа чистого программирования.
          Там особого окружения никакого нет, основная проблема — какой редактор выбрать. Тоже в свое время остановился на Atom, как автор (видно иконку на скриншоте).
          • 0
            Так ведь в статье написано:
            Вот что получилось у меня за 2 часа программирования.

            По сути, могу подтвердить такой временной отрезок.

            Вообще, лучше пройти мануал на сайте короны (видел где-то в статье ссылку) и потом уже браться за свой фалафенберд, но вполне реально за выходные например настругать пару апп, понимая при этом, что и как ты делаешь. В этом и фишка короны.

            Как написал varton86, окружение — это Corona Simulator скачать бесплатно без смс молоденькие киски блокируют телеграм и редактор кода на выбор.
          • 0
            "… я стал евангелистом Corona, получил первую зарплату и все это благодаря публикации на Хабре."

            Это как? Зарплату, как евангелист, или за код на маркете? Или евангелист за пост на Хабре?
            • +1
              Код на маркете бесплатный. Сижу на окладе, никого не трогаю, починяю примус. Кстати, я стесняюсь спросить, что такое евангелист (в терминах software), может кто тихонько разъяснит?
              • 0
                Наверное, это то же самое, что и адепт (в тех же терминах).
                • 0

                  Евангелист — это чувак, который самозабвенно продвигает какую-то конкретную технологию/продукт и призывает всех ей пользоваться, расписывает плюсы, строит стратегию развития. В крупных компаниях, создающих какой-то продукт для других разработчиков, евангелиста могут держать на окладе, т.е. это такой же нужный сотрудник, как и разработчик.

              • +1
                Рисование совы, ей богу.
                • +1
                  Весь код -200 строк утяжелит статью. Поместить main.lua под спойлер?
                  • 0
                    Я скорее о том, что для новичков (а как мне кажется, именно на них и направлены подобные статьи) Важно не столько просто скопировать куски кода, сколько понять, как они вообще работают, откуда взялось то или иное слово, переменная, функция, оператор, что оно делает и тд и тп.

                    Потому как для меня
                    • 0
                      Доброго времени суток. Если возможно, хотя бы здесь хотелось бы увидеть код целиком. Под спойлер, на гит, как угодно. Спасибо;)
                      • +1
                        Добавил.
                        • 0
                          Моё почтение. Спасибо за статью, весьма познавательно.
                  • +3
                    Если присмотреться в моем клипе к осколкам столкновения птички со столбом, то видно надпись ХАБР.
                    Чтобы не мучать ютюб на скорости проигрывания 0,25

                    • +1
                      В самом деле видно. Ваше зрение бесподобно!
                    • 0
                      На самом деле, фишка флеппи бёрда в экспоненциальном росте скорости падения. Я сам делал клон, и с VerticalVelocity = JumpVelocity — a^t (t — время с момента прыжка), при правильно подобранном a, получался, по ощущениям, идентичный оригиналу эффект.
                      • +3
                        Это не так. Гравитация в оригинале честная — просто вы неправильно нашли g. Я с Нгуеном жил в общаге МГУ три года в одной комнате, знаю точно.
                      • +1

                        Узнал автора по стилю. Люблю луа, люблю корону, спасибо за статью!

                        • +1
                          мы только что научились играть любые звуки. Причем звучат они одинаково, что на Android, что на iPhone, что на (прости меня, Господи) Windows.


                          Согласно таблице из официальной документации формат .caf работает только на iOS/macOS, не сталкивались ли вы с этим в реальном проекте?

                          Как я понимаю нужно держать музыку в двух форматах для разных систем (или использовать mp3)
                          • 0
                            Честно говоря, *.caf не проверял на Windows/Android. Спрошу, заодно html5. Последнее работает отлично, пока бета-версия.
                          • 0
                            Норм.статья, спс!
                            PapaBubaDiop, напишешь как-нибудь про Корону в связке с Zerobrane Studio?
                            Zerobrane это самый лучший редактор для этих дел :))
                            • 0
                              Спасибо за наводку — Atom мне не очень нравится работой с автоподсказками и поиском. Зато у него есть отличное качество прятать тело функции под спойлер — код выглядит лаконичным и ясным.
                          • 0
                            PapaBubaDiop можешь пожалуйста у команды Corona попросить примеры продвижения успешных игр? Где рекламировались, сколько тратили, какая эффективность тех или иных платных/бесплатных источников трафика.
                            На Corona SDK же оч.много успешных проектов, наверняка есть интересная информация.
                            • +1
                              Готовлю статью. В мае опубликую.
                              • 0
                                ждем с нетерпением :)

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

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