2024-10-08

Как я написал программу шортсогенератор

На днях мне позвонил давний друг. На тот момент я спал, и его звонок застал меня врасплох, особенно учитывая, что он начал пытаться объяснить мне, как заработать на создании шортсов на Youtube.

Он увидел такое видео и вбил его в Socialblade. У автора приблизительный доход — 1.5 - 24 (к$/мес).

Скриншот видео, которое мы будем имитировать

Я давно разочаровался в методах заработка, которые вращаются вокруг того, что надо стать знаменитым в интернете. Но я сказал, что посмотрю и поразбираюсь в том, как можно было бы сделать такое видео, но Youtube каналом заниматься не буду. Если честно, то я не очень-то и рассчитывал всерьез этим заниматься, но я уже проснулся, спать было неохота, и я уселся думать над тем, как можно повторить такую симуляцию.

Друг ко мне обратился, потому что знал, что я когда-то делал игры. Чтобы не терять время на изучение нового игрового движка, я решил освежить свои навыки Lua.

Музыкальных навыков у меня нет, но я знаю про формат Midi и догадываюсь, что получить нужную аудиодорожку можно, если воспроизводить ноты синхронно с ударами мяча.

Возвращаясь к графической части, инструмент, на который в младенчестве пал мой выбор — LÖVE. Фреймворк с очень низким порогом вхождения, позволяющий создавать 2D игры на Lua.

Единственная его проблема — сообщество очень любит «велосипеды». Библиотек по пальцам пересчитать, и пакета способного воспроизводить Midi нет и в помине. Но я решил разбираться с проблемами по порядку и взялся за то, что точно могу сделать — графику.

Графика

Создадим файл main.lua и начнем с перечисления констант, которые будут задавать поведение симуляции:

local windowW = 720
local windowH = 720
local centerX = windowW / 2
local centerY = windowH / 2

local circleCount = 6 -- Количество окружностей
local radiusIncrement = 50 -- Расстояние между окружностями
local circleResolution = 36 -- Сколько градусов приходится на один сегмент
local gravity = 180

local circles = {}
local ball = {
  x = centerX,
  y = centerY,
  radius = 7,
  speedX = 100 * love.math.random() - 50, -- Дадим случайную начальную скорость мячику
  speedY = 100 * love.math.random() - 50,
  color = {1, 1, 1} -- Белый
}

Любая игра на LÖVE разделена на 3 основные функции:

love.load()

В функции love.load() создадим нужные нам окружности. Они состоят из арок, и, чтобы арки на внешних окружностях не были слишком большими, умножаем разрешение окружностей на их номер по порядку от центра.

function love.load()
  love.window.setMode(720, 720, {highdpi = true})

  -- Создаем несколько окружностей
  for i = 1, circleCount do
    local radius = radiusIncrement * i
    local angleStart = 0
    local angleEnd = 360
    local segments = {}
    local boostedResolution = circleResolution / i

    -- Заполняем окружности сегментами, в соответствии с разрешением
    for angle = angleStart, angleEnd - boostedResolution, boostedResolution do
      local angleRadStart = math.rad(angle)
      local angleRadEnd = math.rad(angle + boostedResolution)

      local startX = centerX + radius * math.cos(angleRadStart)
      local startY = centerY + radius * math.sin(angleRadStart)
      local endX = centerX + radius * math.cos(angleRadEnd)
      local endY = centerY + radius * math.sin(angleRadEnd)

      segments[#segments + 1] = {
        startX = startX,
        startY = startY,
        endX = endX,
        endY = endY,
        angleStart = angle,
        angleEnd = angle + boostedResolution,
        isAlive = true
      }
    end

    circles[i] = {
      -- У каждой окружности будет случайный цвет
      color = {
        love.math.random(),
        love.math.random(),
        love.math.random()
      },
      segments = segments
    }
  end
end

Получаем структуру:

circles = {
  {
    color = rgba,
    segments = {
      {
        startX = number,
        startY = number,
        endX = number,
        endY = number,
        angleStart = number,
        angleEnd = number,
        isAlive = boolean
      },
      ...
    }
  },
  ...
}
-- Я понятия не имею как описывать типы в Lua

love.update()

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

Для этого сначала опишем функцию поиска пересечения окружности и линии. В роли окружности будет наш мячик, а в роли линии сегмент круга. Конечно, сегмент, на самом деле, не прямая линия, а арка, но никто не заметит, что при столкновении он ведет себя как линия.

function isCollidingWithLine(ball, arc)
  local function pointToLineDistance(px, py, ax, ay, bx, by)
    local abx = bx - ax
    local aby = by - ay
    local apx = px - ax
    local apy = py - ay

    local ab2 = abx * abx + aby * aby
    local ap_ab = apx * abx + apy * aby
    local t = ap_ab / ab2

    t = math.max(0, math.min(t, 1))

    local nearestX = ax + t * abx
    local nearestY = ay + t * aby

    local distX = px - nearestX
    local distY = py - nearestY

    return math.sqrt(distX * distX + distY * distY), nearestX, nearestY
  end

  local distToSegment, nearestX, nearestY = pointToLineDistance(
    ball.x,     ball.y,
    arc.startX, arc.startY,
    arc.endX,   arc.endY
  )

  if distToSegment < ball.radius then
    return true, nearestX, nearestY
  end

  return false
