squad-proto/src/render/blood.cpp
z.kirill 7d6266aaf3 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-13 21:07:19 +03:00

234 lines
9.8 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/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;
}