squad-proto/engine/framebuffer.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

355 lines
12 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 "engine/framebuffer.h"
#include <raylib.h>
#include <cmath>
#include <cstdlib>
// -----------------------------------------------------------------------------
// Световая рампа
// -----------------------------------------------------------------------------
uint8_t g_shadeLut[RAMP_LEVELS][3][256];
const uint8_t BAYER4[4][4] = {
{ 0, 8, 2, 10},
{12, 4, 14, 6},
{ 3, 11, 1, 9},
{15, 7, 13, 5},
};
namespace
{
// Цвет полумрака и цвет пересвета. Между ними рампа плавно интерполирует.
constexpr float COLD[3] = {0.60f, 0.74f, 1.00f}; // холодный синий в тенях
constexpr float WARM[3] = {1.00f, 0.92f, 0.76f}; // тёплый пересвет во вспышке
inline float Smooth01(float t) { return t * t * (3.0f - 2.0f * t); }
struct ShadeRampInit
{
ShadeRampInit() { BuildShadeRamp(1.35f, 1.10f); }
};
ShadeRampInit g_rampInit;
} // namespace
void BuildShadeRamp(float gamma, float maxGain)
{
for (int lvl = 0; lvl < RAMP_LEVELS; ++lvl)
{
// Ступень 0 — чистый чёрный: она обязана совпадать с цветом Clear,
// иначе невидимые тайлы нельзя пропускать при отрисовке.
if (lvl == 0)
{
for (int ch = 0; ch < 3; ++ch)
for (int v = 0; v < 256; ++v) g_shadeLut[lvl][ch][v] = 0;
continue;
}
const float t = float(lvl) / float(RAMP_LEVELS - 1);
const float gain = std::pow(t, gamma) * maxGain;
const float mix = Smooth01(t);
for (int ch = 0; ch < 3; ++ch)
{
const float tint = COLD[ch] + (WARM[ch] - COLD[ch]) * mix;
const float k = gain * tint;
for (int v = 0; v < 256; ++v)
{
const float out = float(v) * k;
g_shadeLut[lvl][ch][v] = uint8_t(out < 0.0f ? 0.0f : (out > 255.0f ? 255.0f : out));
}
}
}
}
// -----------------------------------------------------------------------------
void Framebuffer::Init(int width, int height)
{
w = width;
h = height;
px.assign(size_t(w) * size_t(h), 0u);
}
void Framebuffer::Clear(uint32_t color)
{
std::fill(px.begin(), px.end(), color);
}
namespace
{
// Общая обрезка спрайта по буферу. false — рисовать нечего.
inline bool ClipSprite(const Sprite& s, int x, int y, int fbW, int fbH, int& dstX0, int& dstY0,
int& sx0, int& sy0, int& sx1, int& sy1)
{
dstX0 = x - s.ax;
dstY0 = y - s.ay;
sx0 = std::max(0, -dstX0);
sy0 = std::max(0, -dstY0);
sx1 = std::min(s.w, fbW - dstX0);
sy1 = std::min(s.h, fbH - dstY0);
// Пустой диапазон -> выходим ДО вычисления адресов: иначе индекс уезжает
// за границы vector (Debug ловит это как "vector subscript out of range").
return sx0 < sx1 && sy0 < sy1;
}
} // namespace
void Framebuffer::Blit(const Sprite& s, int x, int y)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
{
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
{
const uint32_t c = *src;
if (AlphaOf(c) != 0) *dst = c; // альфа-тест, без блендинга
}
}
}
void Framebuffer::BlitTinted(const Sprite& s, int x, int y, uint32_t tint)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
for (int sx = sx0; sx < sx1; ++sx)
if (AlphaOf(s.px[size_t(sy) * s.w + sx]) != 0)
px[size_t(dstY0 + sy) * w + (dstX0 + sx)] = tint;
}
void Framebuffer::BlitShaded(const Sprite& s, int x, int y, const ShadeQuad& q)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(s, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
constexpr float MAX_LVL = float(RAMP_LEVELS - 1);
for (int sy = sy0; sy < sy1; ++sy)
{
const float py = float(sy < q.clampRow ? sy : q.clampRow);
const float rowBase = q.a + q.c * py + q.f * py * py;
// Прямые разности по строке: во внутреннем цикле два сложения.
const float fx0 = float(sx0);
float lit = rowBase + q.b * fx0 + q.e * fx0 * fx0;
float dl = q.b + q.e * (2.0f * fx0 + 1.0f);
const float ddl = 2.0f * q.e;
const uint8_t* bayerRow = BAYER4[(dstY0 + sy) & 3];
const uint32_t* src = s.px.data() + size_t(sy) * s.w + sx0;
uint32_t* dst = px.data() + size_t(dstY0 + sy) * w + (dstX0 + sx0);
for (int sx = sx0; sx < sx1; ++sx, ++src, ++dst)
{
const uint32_t c = *src;
if (AlphaOf(c) != 0)
{
const float dither = (float(bayerRow[(dstX0 + sx) & 3]) + 0.5f) * (1.0f / 16.0f);
int lvl = int(lit * MAX_LVL + dither);
if (lvl < 0) lvl = 0;
else if (lvl >= RAMP_LEVELS) lvl = RAMP_LEVELS - 1;
if (lvl != 0) *dst = ShadePixel(c, lvl);
}
lit += dl;
dl += ddl;
}
}
}
void Framebuffer::BlitMul(const Sprite& mask, int x, int y, uint32_t k256)
{
int dstX0, dstY0, sx0, sy0, sx1, sy1;
if (!ClipSprite(mask, x, y, w, h, dstX0, dstY0, sx0, sy0, sx1, sy1)) return;
for (int sy = sy0; sy < sy1; ++sy)
for (int sx = sx0; sx < sx1; ++sx)
if (AlphaOf(mask.px[size_t(sy) * mask.w + sx]) != 0)
{
uint32_t& d = px[size_t(dstY0 + sy) * w + (dstX0 + sx)];
d = ScaleColor8(d, k256);
}
}
void Framebuffer::FillRect(int x, int y, int rw, int rh, uint32_t c)
{
const int x0 = std::max(0, x);
const int y0 = std::max(0, y);
const int x1 = std::min(w, x + rw);
const int y1 = std::min(h, y + rh);
for (int yy = y0; yy < y1; ++yy)
for (int xx = x0; xx < x1; ++xx)
px[size_t(yy) * w + xx] = c;
}
void Framebuffer::Line(int x0, int y0, int x1, int y1, uint32_t c)
{
// Брезенхем. Put сам отсекает по границам.
int dx = std::abs(x1 - x0);
int dy = -std::abs(y1 - y0);
const int sx = (x0 < x1) ? 1 : -1;
const int sy = (y0 < y1) ? 1 : -1;
int err = dx + dy;
for (;;)
{
Put(x0, y0, c);
if (x0 == x1 && y0 == y1) break;
const int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
void Framebuffer::CircleOutline(int cx, int cy, int r, uint32_t c)
{
int x = r;
int y = 0;
int err = 1 - r;
while (x >= y)
{
Put(cx + x, cy + y, c); Put(cx + y, cy + x, c);
Put(cx - y, cy + x, c); Put(cx - x, cy + y, c);
Put(cx - x, cy - y, c); Put(cx - y, cy - x, c);
Put(cx + y, cy - x, c); Put(cx + x, cy - y, c);
++y;
if (err < 0) err += 2 * y + 1;
else { --x; err += 2 * (y - x) + 1; }
}
}
void Framebuffer::CircleFilled(int cx, int cy, int r, uint32_t c)
{
const int r2 = r * r;
for (int y = -r; y <= r; ++y)
for (int x = -r; x <= r; ++x)
if (x * x + y * y <= r2) Put(cx + x, cy + y, c);
}
// -----------------------------------------------------------------------------
// Аддитивные примитивы
// -----------------------------------------------------------------------------
void Framebuffer::AddBeam(float x0, float y0, float x1, float y1, uint32_t headCol,
uint32_t tailCol, int halo)
{
// Отсечение по расширенному прямоугольнику кадра: длинный закадровый штрих
// не должен крутить тысячи холостых итераций.
const float pad = float(halo) + 2.0f;
if ((x0 < -pad && x1 < -pad) || (x0 > float(w) + pad && x1 > float(w) + pad)) return;
if ((y0 < -pad && y1 < -pad) || (y0 > float(h) + pad && y1 > float(h) + pad)) return;
const float dx = x1 - x0;
const float dy = y1 - y0;
const float len = std::sqrt(dx * dx + dy * dy);
const int steps = int(len) + 1;
if (steps > 4 * (w + h)) return; // защита от вырожденных значений
const float inv = 1.0f / float(steps);
for (int i = 0; i <= steps; ++i)
{
const float t = float(i) * inv; // 0 = хвост, 1 = голова
const float px_ = x0 + dx * t;
const float py_ = y0 + dy * t;
// Цвет плывёт от хвоста к голове; хвост дополнительно гаснет.
const uint32_t core = AddSat(ScaleColor8(tailCol, uint32_t((1.0f - t) * 256.0f)),
ScaleColor8(headCol, uint32_t(t * 256.0f)));
const int ix = int(px_);
const int iy = int(py_);
AddPx(ix, iy, core);
// Боковой ореол: та же линия вполсилы на соседних пикселях.
if (halo > 0)
{
const uint32_t glow = ScaleColor8(core, 96);
AddPx(ix + 1, iy, glow);
AddPx(ix - 1, iy, glow);
AddPx(ix, iy + 1, glow);
AddPx(ix, iy - 1, glow);
}
}
}
void Framebuffer::AddGlowDisc(int cx, int cy, int radius, uint32_t color)
{
if (radius <= 0) return;
if (cx + radius < 0 || cx - radius >= w || cy + radius < 0 || cy - radius >= h) return;
const float inv = 1.0f / float(radius);
for (int y = -radius; y <= radius; ++y)
{
for (int x = -radius; x <= radius; ++x)
{
const float d = std::sqrt(float(x * x + y * y)) * inv;
if (d >= 1.0f) continue;
const float k = (1.0f - d) * (1.0f - d); // квадратичное затухание
AddPx(cx + x, cy + y, ScaleColor8(color, uint32_t(k * 256.0f)));
}
}
}
// -----------------------------------------------------------------------------
void ScreenBlitter::Init(int width, int height)
{
Image img = GenImageColor(width, height, BLANK);
ImageFormat(&img, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8);
Texture2D* t = new Texture2D(LoadTextureFromImage(img));
UnloadImage(img);
SetTextureFilter(*t, TEXTURE_FILTER_POINT);
tex = t;
texW = width;
texH = height;
}
void ScreenBlitter::Shutdown()
{
if (!tex) return;
Texture2D* t = static_cast<Texture2D*>(tex);
UnloadTexture(*t);
delete t;
tex = nullptr;
}
// Прямоугольник кадра в окне: ЦЕЛЫЙ масштаб и центрирование.
//
// На borderless fullscreen окно почти никогда не кратно 480x270, и дробное
// растяжение размывает пиксель: одни строки удваиваются, соседние нет.
// Поэтому берём наибольший целый масштаб, а остаток отдаём чёрным полям.
ScreenRect ScreenFitRect(int fbW, int fbH, int windowW, int windowH)
{
if (fbW <= 0 || fbH <= 0) return {};
int scale = std::min(windowW / fbW, windowH / fbH);
if (scale < 1) scale = 1; // окно меньше кадра: лучше обрезать, чем мылить
ScreenRect r;
r.scale = scale;
r.w = fbW * scale;
r.h = fbH * scale;
r.x = (windowW - r.w) / 2;
r.y = (windowH - r.h) / 2;
return r;
}
void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) const
{
if (!tex) return;
if (fb.w != texW || fb.h != texH) return; // размер разошёлся — не грузим мусор
Texture2D* t = static_cast<Texture2D*>(tex);
UpdateTexture(*t, fb.px.data());
const ScreenRect fit = ScreenFitRect(fb.w, fb.h, windowW, windowH);
const Rectangle src = {0.0f, 0.0f, float(fb.w), float(fb.h)};
const Rectangle dst = {float(fit.x), float(fit.y), float(fit.w), float(fit.h)};
DrawTexturePro(*t, src, dst, {0.0f, 0.0f}, 0.0f, WHITE);
}