squad-proto/src/render/fx.cpp
z.kirill 7880f275bd Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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 13:00:49 +03:00

284 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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);
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
{
const Vec2 s = iso::WorldToScreen(world);
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;
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);
}
}