squad-proto/engine/tileset.cpp

293 lines
12 KiB
C++
Raw Permalink Normal View History

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-14 01:07:19 +07:00
#include "engine/tileset.h"
#include <utility>
#include "engine/math.h"
#include "engine/pixel.h"
#include "engine/projection.h"
#include "engine/sfa_library.h"
namespace
{
Sprite MakeFloor(uint32_t base)
{
const int tw = g_proj.tileW;
const int th = g_proj.tileH;
Sprite s;
s.Init(tw, th, 0);
s.ax = tw / 2;
s.ay = th / 2;
const uint32_t edge = ShadeColor(base, 0.78f);
const uint32_t fleck = ShadeColor(base, 1.18f);
for (int y = 0; y < th; ++y)
{
int x0 = 0, x1 = 0;
g_proj.TileRow(y, x0, x1);
for (int x = x0; x < x1; ++x)
{
uint32_t c = base;
if (x < x0 + 2 || x >= x1 - 2) c = edge; // рант тайла
else if (((x * 7 + y * 13) % 23) == 0) c = fleck; // «металлическая» рябь
s.Set(x, y, c);
}
}
return s;
}
// Рисунок ПОВЕРХ готовой плиты. Отдельно от MakeFloor намеренно: тайлы могут
// приехать из SpriteForge, и варианты обязаны получаться из них тем же кодом —
// иначе половина пола была бы нарисованной, а половина нет.
//
// Пишет только по непрозрачным пикселям, поэтому силуэт ромба не меняется:
// сетка пола остаётся ровно там же, где была.
void AddFloorDetail(Sprite& s, int variant, uint32_t base)
{
if (variant <= 0) return; // ровная плита
const uint32_t crack = ShadeColor(base, 0.42f);
const uint32_t deep = ShadeColor(base, 0.30f);
const uint32_t lip = ShadeColor(base, 1.28f);
auto paint = [&](int x, int y, uint32_t c) {
if (AlphaOf(s.Get(x, y)) != 0) s.Set(x, y, c);
};
// Размеры берутся у самой плиты, а не у констант тайла: рисунок обязан
// ложиться на ту плиту, которую выдала проекция, — ромб или квадрат.
const int tw = s.w;
const int th = s.h;
switch (variant)
{
case 1:
{
// Трещина: ломаная от левого края плиты к правому. Ход задаётся
// хешем от x, а не генератором, — плита обязана быть одинаковой
// в каждом прогоне.
int y = th / 2;
for (int x = 3; x < tw - 3; ++x)
{
const float r = Hash01(Hash2(0xC7A1u, uint32_t(x)));
y += (r < 0.33f) ? -1 : (r > 0.66f ? 1 : 0);
y = std::max(1, std::min(th - 2, y));
paint(x, y, crack);
if ((x & 3) == 0) paint(x, y + 1, deep);
}
break;
}
case 2:
{
// Решётка стока: в изометрии слаты идут вдоль оси сетки, поэтому
// шаг по x вдвое больше шага по y — иначе решётка «встаёт» из пола.
// При виде сверху наклонять нечего, слаты горизонтальные.
//
// Контраст НАМЕРЕННО низкий. Пол — фон: на нём читаются кровь,
// трассеры и твари, и решётка, нарисованная в полную силу, спорит
// со всеми тремя. Она должна угадываться, а не бросаться в глаза.
const uint32_t slat = ShadeColor(base, 0.74f);
const uint32_t gap = ShadeColor(base, 0.90f);
const bool slanted = (g_proj.mode == ViewMode::ISO_2_1);
// Доли, а не пиксели: на плите любого размера решётка занимает ту
// же часть плиты. Для тайла 32x16 числа совпадают с прежними —
// рисунок пола не должен «поехать» от одного лишь переезда кода.
const int step = std::max(2, th / 8);
const int margin = std::max(1, tw * 9 / 32);
for (int i = -2; i <= 2; ++i)
{
const int y0 = th / 2 + i * step;
for (int x = margin; x < tw - margin; ++x)
{
const int y = y0 + (slanted ? (x - tw / 2) / 4 : 0);
paint(x, y, (i & 1) ? gap : slat);
}
}
break;
}
default:
{
// Труха и щебень: тёмные кляксы плюс редкие светлые крупинки.
for (int y = 0; y < th; ++y)
for (int x = 0; x < tw; ++x)
{
const float r = Hash01(Hash2(uint32_t(x) * 73u + 0x5B3Du, uint32_t(y)));
if (r < 0.10f) paint(x, y, deep);
else if (r < 0.14f) paint(x, y, crack);
else if (r > 0.965f) paint(x, y, lip);
}
break;
}
}
}
Sprite MakeWall(uint32_t top)
{
const int tw = g_proj.tileW;
const int th = g_proj.tileH;
Sprite s;
s.Init(tw, g_proj.wallH, 0);
s.ax = tw / 2;
s.ay = g_proj.WallAnchorY(); // в изометрии низ блока садится на ромб пола
const uint32_t left = ShadeColor(top, 0.62f);
const uint32_t right = ShadeColor(top, 0.80f);
// верхняя грань — по форме плиты
for (int y = 0; y < th; ++y)
{
int x0 = 0, x1 = 0;
g_proj.TileRow(y, x0, x1);
for (int x = x0; x < x1; ++x) s.Set(x, y, top);
}
// Вертикальные грани: от нижней кромки крыши вниз на высоту тайла. При
// виде сверху их нет вообще — блок, показанный сверху И сбоку сразу, это
// две проекции в одном спрайте.
if (g_proj.WallHasSides())
{
for (int x = 0; x < tw; ++x)
{
int bottom = -1;
for (int y = 0; y < th; ++y)
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
if (bottom < 0) continue;
const uint32_t side = (x < tw / 2) ? left : right;
for (int y = bottom + 1; y <= bottom + th && y < g_proj.wallH; ++y)
s.Set(x, y, side);
}
}
OutlineSilhouette(s, ShadeColor(top, 0.35f));
return s;
}
// -----------------------------------------------------------------------------
// Темы. Множители по каналам, а не отдельные наборы спрайтов: пока тайлсет
// один, тема обязана менять МАТЕРИАЛ, оставляя рисунок — трещины, решётку,
// ранты — на месте. Игрок читает планировку по рисунку, и перерисовывать его
// на каждую тему значило бы учить его читать пол заново в каждой комнате.
//
// Базовая тема (0) — множители 1.0: она и есть то, что нарисовал Generate()
// или привезла SpriteForge, и трогать её перекраской нельзя.
// -----------------------------------------------------------------------------
struct ThemeTint
{
const char* name;
float r, g, b;
};
const ThemeTint THEMES[THEME_COUNT] = {
{"STEEL", 1.00f, 1.00f, 1.00f}, // сталь: базовый набор как есть
{"RUST", 1.28f, 0.94f, 0.66f}, // ржавчина: тёплая, выцветшая
{"FLESH", 1.18f, 0.70f, 0.74f}, // плоть: розово-багровая
{"FROST", 0.78f, 0.96f, 1.32f}, // наледь: холодная и светлая
};
Sprite TintSprite(const Sprite& src, const ThemeTint& t)
{
Sprite out = src;
for (uint32_t& px : out.px)
{
if (AlphaOf(px) == 0) continue; // силуэт не трогаем
const float r = Clampf(float(px & 0xFFu) * t.r, 0.0f, 255.0f);
const float g = Clampf(float((px >> 8) & 0xFFu) * t.g, 0.0f, 255.0f);
const float b = Clampf(float((px >> 16) & 0xFFu) * t.b, 0.0f, 255.0f);
px = RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(px));
}
return out;
}
} // namespace
const char* ThemeName(uint8_t theme)
{
return (theme < THEME_COUNT) ? THEMES[theme].name : "?";
}
void TileSet::Generate()
{
// Шахматка x рисунок: два тона плиты, на каждом — все варианты детали.
const uint32_t floorTint[2] = {RGBA(42, 46, 53), RGBA(50, 54, 62)};
for (int p = 0; p < 2; ++p)
for (int v = 0; v < FLOOR_VARIANTS; ++v)
{
floor[0][p][v] = MakeFloor(floorTint[p]);
AddFloorDetail(floor[0][p][v], v, floorTint[p]);
}
wall[0][0] = MakeWall(RGBA(78, 84, 96));
wall[0][1] = wall[0][0];
BuildThemes();
}
void TileSet::BuildThemes()
{
for (int t = 1; t < THEME_COUNT; ++t)
{
for (int p = 0; p < 2; ++p)
for (int v = 0; v < FLOOR_VARIANTS; ++v)
floor[t][p][v] = TintSprite(floor[0][p][v], THEMES[t]);
for (int i = 0; i < 2; ++i) wall[t][i] = TintSprite(wall[0][i], THEMES[t]);
}
}
bool TileSet::LoadFromForge(const SpriteLibrary& lib)
{
if (!lib.IsOpen()) return false;
// Библиотека нарисована в изометрии: ромб 32x16 и блок с гранями. Положить
// её в вид сверху нельзя — это не «неточно», это другой ракурс. Отказ здесь
// оставляет процедурный набор целиком, ровно как при любой другой осечке.
if (g_proj.mode != ViewMode::ISO_2_1) return false;
// Холст тайла канонический — TILE_W x TILE_H с ромбом на своём месте.
// SpriteForge обрезает кадр по силуэту (32x16 приходит как 30x16), а
// ShadeQuad из Lighting::QuadFor считается в координатах спрайта: съехавший
// на пиксель холст увёл бы свет пола относительно света стен.
const FramePlacement floorPlace{TILE_W, TILE_H, TILE_HALF_W, TILE_HALF_H, -1, -1};
// У стены pivot кадра — нижняя строка блока, она садится на дно холста.
// Привязка же спрайта остаётся в центре крыши: низ блока ложится на ромб
// пола ровно так же, как у MakeWall.
const FramePlacement wallPlace{TILE_W, WALL_H, TILE_HALF_W, WALL_H - 1,
TILE_HALF_W, TILE_HALF_H + TILE_H};
// Явное приведение: sf_asset_id — обычный enum, и MSVC считает его знаковым,
// хотя хеши ассетов доходят до 32 бит (C4245 на SF_ASSET_WALL_BLOCK).
const uint32_t floorIds[FLOOR_VARIANTS] = {
uint32_t(SF_ASSET_FLOOR_CLEAN), uint32_t(SF_ASSET_FLOOR_CRACKED),
uint32_t(SF_ASSET_FLOOR_GRATE), uint32_t(SF_ASSET_FLOOR_BIOMASS),
};
Sprite newFloor[2][FLOOR_VARIANTS];
for (int v = 0; v < FLOOR_VARIANTS; ++v)
{
Sprite tile;
if (!lib.Read(floorIds[v], 0, floorPlace, tile)) return false;
newFloor[0][v] = tile;
newFloor[1][v] = std::move(tile);
}
Sprite newWall[2];
if (!lib.Read(uint32_t(SF_ASSET_WALL_INDUSTRIAL), 0, wallPlace, newWall[0])) return false;
if (!lib.Read(uint32_t(SF_ASSET_WALL_INFESTED), 0, wallPlace, newWall[1])) return false;
// Всё прочитано — только теперь подменяем набор и пересобираем темы.
for (int p = 0; p < 2; ++p)
for (int v = 0; v < FLOOR_VARIANTS; ++v) floor[0][p][v] = std::move(newFloor[p][v]);
wall[0][0] = std::move(newWall[0]);
wall[0][1] = std::move(newWall[1]);
BuildThemes();
return true;
}