#include "render/blood.h" #include "engine/view2d.h" #include "render/noise.h" namespace { // Максимум капель в одном пятне. Больше — это уже не брызги, а забор. constexpr int MAX_DROPS = 16; // Одна капля пятна: круг с рваным краем, при желании вытянутый вдоль удара. struct Blob { Vec2 c{}; float r = 0.0f; float strength = 1.0f; float elong = 1.0f; // вытяжение вдоль dir; 1 = ровный круг uint32_t seed = 0; }; // Множитель цвета по каналам с насыщением. Кровь именно УМНОЖАЕТ пол, а не // закрашивает его: фактура плитки должна просвечивать, иначе получается наклейка. inline uint32_t MulColor(uint32_t c, float kr, float kg, float kb) { const float r = Clampf(float(c & 0xFFu) * kr, 0.0f, 255.0f); const float g = Clampf(float((c >> 8) & 0xFFu) * kg, 0.0f, 255.0f); const float b = Clampf(float((c >> 16) & 0xFFu) * kb, 0.0f, 255.0f); return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c)); } inline uint32_t MixColor(uint32_t a, uint32_t b, float t) { const float k = Clampf(t, 0.0f, 1.0f); return RGBA(uint8_t(Lerpf(float(a & 0xFFu), float(b & 0xFFu), k)), uint8_t(Lerpf(float((a >> 8) & 0xFFu), float((b >> 8) & 0xFFu), k)), uint8_t(Lerpf(float((a >> 16) & 0xFFu), float((b >> 16) & 0xFFu), k)), AlphaOf(a)); } // Рваный край: радиус зависит от угла, между лепестками — гладкая интерполяция. // Идеальный круг читается как штамп, а не как лужа. float EdgeWobble(uint32_t seed, float ang) { constexpr int LOBES = 12; const float t = (ang + PI_F) / TWO_PI_F * float(LOBES); const int i0 = int(t) % LOBES; const int i1 = (i0 + 1) % LOBES; float f = t - std::floor(t); f = f * f * (3.0f - 2.0f * f); const float w0 = Hash01(Hash3(seed, uint32_t(i0), 0x5Bu)); const float w1 = Hash01(Hash3(seed, uint32_t(i1), 0x5Bu)); return 0.72f + 0.44f * Lerpf(w0, w1, f); } } // namespace void BloodField::Reset() { slotOf.assign(size_t(MAP_W) * size_t(MAP_H), -1); stained.clear(); eventCursor = 0; splats = 0; } void BloodField::Ingest(const CombatEventLog& log, const SpriteSet& sprites, const Tilemap& map) { 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]; float radius = 0.0f; switch (e.kind) { case CombatEventKind::HIT_TARGET: radius = g_tune.bloodRadiusHit; break; case CombatEventKind::HIT_ALLY: radius = g_tune.bloodRadiusHit; break; case CombatEventKind::MELEE_HIT: radius = g_tune.bloodRadiusMelee; break; case CombatEventKind::HIT_KILL: radius = g_tune.bloodRadiusKill; break; default: continue; } // Зерно от номера события: два попадания в одну точку дадут разные пятна. Splat(sprites, map, e.pos, e.dir, radius, Hash2(i, uint32_t(e.owner + 1))); } eventCursor = log.head; } void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, Vec2 dir, float radius, uint32_t seed) { if (g_tune.bloodAmount <= 0.0f) return; radius *= g_tune.bloodAmount; if (radius < 0.02f) return; if (slotOf.empty()) Reset(); const Vec2 d = Normalized(dir, Vec2{1.0f, 0.0f}); const Vec2 side = Perp(d); // --- набор капель --------------------------------------------------------- Blob blobs[1 + MAX_DROPS]; int n = 0; Blob main_; main_.c = world; main_.r = radius; main_.strength = 1.0f; main_.elong = 1.35f; // лужа вытянута по удару, а не идеально кругла main_.seed = seed; blobs[n++] = main_; const int drops = std::min(MAX_DROPS, std::max(0, int(g_tune.bloodDrops))); for (int k = 0; k < drops; ++k) { const uint32_t h = Hash3(seed, uint32_t(k), 0x11u); const float along = radius * (1.05f + 2.4f * (float(k) + 1.0f) / float(drops)) * (0.55f + 0.9f * Hash01(h)); const float lateral = radius * 1.15f * HashSigned(Hash2(h, 7u)) * (0.3f + 0.7f * float(k) / float(drops)); const float rr = radius * (0.34f - 0.20f * float(k) / float(drops)) * (0.45f + 0.9f * Hash01(Hash2(h, 13u))); if (rr < 0.02f) continue; Blob b; b.c = world + d * along + side * lateral; b.r = rr; b.strength = 0.86f; b.elong = 1.0f; b.seed = h; blobs[n++] = b; } // --- границы в мире -> диапазон тайлов ------------------------------------ float minX = world.x, maxX = world.x, minY = world.y, maxY = world.y; for (int i = 0; i < n; ++i) { const float r = blobs[i].r * 1.5f; minX = std::min(minX, blobs[i].c.x - r); maxX = std::max(maxX, blobs[i].c.x + r); minY = std::min(minY, blobs[i].c.y - r); maxY = std::max(maxY, blobs[i].c.y + r); } const int tx0 = int(std::floor(minX)); const int tx1 = int(std::floor(maxX)); const int ty0 = int(std::floor(minY)); const int ty1 = int(std::floor(maxY)); const uint32_t coreCol = RGBA(52, 8, 10); const uint32_t rimCol = RGBA(104, 22, 16); const float opacity = Clampf(g_tune.bloodOpacity, 0.0f, 1.0f); bool painted = false; for (int ty = ty0; ty <= ty1; ++ty) { for (int tx = tx0; tx <= tx1; ++tx) { if (!Tilemap::InBounds(tx, ty)) continue; if (map.IsWall(tx, ty)) continue; // под стеной пола не видно const size_t key = size_t(ty) * MAP_W + tx; int32_t slot = slotOf[key]; if (slot < 0) { // Бюджет выбран: на чистый тайл больше не тратимся, но уже // запачканные продолжают собирать кровь. if (int(stained.size()) >= BLOOD_TILE_BUDGET) continue; slot = int32_t(stained.size()); stained.push_back(sprites.FloorTile(tx, ty, map.ThemeAt(tx, ty))); slotOf[key] = slot; } Sprite& s = stained[size_t(slot)]; const Vec2 center{float(tx) + 0.5f, float(ty) + 0.5f}; for (int py = 0; py < s.h; ++py) { for (int px = 0; px < s.w; ++px) { const uint32_t base = s.Get(px, py); if (AlphaOf(base) == 0) continue; // вне ромба // Пиксель ромба -> точка мира. Проекция линейна, поэтому // обратный переход точен и швов между тайлами не будет. const Vec2 p = center + v2d::ScreenToWorld( Vec2{float(px - s.ax), float(py - s.ay)}); float cover = 0.0f; for (int i = 0; i < n && cover < 1.0f; ++i) { const Blob& b = blobs[i]; Vec2 dv = p - b.c; if (b.elong != 1.0f) dv = Vec2{Dot(dv, d) / b.elong, Dot(dv, side)}; const float dist = Length(dv); const float r = b.r * EdgeWobble(b.seed, AngleOf(dv)); if (dist >= r) continue; const float inner = r * 0.68f; const float a = (dist <= inner) ? 1.0f : 1.0f - (dist - inner) / std::max(1e-4f, r - inner); cover = std::max(cover, b.strength * a); } if (cover <= 0.02f) continue; // Кромка светлее и рыжее середины — так лужа читается объёмной. const uint32_t tint = MixColor(rimCol, coreCol, Clampf(cover * 1.3f, 0.0f, 1.0f)); // Крапчатость: свернувшаяся кровь неоднородна. const float mott = 0.84f + 0.26f * Hash01(Hash3(uint32_t(tx * 64 + px), uint32_t(ty * 64 + py), seed)); // Плотность держится почти по всему пятну и падает только // у самого края: лужа с ровным градиентом выглядит подтёком. const float a = Clampf(cover * 1.5f, 0.0f, 1.0f) * opacity * mott; // Делители подобраны так, чтобы серый пол ушёл в тёмно-красное, // а кромка — в заметно более светлое и рыжее. const uint32_t stainedPx = MulColor(base, Lerpf(1.0f, float(tint & 0xFFu) / 55.0f, a), Lerpf(1.0f, float((tint >> 8) & 0xFFu) / 110.0f, a), Lerpf(1.0f, float((tint >> 16) & 0xFFu) / 150.0f, a)); s.Set(px, py, stainedPx); painted = true; } } } } if (painted) ++splats; }