New article
All checks were successful
Deploy / build-and-publish (push) Successful in 1m48s
Deploy / deploy (push) Successful in 13s

This commit is contained in:
2024-10-08 02:15:02 +03:00
parent 4d15eb0112
commit 93f92efefc
17 changed files with 914 additions and 11 deletions

View File

@@ -0,0 +1,45 @@
<script lang="ts">
let isHovered = false;
let x: number;
let y: number;
function mouseOver(event: MouseEvent) {
isHovered = true;
x = event.pageX + 5;
y = event.pageY + 5;
}
function mouseMove(event: MouseEvent) {
x = event.pageX + 5;
y = event.pageY + 5;
}
function mouseLeave() {
isHovered = false;
}
</script>
<span
on:mouseover={mouseOver}
on:mouseleave={mouseLeave}
on:mousemove={mouseMove}
>
<slot />
</span>
{#if isHovered}
<div
style="top: {y}px; left: {x}px;"
class="tooltip"
>
<slot name="title"></slot>
</div>
{/if}
<style>
.tooltip {
position: absolute;
padding: 8px 16px;
background-color: theme(colors.slate.900);
border: 1px solid theme(colors.slate.500);
border-radius: 8px;
}
</style>

View File

@@ -13,28 +13,49 @@ import Navbar from "$lib/components/Navbar.svelte";
</article>
<style lang="postcss">
:global(h1) {
:global(article h1) {
@apply text-5xl font-semibold my-6;
}
:global(h2) {
:global(article h2) {
@apply text-3xl my-5;
}
:global(h3) {
:global(article h3) {
@apply text-2xl my-4;
}
:global(h4) {
:global(article h4) {
@apply text-xl my-3;
}
:global(pre) {
:global(article ul) {
margin-block: 16px;
}
:global(article ul li) {
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221em%22%20height%3D%221em%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22%23C3E88D%22%20d%3D%22M12%2021q-.425%200-.712-.288T11%2020v-5.6l-3.95%203.975q-.3.3-.712.3t-.713-.3t-.3-.712t.3-.713L9.6%2013H4q-.425%200-.712-.288T3%2012t.288-.712T4%2011h5.6L5.625%207.05q-.3-.3-.3-.712t.3-.713t.713-.3t.712.3L11%209.6V4q0-.425.288-.712T12%203t.713.288T13%204v5.6l3.95-3.975q.3-.3.713-.3t.712.3t.3.713t-.3.712L14.4%2011H20q.425%200%20.713.288T21%2012t-.288.713T20%2013h-5.6l3.975%203.95q.3.3.3.713t-.3.712t-.712.3t-.713-.3L13%2014.4V20q0%20.425-.288.713T12%2021%22%2F%3E%3C%2Fsvg%3E");
list-style-position: outside;
margin-left: 16px;
margin-bottom: 8px;
}
:global(article ul li::marker) {
margin-right: 16px;
}
:global(article img, article video) {
margin-block: 24px;
border-radius: 8px;
overflow: hidden;
}
:global(article pre) {
@apply p-4 !my-4 overflow-auto rounded-lg relative;
max-width: calc(100vw - 16px);
}
:global(pre code) {
:global(article pre code) {
min-width: calc(100% - 16px);
@apply p-0 relative inline-block;
@@ -43,20 +64,20 @@ import Navbar from "$lib/components/Navbar.svelte";
}
}
:global(code) {
:global(article code) {
background: #263238;
color: #C3E88D;
@apply px-2 py-0.5 rounded;
}
:global(code .highlighted) {
:global(article code .highlighted) {
background: #334752;
display: inline-block;
width: calc(100% + 32px);
@apply px-4 -mx-4;
}
:global(code .diff.add) {
:global(article code .diff.add) {
background: #526a4f;
display: inline-block;
width: calc(100% + 48px);

View File

@@ -1,7 +1,12 @@
import {metadata as htmlInCssMetadata} from "./html-in-css/+page.svx"
import {metadata as thisBlogMetadata} from "./this-blog/+page.svx"
import {metadata as shortsMetadata} from "./shorts/+page.svx"
const posts = [
{
href: '/blog/shorts',
...shortsMetadata
},
{
href: '/blog/this-blog',
...thisBlogMetadata

View File

@@ -0,0 +1,361 @@
---
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>

View File

@@ -1,5 +1,5 @@
---
title: "Как сделан этот блог?"
title: "Как сделать блог на Svelte + MDX?"
date: "2024-10-04"
---