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