end

Может произойти так, что наш шарик одновременно пересечет 2 сегмента. Если никак от этого не защититься, то получится, что он 2 раза за кадр поменяет свое направление на 180º и как будто пролетит сквозь препятствие.

Поэтому храним информацию о том, сталкивался ли уже мячик с чем-то и один раз в кадр даем ему это сделать.

function love.update(dt)
  ball.collided = false

  -- Гравитация
  ball.speedY = ball.speedY + gravity * dt

  ball.speedY = math.min(ball.speedY, maxSpeed)
  ball.speedX = math.min(ball.speedX, maxSpeed)

  ball.x = ball.x + ball.speedX * dt
  ball.y = ball.y + ball.speedY * dt

  -- Проверяем столкновения мячика и окружностей
  for i, circle in ipairs(circles) do
    for j, arc in ipairs(circle.segments) do
      if arc.isAlive and not ball.collided then
        local collided, impactX, impactY = isCollidingWithLine(ball, arc)
        if collided then
          arc.isAlive = false
          ball.collided = true

          -- Рассчитываем нормаль к точке столкновения
          local normalX = ball.x - impactX
          local normalY = ball.y - impactY
          local normalLength = math.sqrt(normalX * normalX + normalY * normalY)
          normalX = normalX / normalLength
          normalY = normalY / normalLength

          -- Отражаем скорость относительно нормали
          local dotProduct = ball.speedX * normalX + ball.speedY * normalY
          ball.speedX = ball.speedX - 2 * dotProduct * normalX
          ball.speedY = ball.speedY - 2 * dotProduct * normalY

          -- Увеличиваем скорость после столкновения, так веселее
          ball.speedX = ball.speedX * 1.1
          ball.speedY = ball.speedY * 1.1
        end
      end
    end
  end
end

love.draw()

Ну и, наконец, отрисовка

function love.draw()
  -- Окружности
  for i, circle in ipairs(circles) do
    for _, arc in ipairs(circle.segments) do
      if arc.isAlive then
        love.graphics.setColor(circle.color)
        love.graphics.arc(
          "line",
          "open",
          centerX,
          centerY,
          radiusIncrement * i,
          math.rad(arc.angleStart),
          math.rad(arc.angleEnd)
        )
      end
    end
  end

  -- Мячик
  love.graphics.setColor(ball.color)
  love.graphics.circle("fill", ball.x, ball.y, ball.radius)
end

Добавим немного эффектов и получаем такой результат:

Клик по окну перезапускает «игру».

Звук

Раз возможности воспроизводить Midi файлы из самой программы у нас нет, напишем отдельный скрипт на Python, который будет за это отвечать. Благо, в экосистеме Питона нет недостатка готовых библиотек.

Принцип в том, что графическая часть будет писать в файл время каждого удара, а скрипт на питоне можно будет потом запустить и получить звук, который будет синхронизирован с видеорядом, если мы таковой записали с экрана.

Midi файл представляет собой очень плотно упакованный бинарный файл, который просто так, как JSON не откроешь, но если его расшифровать, то он состоит из событий. Они делятся на несколько типов:

Нас интересует «Note On». Их мы будем поочередно задерживать до следующего таймстампа, а остальные события будем просто пропускать без задержки.

import time
import mido

timestamps = [
  # Вставляем сюда список таймстампов
]

midi_file = mido.MidiFile('midis/tetris.mid')

print(mido.get_output_names()) # Выводим список Midi устройств
port = mido.open_output('IAC Driver Bus 1') # Вводим сюда имя нужного нам устройства

def play_midi_file(midi_file, timestamps):
  current_time = 0
  index = 0

  for msg in midi_file.play():
    print(msg)

    if (msg.type == 'note_on'):
      timestamp = timestamps[index]
      time.sleep(timestamp - current_time)
      current_time = timestamp
      index += 1

    port.send(msg) # Воспроизводим звук

play_midi_file(midi_file, timestamps)

Этот скрипт можно воспринимать, как будто мы подключили к компьютеру клавиатуру, и скрипт играет на ней выбранную нами песню в нужном нам темпе. Проблема в том, что если вы подключите физическую клавиатуру к компьютеру и начнете нажимать по клавишам, то вы ничего не услышите. Компьютеру неоткуда знать, что делать с этим вводом.

Чтобы услышать что-то при запуске скрипта, надо чтобы на компьютере был запущен виртуальный синтезатор. В моем случае это Garage Band.

Результат

Теперь можно взять запись экрана первой программы и совместить ее с записью звука в вашем любимом видеоредакторе. На выходе получаем такой довольно залипательный шортс/рилс/тикток: