squad-proto/src/render/fx.cpp

291 lines
14 KiB
C++
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#include "render/fx.h"
#include "render/sprites.h"
namespace
{
// Цвета эффектов: аддитивные, поэтому альфа 0 (см. FXC).
constexpr uint32_t COL_TRACER_TAIL = FXC(120, 70, 20);
constexpr uint32_t COL_MUZZLE = FXC(255, 225, 150);
constexpr uint32_t COL_SPARK_WALL = FXC(255, 190, 90);
constexpr uint32_t COL_SPARK_FLESH = FXC(220, 40, 40);
constexpr uint32_t COL_SPARK_KILL = FXC(255, 120, 60);
// Пурпурная искра — не украшение, а индикатор: попадание по своему.
// В норме её не должно появиться НИ РАЗУ (критерий приёмки 11.2).
constexpr uint32_t COL_SPARK_ALLY = FXC(255, 60, 255);
// Ближний бой: холодная сталь на замахе, раскалённый след на ударе.
constexpr uint32_t COL_BLADE_COLD = FXC(70, 110, 165);
constexpr uint32_t COL_BLADE_EDGE = FXC(255, 250, 235);
constexpr uint32_t COL_BLADE_TAIL = FXC(120, 165, 235);
constexpr uint32_t COL_SPARK_MELEE = FXC(255, 165, 95);
// Оборванный взмах — тусклая серая вспышка: свой влез под лезвие.
constexpr uint32_t COL_SPARK_ABORT = FXC(150, 150, 170);
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Обстановка: пыльная щепа, а не кровь. Тусклее любого попадания по телу —
// игрок обязан по одному цвету искры понимать, во что он стреляет.
constexpr uint32_t COL_SPARK_SPLINTER = FXC(170, 130, 80);
constexpr uint32_t COL_SPARK_DEBRIS = FXC(200, 160, 105);
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
{
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
const Vec2 s = v2d::WorldToScreen(world);
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
return Vec2{s.x + float(ox), s.y + float(oy) - float(liftPx)};
}
// Смешать два аддитивных цвета.
inline uint32_t MixFx(uint32_t a, uint32_t b, float t)
{
const uint32_t k = uint32_t(Clampf(t, 0.0f, 1.0f) * 256.0f);
return AddSat(ScaleColor8(a, 256u - k), ScaleColor8(b, k));
}
// Дуга в мировых координатах, спроецированная по точкам: в изометрии круг —
// это эллипс, и рисовать его надо сэмплами, а не экранной окружностью.
// Цвет плывёт от хвоста (colFrom) к голове (colTo) — так читается направление.
void AddWorldArc(Framebuffer& fb, Vec2 center, float radius, float angFrom, float angTo,
int ox, int oy, int lift, uint32_t colFrom, uint32_t colTo, int halo)
{
constexpr int SEG = 12;
Vec2 prev = ToScreen(center + FromAngle(angFrom) * radius, ox, oy, lift);
for (int k = 1; k <= SEG; ++k)
{
const float t = float(k) / float(SEG);
const Vec2 cur = ToScreen(center + FromAngle(angFrom + (angTo - angFrom) * t) * radius,
ox, oy, lift);
fb.AddBeam(prev.x, prev.y, cur.x, cur.y, MixFx(colFrom, colTo, t),
MixFx(colFrom, colTo, t - 1.0f / float(SEG)), halo);
prev = cur;
}
}
} // namespace
void FxSystem::Reset()
{
flashCount = 0;
sparkCount = 0;
eventCursor = 0;
shotShake = 0.0f;
}
void FxSystem::Ingest(const CombatEventLog& log, const Agent* agents, int agentCount)
{
uint32_t from = eventCursor;
if (log.head > from + COMBAT_EVENT_CAP) from = log.head - COMBAT_EVENT_CAP;
for (uint32_t i = from; i < log.head; ++i)
{
const CombatEvent& e = log.buf[i % COMBAT_EVENT_CAP];
if (e.kind == CombatEventKind::SHOT)
{
// Вид выстрела — свойство ствола, а не события: журнал носит только
// «кто, откуда, куда», а как это выглядит, знает оружие стрелка.
const bool known = (e.owner >= 0 && e.owner < agentCount);
const FirearmDef def = known ? agents[e.owner].gun.def : FirearmDef{};
shotShake += def.shakePerShot;
if (flashCount < FX_FLASH_MAX)
{
FxFlash f;
f.pos = e.pos;
f.dir = e.dir;
f.life = def.muzzleTime;
f.maxLife = def.muzzleTime;
f.radiusPx = def.muzzleRadiusPx;
f.flamePx = def.muzzleFlamePx;
f.color = FXC(def.muzzleR, def.muzzleG, def.muzzleB);
f.owner = e.owner;
flashes[flashCount++] = f;
}
continue;
}
uint32_t col = 0;
float power = 1.0f;
switch (e.kind)
{
case CombatEventKind::HIT_WALL: col = COL_SPARK_WALL; power = 1.0f; break;
case CombatEventKind::HIT_TARGET: col = COL_SPARK_FLESH; power = 0.8f; break;
case CombatEventKind::HIT_KILL: col = COL_SPARK_KILL; power = 1.8f; break;
case CombatEventKind::HIT_ALLY: col = COL_SPARK_ALLY; power = 2.5f; break;
// Удар клинком — самое громкое событие в кадре: он всегда в упор.
case CombatEventKind::MELEE_HIT: col = COL_SPARK_MELEE; power = 2.2f; break;
case CombatEventKind::MELEE_ABORT: col = COL_SPARK_ABORT; power = 0.7f; break;
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Обстановка: щепки на попадание, облако трухи на развал.
case CombatEventKind::HIT_PROP: col = COL_SPARK_SPLINTER; power = 0.6f; break;
case CombatEventKind::PROP_BREAK: col = COL_SPARK_DEBRIS; power = 1.6f; break;
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
default: continue;
}
if (sparkCount < FX_SPARK_MAX)
sparks[sparkCount++] = FxSpark{e.pos, e.dir, g_tune.sparkTime, power, col};
}
eventCursor = log.head;
}
void FxSystem::Update(float frameDt)
{
for (int i = 0; i < flashCount;)
{
flashes[i].life -= frameDt;
if (flashes[i].life <= 0.0f) flashes[i] = flashes[--flashCount];
else ++i;
}
for (int i = 0; i < sparkCount;)
{
sparks[i].life -= frameDt;
if (sparks[i].life <= 0.0f) sparks[i] = sparks[--sparkCount];
else ++i;
}
}
void FxSystem::Draw(Framebuffer& fb, const std::vector<Bullet>& bullets, int ox, int oy,
float alpha) const
{
const int lift = int(g_tune.bulletLiftPx);
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
// --- трассеры ------------------------------------------------------------
// Длина и цвет штриха приехали вместе со снарядом: у пистолета трассер
// короче и теплее, чем у карабина, и это видно без единой подписи.
for (const Bullet& b : bullets)
{
// Голова — интерполированная позиция кадра, хвост — на tracerLength
// назад по вектору движения, но не дальше точки вылета.
const Vec2 head = Lerp(b.prevPos, b.pos, alpha);
const Vec2 dir = Normalized(b.vel);
const float flown = Distance(b.origin, head);
const float tailLen = std::min(b.tracerLength, flown);
const Vec2 tail = head - dir * tailLen;
const Vec2 hs = ToScreen(head, ox, oy, lift);
const Vec2 ts = ToScreen(tail, ox, oy, lift);
const uint32_t headCol = ScaleColor8(FXC(b.tracerR, b.tracerG, b.tracerB), inten);
fb.AddBeam(ts.x, ts.y, hs.x, hs.y, headCol, ScaleColor8(COL_TRACER_TAIL, inten), 1);
// Само тело пули — яркая точка на голове штриха.
fb.AddGlowDisc(int(hs.x), int(hs.y), 2, headCol);
}
// --- вспышки у дула ------------------------------------------------------
for (int i = 0; i < flashCount; ++i)
{
const FxFlash& f = flashes[i];
const float t = Clampf(f.life / std::max(0.001f, f.maxLife), 0.0f, 1.0f);
const float amp = t * t;
const Vec2 s = ToScreen(f.pos, ox, oy, lift);
const uint32_t c = ScaleColor8(f.color ? f.color : COL_MUZZLE,
uint32_t(amp * float(inten)));
fb.AddGlowDisc(int(s.x), int(s.y), int(f.radiusPx * amp) + 1, c);
// Короткий язык пламени по направлению выстрела.
const Vec2 tip = ToScreen(f.pos + f.dir * f.flamePx, ox, oy, lift);
fb.AddBeam(s.x, s.y, tip.x, tip.y, c, ScaleColor8(c, 128), 1);
}
// --- искры попаданий ------------------------------------------------------
for (int i = 0; i < sparkCount; ++i)
{
const FxSpark& sp = sparks[i];
const float t = Clampf(sp.life / std::max(0.001f, g_tune.sparkTime), 0.0f, 1.0f);
const float amp = t * t * sp.power;
const Vec2 s = ToScreen(sp.pos, ox, oy, lift);
const uint32_t c = ScaleColor8(sp.color, uint32_t(amp * float(inten)));
fb.AddGlowDisc(int(s.x), int(s.y), int(g_tune.sparkRadiusPx * amp) + 1, c);
// Разлёт осколков назад от направления удара — детерминированно по индексу.
const int shards = 4;
for (int k = 0; k < shards; ++k)
{
const float ang = AngleOf(sp.dir) + PI_F + (float(k) - 1.5f) * 0.5f;
const float len = (1.0f - t) * 0.45f;
const Vec2 e = ToScreen(sp.pos + FromAngle(ang) * len, ox, oy, lift);
fb.AddBeam(s.x, s.y, e.x, e.y, c, 0u, 0);
}
}
}
void DrawMeleeArcs(Framebuffer& fb, const Agent* agents, int count, int ox, int oy, float alpha)
{
const int lift = int(g_tune.bladeLiftPx);
const uint32_t inten = uint32_t(Clampf(g_tune.fxIntensity, 0.0f, 4.0f) * 256.0f);
for (int i = 0; i < count; ++i)
{
const Agent& a = agents[i];
if (!a.IsMelee()) continue;
const MeleeWeapon& m = a.melee;
if (m.state == MeleeState::IDLE) continue;
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
const float half = m.HalfArc();
const float start = WrapPi(m.dir - m.sweepSign * half); // откуда заходит лезвие
if (m.state == MeleeState::WINDUP)
{
// Телеграф: клинок уводится НАЗАД за край сектора и разгорается.
// Игрок обязан успеть прочитать замах — иначе удар бьёт «из ниоткуда»
// и ощущается не как удар, а как случайное списание HP.
const float t = m.Progress();
const float back = WrapPi(start - m.sweepSign * (0.4f * half * t));
const uint32_t c = ScaleColor8(COL_BLADE_COLD, uint32_t(inten * (0.2f + 0.8f * t)));
const Vec2 near_ = ToScreen(p + FromAngle(back) * (m.reach * 0.40f), ox, oy, lift);
const Vec2 far_ = ToScreen(p + FromAngle(back) * (m.reach * 0.95f), ox, oy, lift);
AddWorldArc(fb, p, m.reach * 0.9f, start, back, ox, oy, lift, 0u, c, 0);
fb.AddBeam(near_.x, near_.y, far_.x, far_.y, c, ScaleColor8(c, 64), 0);
fb.AddGlowDisc(int(far_.x), int(far_.y), 1 + int(2.0f * t), c);
continue;
}
// STRIKE и RECOVER рисуются одним серпом: в ударе он растёт до текущего
// угла лезвия, после удара — стоит на месте и гаснет.
float prog = 1.0f;
float fade = 1.0f;
if (m.state == MeleeState::STRIKE)
{
prog = m.Progress();
}
else
{
// Затухание быстрое: след клинка не должен висеть до следующего замаха.
fade = 1.0f - Clampf(m.Progress() * 2.2f, 0.0f, 1.0f);
if (fade <= 0.0f) continue;
}
const float bladeAng = m.BladeAngleAt(prog);
// Хвост — не весь пройденный путь, а его доля: серп, а не кольцо.
const float tailProg = std::max(0.0f, prog - g_tune.bladeTrailFrac);
const float tailAng = m.BladeAngleAt(tailProg);
const uint32_t k = uint32_t(inten * fade);
// Серп набирается слоями концентрических дуг: внешний край раскалён,
// внутренние тускнеют к рукояти. На 480x270 одна линия читается палкой,
// а четыре — взмахом.
constexpr int LAYERS = 4;
for (int l = 0; l < LAYERS; ++l)
{
const float f = float(l) / float(LAYERS - 1);
const float rr = m.reach * Lerpf(0.42f, 1.02f, f);
const uint32_t hot = ScaleColor8(COL_BLADE_EDGE, uint32_t(k * (0.25f + 0.75f * f)));
const uint32_t cold = ScaleColor8(COL_BLADE_TAIL, uint32_t(k * 0.18f * (0.4f + f)));
AddWorldArc(fb, p, rr, tailAng, bladeAng, ox, oy, lift, cold, hot,
(l == LAYERS - 1) ? 1 : 0);
}
// Само лезвие: раскалённая радиальная черта на голове серпа.
const Vec2 inner = ToScreen(p + FromAngle(bladeAng) * (m.reach * 0.38f), ox, oy, lift);
const Vec2 tip = ToScreen(p + FromAngle(bladeAng) * (m.reach * 1.04f), ox, oy, lift);
const uint32_t edge = ScaleColor8(COL_BLADE_EDGE, k);
fb.AddBeam(inner.x, inner.y, tip.x, tip.y, edge, ScaleColor8(COL_BLADE_TAIL, k / 2u), 1);
fb.AddGlowDisc(int(tip.x), int(tip.y), 3, edge);
}
}