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

293 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/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;
}