Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
291 lines
14 KiB
C++
291 lines
14 KiB
C++
#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);
|
||
}
|
||
}
|