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