---
title: "Как я написал программу шортсогенератор"
date: "2024-10-08"
---

<script>
  import ArticleTitle from "$lib/components/ArticleTitle.svelte"
  import MaterialSymbolsErrorCircleRoundedOutline from '~icons/material-symbols/error-circle-rounded-outline';
  import Tooltip from "$lib/components/Tooltip.svelte"
</script>

<ArticleTitle metadata={metadata} />

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

Он увидел [такое видео](https://www.youtube.com/shorts/iMBJH3Eo8M0) и вбил его в [Socialblade](https://socialblade.com/youtube/channel/UCBcGha5FjnmsaOhMJJeXtIQ).
У автора приблизительный доход -- **1.5 \- 24 (к$/мес)**.

<img
  src="/images/blog/shorts/ball-escape.avif"
  alt="Скриншот видео, которое мы будем имитировать"
  class="max-h-[400px] mx-auto"
/>

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

Друг ко мне обратился, потому что знал, что я когда-то <Tooltip><MaterialSymbolsErrorCircleRoundedOutline
  class='inline'
/><div slot="title">{new Date().getFullYear()-2015} лет назад</div></Tooltip> [делал игры](https://games.kopyl.dev).
Чтобы не терять время на изучение нового игрового движка, я решил освежить свои навыки Lua.

Музыкальных навыков у меня нет, но я знаю про [формат Midi](https://ru.wikipedia.org/wiki/MIDI) и догадываюсь, что получить
нужную аудиодорожку можно, если воспроизводить ноты синхронно с ударами мяча.

Возвращаясь к графической части, инструмент, на который в младенчестве пал мой выбор -- [LÖVE](https://love2d.org).
Фреймворк с очень низким порогом вхождения, позволяющий создавать 2D игры на Lua.

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

## Графика

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

```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.update()` -- выполняется каждый кадр,
- `love.draw()` -- выполняется каждый кадр, отвечает за отрисовку графики.

### `love.load()`

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

```lua
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
```

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

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

### `love.update()`

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

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

```lua
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º
и как будто пролетит сквозь препятствие.

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

```lua
function love.update(dt)
  ball.collided = false -- [!code highlight]

  -- Гравитация
  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 -- [!code highlight]
        local collided, impactX, impactY = isCollidingWithLine(ball, arc)
        if collided then
          arc.isAlive = false
          ball.collided = true -- [!code highlight]

          -- Рассчитываем нормаль к точке столкновения
          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()`

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

```lua
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",
          windowW/2,
          windowH/2,
          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
```

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

<iframe
  style='width: 100%; aspect-ratio: 1; border-radius: 8px; margin-block: 16px; cursor: pointer;'
  src="/game/index.html"
></iframe>

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

## Звук

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

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

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

- Note Off,
- Note On,
- Poly Key Pressure,
- Controller Change,
- Program Change,
- Channel Pressure,
- Pitch Bend.

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

```python
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.

![](/images/blog/shorts/garage-band.avif)

## Результат

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

<video
  class="max-h-[400px] mx-auto"
  src="/images/blog/shorts/tetris.webm"
  controls
></video>
