squad-proto/src/render/lighting.cpp
z.kirill ef103050b1 Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.

НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.

СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.

ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.

ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.

НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.

ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.

Формат профиля 6: строки merc и rescued.

Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:09:26 +03:00

314 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/lighting.h"
#include "engine/projection.h"
namespace
{
inline float Smooth01(float t)
{
if (t <= 0.0f) return 0.0f;
if (t >= 1.0f) return 1.0f;
return t * t * (3.0f - 2.0f * t);
}
// Плавное падение с 1 до 0 на отрезке [lo, hi].
inline float FadeOut(float x, float lo, float hi)
{
if (hi <= lo) return x <= lo ? 1.0f : 0.0f;
return 1.0f - Smooth01((x - lo) / (hi - lo));
}
} // namespace
void Lighting::Reset()
{
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
vis[i] = 0.0f;
mem[i] = 0.0f;
dyn[i] = 0.0f;
tile[i] = 0.0f;
}
for (int i = 0; i < (MAP_W + 1) * (MAP_H + 1); ++i) corner[i] = 0.0f;
flashCount = 0;
eventCursor = 0;
}
void Lighting::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];
// Параметры вспышки берутся с оружия стрелявшего, если оно известно.
const bool ownerKnown = (e.owner >= 0 && e.owner < agentCount);
const float ownPower = ownerKnown ? agents[e.owner].gun.def.flashPower : g_tune.flashPower;
const float ownRadius = ownerKnown ? agents[e.owner].gun.def.flashRadius : g_tune.flashRadius;
float power = 0.0f;
switch (e.kind)
{
case CombatEventKind::SHOT: power = ownPower; break;
case CombatEventKind::HIT_WALL: power = g_tune.flashPower * 0.45f; break;
case CombatEventKind::HIT_TARGET: power = g_tune.flashPower * 0.35f; break;
case CombatEventKind::HIT_KILL: power = g_tune.flashPower * 0.9f; break;
// Клинок не даёт дульной вспышки, но искры от удара в упор освещают
// не хуже — иначе весь ближний бой идёт в кромешной темноте.
case CombatEventKind::MELEE_SWING: power = g_tune.flashPower * 0.22f; break;
case CombatEventKind::MELEE_HIT: power = g_tune.flashPower * 0.75f; break;
default: break;
}
if (power <= 0.0f) continue;
if (flashCount < LIGHT_FLASH_MAX)
flashes[flashCount++] = LightFlash{e.pos, power, g_tune.flashTime,
(e.kind == CombatEventKind::SHOT)
? ownRadius : g_tune.flashRadius};
}
eventCursor = log.head;
}
void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float dt)
{
static float visRaw[MAP_W * MAP_H];
if (!enabled)
{
// Аварийный тумблер: всё видно, память полная, вспышек нет.
for (int i = 0; i < MAP_W * MAP_H; ++i) { vis[i] = 1.0f; mem[i] = 1.0f; dyn[i] = 0.0f; }
for (int i = 0; i < MAP_W * MAP_H; ++i) tile[i] = 1.0f;
for (int cy = 0; cy <= MAP_H; ++cy)
for (int cx = 0; cx <= MAP_W; ++cx) corner[cy * (MAP_W + 1) + cx] = 1.0f;
return;
}
for (int i = 0; i < MAP_W * MAP_H; ++i) visRaw[i] = 0.0f;
// --- 1. видимость пола от каждого агента ---------------------------------
const float coneHalf = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
const float coneSoft = g_tune.sightConeSoftDeg * DEG2RAD_F;
for (int a = 0; a < count; ++a)
{
const Agent& ag = agents[a];
if (!ag.alive) continue; // павший больше не смотрит по сторонам
// Радиус обзора СВОЙ у каждого бойца: фонарь в кармане светит дальше,
// саван — ближе (sim/items.h). Без этого экран отряда показывал бы
// «SIGHT 135%», за которым ничего не стоит.
// Глубина сжимает обзор ВСЕМ одинаково, поверх личного множителя:
// фонарь на двадцатом этаже по-прежнему лучше савана, просто оба
// светят хуже, чем на первом.
const float mul = ag.sightMul * sightScale;
const float rFull = g_tune.sightFullRadius * mul;
const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius * mul);
const float personal = g_tune.sightPersonalRadius * mul;
const int x0 = std::max(0, int(std::floor(ag.pos.x - rMax)));
const int x1 = std::min(MAP_W - 1, int(std::floor(ag.pos.x + rMax)));
const int y0 = std::max(0, int(std::floor(ag.pos.y - rMax)));
const int y1 = std::min(MAP_H - 1, int(std::floor(ag.pos.y + rMax)));
for (int ty = y0; ty <= y1; ++ty)
{
for (int tx = x0; tx <= x1; ++tx)
{
if (map.IsWall(tx, ty)) continue; // стены освещаются отдельно
const Vec2 c{tx + 0.5f, ty + 0.5f};
const Vec2 d = c - ag.pos;
const float dist = Length(d);
if (dist > rMax) continue;
const float fd = FadeOut(dist, rFull, rMax);
if (fd <= 0.0f) continue;
// Конус по направлению взгляда + круговой «личный» ореол рядом.
// max, а не сумма: ореол дырявит конус вблизи, но не удваивает свет.
const float ang = std::fabs(AngleDiff(ag.facing, AngleOf(d)));
const float fc = FadeOut(ang, coneHalf, coneHalf + coneSoft);
const float fp = FadeOut(dist, personal * 0.6f, personal);
const float v = fd * std::max(fc, fp);
if (v <= 0.0f) continue;
const int idx = ty * MAP_W + tx;
if (v <= visRaw[idx]) continue; // уже ярче — рейкаст не нужен
if (map.RaycastBlocked(ag.pos, c)) continue; // стена перекрыла обзор
visRaw[idx] = v;
}
}
}
// --- 2. свет протекает в стены ------------------------------------------
// Стену нельзя проверить лучом до её центра: DDA считает попадание в саму
// стену перекрытием. Поэтому стена берёт максимум от соседей.
for (int pass = 0; pass < WALL_LIGHT_PASSES; ++pass)
{
for (int ty = 0; ty < MAP_H; ++ty)
{
for (int tx = 0; tx < MAP_W; ++tx)
{
if (!map.IsWall(tx, ty)) continue;
float best = 0.0f;
for (int oy = -1; oy <= 1; ++oy)
for (int ox = -1; ox <= 1; ++ox)
{
if (ox == 0 && oy == 0) continue;
const int nx = tx + ox, ny = ty + oy;
if (!Tilemap::InBounds(nx, ny)) continue;
best = std::max(best, visRaw[ny * MAP_W + nx]);
}
const int idx = ty * MAP_W + tx;
visRaw[idx] = std::max(visRaw[idx], best * g_tune.fogWallBleed);
}
}
}
// --- 3. временное сглаживание, чтобы свет не щёлкал ----------------------
const float k = 1.0f - std::exp(-g_tune.fogFadeRate * dt);
for (int i = 0; i < MAP_W * MAP_H; ++i)
vis[i] += (visRaw[i] - vis[i]) * k;
// --- 4. динамический свет от вспышек -------------------------------------
for (int i = 0; i < MAP_W * MAP_H; ++i) dyn[i] = 0.0f;
for (int i = 0; i < flashCount;)
{
LightFlash& f = flashes[i];
f.life -= dt;
if (f.life <= 0.0f)
{
flashes[i] = flashes[--flashCount];
continue;
}
const float t = Clampf(f.life / std::max(0.001f, g_tune.flashTime), 0.0f, 1.0f);
const float amp = f.power * t * t; // резкий спад, вспышка короткая
const float rad = f.radius;
const int x0 = std::max(0, int(std::floor(f.pos.x - rad)));
const int x1 = std::min(MAP_W - 1, int(std::floor(f.pos.x + rad)));
const int y0 = std::max(0, int(std::floor(f.pos.y - rad)));
const int y1 = std::min(MAP_H - 1, int(std::floor(f.pos.y + rad)));
for (int ty = y0; ty <= y1; ++ty)
for (int tx = x0; tx <= x1; ++tx)
{
const Vec2 c{tx + 0.5f, ty + 0.5f};
const float dist = Distance(f.pos, c);
if (dist > rad) continue;
const float fall = FadeOut(dist, rad * 0.15f, rad);
if (fall <= 0.0f) continue;
// Вспышка не светит сквозь стены (сама стена подсвечивается).
if (!map.IsWall(tx, ty) && map.RaycastBlocked(f.pos, c)) continue;
const int idx = ty * MAP_W + tx;
dyn[idx] = std::max(dyn[idx], amp * fall);
}
++i;
}
// --- 5. память карты и сборка итогового уровня ---------------------------
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
const float seen = vis[i];
if (seen > 0.02f) mem[i] = std::max(mem[i], std::max(g_tune.fogMemoryLevel, ambient));
float lvl = std::max(mem[i], seen * g_tune.sightBrightness) + dyn[i];
tile[i] = Clampf(lvl, 0.0f, 1.0f);
}
// --- 6. узловая сетка: узел = среднее смежных тайлов ----------------------
for (int cy = 0; cy <= MAP_H; ++cy)
{
for (int cx = 0; cx <= MAP_W; ++cx)
{
float sum = 0.0f;
int n = 0;
for (int oy = -1; oy <= 0; ++oy)
for (int ox = -1; ox <= 0; ++ox)
{
const int tx = cx + ox, ty = cy + oy;
if (!Tilemap::InBounds(tx, ty)) continue;
sum += tile[ty * MAP_W + tx];
++n;
}
corner[cy * (MAP_W + 1) + cx] = (n > 0) ? (sum / float(n)) : 0.0f;
}
}
}
float Lighting::LightAt(Vec2 world) const
{
const float fx = Clampf(world.x, 0.0f, float(MAP_W) - 0.001f);
const float fy = Clampf(world.y, 0.0f, float(MAP_H) - 0.001f);
const int cx = int(fx);
const int cy = int(fy);
const float u = fx - float(cx);
const float v = fy - float(cy);
const int stride = MAP_W + 1;
const float c00 = corner[cy * stride + cx];
const float c10 = corner[cy * stride + cx + 1];
const float c01 = corner[(cy + 1) * stride + cx];
const float c11 = corner[(cy + 1) * stride + cx + 1];
return c00 * (1 - u) * (1 - v) + c10 * u * (1 - v) + c01 * (1 - u) * v + c11 * u * v;
}
float Lighting::MaxCornerOf(int tx, int ty) const
{
if (!Tilemap::InBounds(tx, ty)) return 0.0f;
const int stride = MAP_W + 1;
const float c00 = corner[ty * stride + tx];
const float c10 = corner[ty * stride + tx + 1];
const float c01 = corner[(ty + 1) * stride + tx];
const float c11 = corner[(ty + 1) * stride + tx + 1];
return std::max(std::max(c00, c10), std::max(c01, c11));
}
ShadeQuad Lighting::QuadFor(int tx, int ty, int clampRow) const
{
const int stride = MAP_W + 1;
const float c00 = corner[ty * stride + tx]; // верх ромба, спрайт (16,0)
const float c10 = corner[ty * stride + tx + 1]; // право, спрайт (32,8)
const float c01 = corner[(ty + 1) * stride + tx]; // лево, спрайт (0,8)
const float c11 = corner[(ty + 1) * stride + tx + 1]; // низ, спрайт (16,16)
const float Bu = c10 - c00;
const float Bv = c01 - c00;
const float K = c00 - c10 - c01 + c11;
const float tw = float(g_proj.tileW);
const float th = float(g_proj.tileH);
ShadeQuad q;
q.clampRow = clampRow;
if (g_proj.mode == ViewMode::TOP_DOWN)
{
// Плита — квадрат, углы лежат по сторонам света: c00 в (0,0), c10 в
// (tw,0), c01 в (0,th). Косой член K тут остался бы честным слагаемым
// u*v, а ShadeQuad такого не умеет — у неё только px, py, px^2, py^2.
// Поэтому он отбрасывается: на тайле в 32 пикселя это доли ступени
// рампы, а размен на лишний член в горячем блите того не стоит.
q.a = c00;
q.b = Bu / tw;
q.c = Bv / th;
return q;
}
// Изометрия: те же четыре угла попадают в вершины ромба, и билинейка,
// записанная в координатах спрайта, честно раскладывается по этим членам —
// поэтому на общем ребре соседних тайлов значения совпадают и швов нет.
q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f;
q.b = (Bu - Bv + K) / tw;
q.c = (Bu + Bv) / th;
q.e = -K / (tw * tw);
q.f = K / (th * th);
return q;
}