#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& 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); } }