squad-proto/src/render/fx.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

291 lines
14 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);
// Обстановка: пыльная щепа, а не кровь. Тусклее любого попадания по телу —
// игрок обязан по одному цвету искры понимать, во что он стреляет.
constexpr uint32_t COL_SPARK_SPLINTER = FXC(170, 130, 80);
constexpr uint32_t COL_SPARK_DEBRIS = FXC(200, 160, 105);
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
{
const Vec2 s = v2d::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;
// Обстановка: щепки на попадание, облако трухи на развал.
case CombatEventKind::HIT_PROP: col = COL_SPARK_SPLINTER; power = 0.6f; break;
case CombatEventKind::PROP_BREAK: col = COL_SPARK_DEBRIS; power = 1.6f; 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);
}
}