squad-proto/src/render/sprites.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

779 lines
30 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 "render/sprites.h"
#include <utility>
#include "engine/hash.h"
#include "engine/math.h"
#include "engine/sfa_library.h"
#include "monster_sprites.h"
namespace
{
// DiamondRowN / DiamondRow / OutlineSilhouette / ShadeColor переехали в
// engine/pixel.h: ими рисует и движок (тайлы пола и стен), и игра. Две копии
// наклона ромба означали бы обстановку в одной изометрии, а пол — в другой.
// Капсула с обводкой и бликом.
Sprite MakeCapsule(int w, int h, float radius, uint32_t body)
{
Sprite s;
s.Init(w, h, 0);
s.ax = w / 2;
s.ay = h - 2; // «ноги» чуть выше нижнего края
const uint32_t outline = ShadeColor(body, 0.35f);
const uint32_t hilite = ShadeColor(body, 1.35f);
const uint32_t shadow = ShadeColor(body, 0.72f);
const float cx = (w - 1) * 0.5f;
const Vec2 a{cx, radius - 0.5f};
const Vec2 b{cx, h - radius - 0.5f};
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
const float d = DistancePointToSegment({float(x), float(y)}, a, b);
if (d > radius) continue;
if (d > radius - 1.0f) { s.Set(x, y, outline); continue; }
uint32_t c = body;
if (float(x) < cx - 0.5f && y < h / 2) c = hilite;
else if (float(x) > cx + 1.0f) c = shadow;
s.Set(x, y, c);
}
}
return s;
}
// --- оружие в руках ----------------------------------------------------------
//
// Ствол и клинок дорисовываются ПОВЕРХ капсулы по правому краю силуэта: на
// 14 пикселях иначе не видно ничего. Разница между длинным и тяжёлым стволом —
// два пикселя толщины и один длины, и этого достаточно: игрок различает их
// не разглядывая, а по общему пятну.
const uint32_t kSteel = RGBA(168, 176, 190);
const uint32_t kSteelDark = RGBA(74, 78, 88);
const uint32_t kEdge = RGBA(238, 244, 255);
void DrawBarrel(Sprite& s, int len, int thick, bool heavy)
{
const int bx = s.w - 2;
const int by = s.h - 7; // на уровне пояса
for (int t = 0; t < thick; ++t)
for (int i = 0; i < len; ++i)
s.Set(bx - i, by + t, (t == 0) ? kSteel : kSteelDark);
// Дуло у тяжёлого ствола шире самого ствола: пятно на конце и есть то,
// по чему дробовик отличается от карабина.
if (heavy)
{
s.Set(bx + 1, by, kSteel);
s.Set(bx + 1, by + 1, kSteel);
s.Set(bx + 1, by - 1, kSteelDark);
}
// Рукоять вниз — иначе ствол читается как случайная полоска.
s.Set(bx - len + 1, by + thick, kSteelDark);
}
void DrawBlade(Sprite& s, int len, bool guard)
{
const int bx = s.w - 2;
const int y0 = std::max(1, s.h - 4 - len);
for (int y = y0; y < s.h - 4; ++y) s.Set(bx, y, (y < s.h / 2) ? kEdge : kSteel);
if (guard)
{
s.Set(bx - 1, s.h - 5, kSteel);
s.Set(bx, s.h - 5, kSteel);
}
}
Sprite MakeBody(BodyLook look, uint32_t body)
{
switch (look)
{
case BodyLook::GUN_SHORT:
{
Sprite s = MakeCapsule(9, 13, 3.6f, body);
DrawBarrel(s, 2, 1, false);
return s;
}
case BodyLook::GUN_HEAVY:
{
Sprite s = MakeCapsule(12, 15, 4.8f, body);
DrawBarrel(s, 4, 2, true);
return s;
}
case BodyLook::BLADE_LONG:
{
Sprite s = MakeCapsule(12, 15, 4.6f, body);
DrawBlade(s, 9, true);
return s;
}
case BodyLook::BLADE_SHORT:
{
Sprite s = MakeCapsule(10, 14, 4.0f, body);
DrawBlade(s, 4, false);
return s;
}
case BodyLook::GUN_LONG:
default:
{
Sprite s = MakeCapsule(10, 14, 4.0f, body);
DrawBarrel(s, 4, 1, false);
return s;
}
}
}
// Павший: та же капсула, положенная набок и обесцвеченная. Он остаётся на полу
// до конца вылазки — отряд обязан видеть, где и кого потерял.
Sprite MakeCorpse(uint32_t body)
{
const Sprite up = MakeCapsule(10, 14, 4.0f, ShadeColor(body, 0.45f));
Sprite s;
s.Init(up.h, up.w + 2, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
for (int y = 0; y < up.h; ++y)
for (int x = 0; x < up.w; ++x)
{
const uint32_t c = up.Get(x, y);
if (AlphaOf(c) == 0) continue;
s.Set(y, s.h - 3 - x, c); // поворот на 90 градусов
}
return s;
}
// --- твари -------------------------------------------------------------------
//
// Не капсулы. Тварь на 480x270 занимает 14-24 пикселя, и опознаётся она ПЯТНОМ:
// сначала силуэт, потом цвет, детали — никогда. Поэтому каждый вид строится как
// объединение эллипсов (голова, корпус, конечности), а объём даёт не «блик
// слева», а свет по КРАЮ силуэта: подсвечена верхне-левая кромка, затенена
// нижне-правая. Тот же приём, что у стены, — и твари садятся в один свет с ней.
// Эллипс силуэта. Отрицательный ry допустим только по недосмотру, поэтому он
// зажат: вырожденный эллипс молча съел бы часть тела.
struct Blob
{
float cx, cy, rx, ry;
};
bool InsideBlob(const Blob& b, float x, float y)
{
const float dx = (x - b.cx) / std::max(0.5f, b.rx);
const float dy = (y - b.cy) / std::max(0.5f, b.ry);
return dx * dx + dy * dy <= 1.0f;
}
void FillBlobs(Sprite& s, const Blob* blobs, int count, uint32_t body)
{
for (int y = 0; y < s.h; ++y)
for (int x = 0; x < s.w; ++x)
for (int i = 0; i < count; ++i)
if (InsideBlob(blobs[i], float(x), float(y)))
{
s.Set(x, y, body);
break;
}
}
// Объём по краю силуэта: свет падает сверху-слева. Проверяется НЕ сосед, а
// пиксель через два, — иначе на теле шириной 8 подсветка съедает его целиком.
void ShadeSilhouette(Sprite& s, uint32_t body)
{
const uint32_t hilite = ShadeColor(body, 1.32f);
const uint32_t shadow = ShadeColor(body, 0.66f);
const uint32_t deep = ShadeColor(body, 0.48f);
const Sprite copy = s;
for (int y = 0; y < s.h; ++y)
for (int x = 0; x < s.w; ++x)
{
if (AlphaOf(copy.Get(x, y)) == 0) continue;
const bool litEdge = AlphaOf(copy.Get(x - 2, y - 1)) == 0;
const bool darkEdge = AlphaOf(copy.Get(x + 2, y + 1)) == 0;
if (litEdge && !darkEdge) s.Set(x, y, hilite);
else if (darkEdge && !litEdge) s.Set(x, y, shadow);
else if (darkEdge) s.Set(x, y, deep);
}
}
// Рваный низ: несколько столбцов силуэта укорачиваются. Дешёвый приём, но
// именно он отличает бредущее тело от аккуратной капсулы отряда.
void TatterBottom(Sprite& s, uint32_t seed, int maxCut)
{
for (int x = 0; x < s.w; ++x)
{
const int cut = int(Hash01(Hash2(seed, uint32_t(x))) * float(maxCut + 1));
for (int i = 0; i < cut; ++i) s.Set(x, s.h - 1 - i, 0);
}
}
// Пара глаз. Единственная деталь, которую видно на всех размерах, — и она же
// говорит, куда тварь смотрит.
void DrawEyes(Sprite& s, int x0, int x1, int y, uint32_t col)
{
s.Set(x0, y, col);
s.Set(x1, y, col);
}
Sprite MakeShambler(uint32_t body)
{
// Сутулый: голова свешена вперёд и НИЖЕ плеч, руки висят. Читается как
// «идёт, но еле-еле» — ровно то, что он и делает.
Sprite s;
s.Init(13, 17, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
const Blob parts[] = {
{6.5f, 10.0f, 4.2f, 6.0f}, // корпус
{8.0f, 4.5f, 3.0f, 2.8f}, // голова, свешена вправо-вперёд
{2.5f, 10.5f, 1.8f, 4.5f}, // левая рука
{10.5f, 11.5f, 1.6f, 4.0f}, // правая рука, длиннее
{5.0f, 15.0f, 2.4f, 2.4f}, // ноги
};
FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body);
ShadeSilhouette(s, body);
// Провал на месте лица: у бредущего его нет, и это страшнее любых глаз.
const uint32_t maw = ShadeColor(body, 0.20f);
for (int x = 7; x < 10; ++x) s.Set(x, 5, maw);
s.Set(8, 6, maw);
TatterBottom(s, 0x5A17u, 2);
OutlineSilhouette(s, ShadeColor(body, 0.28f));
return s;
}
Sprite MakeRusher(uint32_t body)
{
// Узкий, высокий, наклонён вперёд: в толпе бредущих его видно по одному
// силуэту, и наклон говорит, что он сейчас прыгнет.
Sprite s;
s.Init(11, 18, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
const Blob parts[] = {
{5.0f, 10.5f, 2.6f, 6.0f}, // тонкий корпус
{7.0f, 3.8f, 2.4f, 3.2f}, // вытянутая вперёд башка
{8.6f, 8.0f, 1.4f, 3.6f}, // передняя лапа вынесена
{2.0f, 11.0f, 1.3f, 3.4f}, // задняя
{4.5f, 15.5f, 2.2f, 2.2f},
};
FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body);
ShadeSilhouette(s, body);
// Глаза горят: он единственный, кто добежит, и знать это надо заранее.
DrawEyes(s, 6, 8, 3, ShadeColor(body, 1.85f));
TatterBottom(s, 0x9E37u, 3);
OutlineSilhouette(s, ShadeColor(body, 0.30f));
return s;
}
Sprite MakeSpitter(uint32_t body)
{
// Приземистый мешок на коротких ногах с распахнутой пастью: единственный,
// кто бьёт издалека, и по картинке это понятно до первого плевка.
Sprite s;
s.Init(16, 14, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
const Blob parts[] = {
{8.0f, 8.0f, 6.6f, 4.8f}, // раздутый корпус
{8.0f, 3.6f, 3.4f, 2.6f}, // приплюснутая голова
{3.0f, 12.0f, 1.8f, 1.8f}, // ноги-обрубки
{13.0f, 12.0f, 1.8f, 1.8f},
};
FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body);
ShadeSilhouette(s, body);
// Пасть — тёмный провал во всю голову. Это его «ствол».
const uint32_t maw = ShadeColor(body, 0.18f);
const uint32_t bile = ShadeColor(body, 1.70f);
for (int x = 5; x < 12; ++x) s.Set(x, 4, maw);
for (int x = 6; x < 11; ++x) s.Set(x, 5, maw);
s.Set(7, 3, bile);
s.Set(10, 3, bile);
// Желчные пузыри на боках: намёк на то, чем именно он плюётся.
for (int y = 7; y < 11; ++y)
for (int x = 2; x < s.w - 2; ++x)
{
if (AlphaOf(s.Get(x, y)) == 0) continue;
if (((x * 3 + y * 7) % 13) == 0) s.Set(x, y, bile);
}
OutlineSilhouette(s, ShadeColor(body, 0.30f));
return s;
}
Sprite MakeBrute(uint32_t body)
{
// Гора мяса: плечи ШИРЕ таза, голова утоплена между ними, кулаки до пола.
// Он должен читаться как «этого вдвоём», причём с одного взгляда.
Sprite s;
s.Init(22, 25, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
const Blob parts[] = {
{11.0f, 9.0f, 9.0f, 6.0f}, // плечевой пояс
{11.0f, 16.0f, 5.5f, 6.0f}, // туловище
{11.0f, 4.5f, 3.0f, 2.8f}, // утопленная голова
{ 2.5f, 17.0f, 2.6f, 5.5f}, // левая рука до пола
{19.5f, 17.0f, 2.6f, 5.5f}, // правая
{ 7.0f, 23.0f, 3.0f, 2.2f}, // ноги
{15.0f, 23.0f, 3.0f, 2.2f},
};
FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body);
ShadeSilhouette(s, body);
const uint32_t bone = ShadeColor(body, 1.55f);
const uint32_t dark = ShadeColor(body, 0.34f);
// Костяные наросты на плечах — то, чем он держит удар.
for (int i = 0; i < 4; ++i)
{
s.Set(3 + i, 5 + i, bone);
s.Set(s.w - 4 - i, 5 + i, bone);
}
// Кулаки: то, чем он бьёт, обязано быть видно отдельно от рук.
for (int y = s.h - 6; y < s.h - 2; ++y)
for (int x = 1; x < 4; ++x)
{
if (AlphaOf(s.Get(x, y)) != 0) s.Set(x, y, bone);
if (AlphaOf(s.Get(s.w - 1 - x, y)) != 0) s.Set(s.w - 1 - x, y, bone);
}
DrawEyes(s, 10, 12, 4, dark);
OutlineSilhouette(s, ShadeColor(body, 0.25f));
return s;
}
Sprite MakeLurker(uint32_t body)
{
// Хват: низкий, паучий, на длинных лапах. Раньше он был просто капсулой —
// и единственная тварь, которая вырывает бойца из строя, выглядела как
// мишень. Силуэт должен кричать «не подходи один».
Sprite s;
s.Init(17, 15, 0);
s.ax = s.w / 2;
s.ay = s.h - 2;
const Blob parts[] = {
{8.5f, 9.0f, 4.0f, 3.6f}, // низкое тело
{8.5f, 5.5f, 2.4f, 2.2f}, // маленькая голова
{2.0f, 7.0f, 1.3f, 4.6f}, // лапы: вынесены далеко вбок
{15.0f, 7.0f, 1.3f, 4.6f},
{4.0f, 12.5f, 1.4f, 2.2f},
{13.0f, 12.5f, 1.4f, 2.2f},
};
FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body);
ShadeSilhouette(s, body);
// Когти на концах передних лап — то, чем он держит.
const uint32_t claw = ShadeColor(body, 1.75f);
s.Set(1, 2, claw); s.Set(2, 3, claw);
s.Set(s.w - 2, 2, claw); s.Set(s.w - 3, 3, claw);
DrawEyes(s, 7, 10, 5, ShadeColor(body, 1.9f));
OutlineSilhouette(s, ShadeColor(body, 0.26f));
return s;
}
// --- разрушаемая обстановка --------------------------------------------------
//
// Не капсулы. Хлам обязан читаться как ЧАСТЬ УРОВНЯ, а не как тварь, которую
// забыли покрасить: коробка стоит в той же изометрии 2:1, что пол и стены, и
// светится тем же ShadeQuad. Правило чтения — то же, что у тварей: силуэт несёт
// вид, цвет — материал.
//
// Привязка у всех считается одинаково: ay = h - dh/2, то есть центр НИЖНЕГО
// ромба (основания). Ровно так же посажена стена в MakeWall — иначе обстановка
// поехала бы относительно пола на половину тайла.
// Изометрическая коробка: верхний ромб dw x dh + вертикальные грани высотой tall.
Sprite MakeBox(int dw, int dh, int tall, uint32_t top)
{
Sprite s;
s.Init(dw, dh + tall, 0);
s.ax = dw / 2;
s.ay = (dh + tall) - dh / 2;
const uint32_t left = ShadeColor(top, 0.60f);
const uint32_t right = ShadeColor(top, 0.80f);
for (int y = 0; y < dh; ++y)
{
int x0 = 0, x1 = 0;
DiamondRowN(y, dw, dh, x0, x1);
for (int x = x0; x < x1; ++x) s.Set(x, y, top);
}
for (int x = 0; x < dw; ++x)
{
int bottom = -1;
for (int y = 0; y < dh; ++y)
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
if (bottom < 0) continue;
const uint32_t side = (x < dw / 2) ? left : right;
for (int y = bottom + 1; y <= bottom + tall && y < s.h; ++y) s.Set(x, y, side);
}
return s;
}
Sprite MakeCrate(uint32_t body)
{
Sprite s = MakeBox(20, 10, 11, body);
const uint32_t plank = ShadeColor(body, 1.22f);
const uint32_t dark = ShadeColor(body, 0.42f);
const uint32_t band = ShadeColor(body, 0.52f);
// Доски: горизонтальный шов посередине граней и косой крест на них.
// Без него коробка читается как кубик, а не как заколоченный ящик.
for (int x = 0; x < s.w; ++x)
{
if (AlphaOf(s.Get(x, s.h - 6)) == 0) continue;
s.Set(x, s.h - 6, band);
}
for (int i = 0; i < 7; ++i)
{
s.Set(3 + i, s.h - 9 + i, plank);
s.Set(s.w - 4 - i, s.h - 9 + i, plank);
}
// Светлая грань верхней крышки — единственное, что даёт объём на 20 пикселях.
for (int y = 1; y < 4; ++y)
{
int x0 = 0, x1 = 0;
DiamondRowN(y, 20, 10, x0, x1);
for (int x = x0 + 1; x < x1 - 1; ++x) s.Set(x, y, plank);
}
OutlineSilhouette(s, dark);
return s;
}
Sprite MakeBarrel(uint32_t body)
{
// Бочка — цилиндр: верх эллипс, бока прямые. Ромб тут врёт, поэтому
// силуэт строится отдельно, а не через MakeBox.
const int w = 15, capH = 7, tall = 13;
Sprite s;
s.Init(w, capH + tall, 0);
s.ax = w / 2;
s.ay = s.h - capH / 2;
const uint32_t lid = ShadeColor(body, 1.25f);
const uint32_t left = ShadeColor(body, 0.95f);
const uint32_t right = ShadeColor(body, 0.62f);
const uint32_t hoop = ShadeColor(body, 1.45f);
const uint32_t rust = ShadeColor(body, 0.70f);
const float cx = (w - 1) * 0.5f;
const float rx = cx, ry = (capH - 1) * 0.5f;
// Крышка: эллипс rx x ry.
for (int y = 0; y < capH; ++y)
for (int x = 0; x < w; ++x)
{
const float dx = (float(x) - cx) / rx;
const float dy = (float(y) - ry) / std::max(0.5f, ry);
if (dx * dx + dy * dy > 1.0f) continue;
s.Set(x, y, (y < capH / 2) ? lid : body);
}
// Бока: каждый столбец крышки продлевается вниз, к низу чуть сужаясь —
// клёпаный бок, а не труба.
for (int x = 0; x < w; ++x)
{
int bottom = -1;
for (int y = 0; y < capH; ++y)
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
if (bottom < 0) continue;
const float t = std::fabs(float(x) - cx) / rx;
const int shrink = (t > 0.86f) ? 2 : 0; // нижняя кромка скруглена
const uint32_t side = (float(x) < cx - 0.5f) ? left : right;
for (int y = bottom + 1; y <= bottom + tall - shrink && y < s.h; ++y)
s.Set(x, y, side);
}
// Два обруча: то, по чему бочка отличается от тумбы одним взглядом.
for (int x = 0; x < w; ++x)
for (const int hy : {capH + 3, capH + 8})
{
if (hy >= s.h || AlphaOf(s.Get(x, hy)) == 0) continue;
s.Set(x, hy, hoop);
}
// Ржавые потёки: детерминированная рябь, как «металлическая» на полу.
for (int y = capH; y < s.h; ++y)
for (int x = 0; x < w; ++x)
{
if (AlphaOf(s.Get(x, y)) == 0) continue;
if (((x * 5 + y * 11) % 17) == 0) s.Set(x, y, rust);
}
OutlineSilhouette(s, ShadeColor(body, 0.38f));
return s;
}
Sprite MakeLocker(uint32_t body)
{
// Узкий и высокий: в ряду хлама шкафчик опознаётся по одному силуэту.
Sprite s = MakeBox(14, 7, 21, body);
const uint32_t door = ShadeColor(body, 0.88f);
const uint32_t seam = ShadeColor(body, 0.45f);
const uint32_t latch = RGBA(196, 178, 120);
const uint32_t vent = ShadeColor(body, 0.55f);
// Дверцы: шов по центру правой грани и жалюзи сверху.
const int seamX = s.w / 2;
for (int y = s.h - 21; y < s.h - 2; ++y) s.Set(seamX, y, seam);
for (int i = 0; i < 3; ++i)
{
const int y = s.h - 19 + i * 2;
for (int x = seamX + 2; x < s.w - 2; ++x) s.Set(x, y, vent);
}
for (int y = 0; y < s.h; ++y)
if (AlphaOf(s.Get(seamX - 3, y)) != 0 && y > s.h - 20) s.Set(seamX - 3, y, door);
s.Set(seamX + 1, s.h - 11, latch);
s.Set(seamX + 1, s.h - 10, latch);
OutlineSilhouette(s, ShadeColor(body, 0.35f));
return s;
}
// --- иконки предметов --------------------------------------------------------
//
// 14x14, по одной форме на ВИД предмета. Читать нужно вид, а не модель: имя
// пишется рядом текстом, и дублировать его пиктограммой незачем.
Sprite MakeIcon(ItemId id)
{
const ItemDef def = ItemDefOf(id);
Sprite s;
s.Init(14, 14, 0);
s.ax = 7;
s.ay = 7;
if (def.kind == ItemKind::NONE) return s;
const uint32_t c = RGBA(def.r, def.g, def.b);
const uint32_t dark = ShadeColor(c, 0.45f);
const uint32_t hot = ShadeColor(c, 1.35f);
switch (def.kind)
{
case ItemKind::FIREARM:
for (int x = 1; x < 12; ++x) s.Set(x, 6, c);
for (int x = 1; x < 12; ++x) s.Set(x, 7, dark);
for (int y = 7; y < 11; ++y) s.Set(3, y, dark);
s.Set(11, 5, hot);
s.Set(12, 6, hot);
break;
case ItemKind::BLADE:
for (int i = 0; i < 10; ++i) s.Set(2 + i, 11 - i, (i > 4) ? hot : c);
s.Set(3, 10, dark);
s.Set(4, 11, dark);
s.Set(2, 9, dark);
break;
case ItemKind::ARMOR:
for (int y = 2; y < 12; ++y)
{
const int inset = (y < 9) ? 2 : (2 + (y - 8));
for (int x = inset; x < 14 - inset; ++x)
s.Set(x, y, (y == 2 || x == inset || x == 13 - inset) ? dark : c);
}
break;
case ItemKind::TRINKET:
for (int y = 0; y < 14; ++y)
for (int x = 0; x < 14; ++x)
{
const float dx = float(x) - 6.5f, dy = float(y) - 6.5f;
const float d = std::sqrt(dx * dx + dy * dy);
if (d > 5.5f) continue;
s.Set(x, y, (d > 4.5f) ? dark : c);
}
s.Set(5, 4, hot);
s.Set(4, 5, hot);
break;
case ItemKind::CONSUMABLE:
for (int i = 3; i < 11; ++i)
{
s.Set(i, 6, c);
s.Set(i, 7, c);
s.Set(6, i, c);
s.Set(7, i, c);
}
s.Set(6, 6, hot);
s.Set(7, 7, hot);
break;
default: break;
}
return s;
}
} // namespace
uint32_t SlotColor(int slot)
{
// 5 различимых цветов — по одному на слот отряда. Номер бойца читается
// только по цвету, поэтому таблица одна на всю игру.
static const uint32_t cols[NUM_AGENTS] = {
RGBA(80, 200, 220),
RGBA(232, 176, 72),
RGBA(116, 204, 116),
RGBA(212, 116, 200),
RGBA(116, 148, 240),
};
return cols[(slot >= 0 && slot < NUM_AGENTS) ? slot : 0];
}
BodyLook LookOf(ItemId weapon)
{
switch (weapon)
{
case ItemId::PISTOL: return BodyLook::GUN_SHORT;
case ItemId::SHOTGUN:
case ItemId::MAGNUM: return BodyLook::GUN_HEAVY;
case ItemId::BOW: return BodyLook::GUN_LONG;
case ItemId::SABER:
case ItemId::CLEAVER: return BodyLook::BLADE_LONG;
case ItemId::KNIFE: return BodyLook::BLADE_SHORT;
default: return BodyLook::GUN_LONG;
}
}
void SpriteSet::Generate()
{
tiles.Generate(); // пол и стены со всеми темами — движок
for (int i = 0; i < NUM_AGENTS; ++i)
{
const uint32_t col = SlotColor(i);
for (int look = 0; look < int(BodyLook::COUNT); ++look)
body[look][i] = MakeBody(BodyLook(look), col);
corpse[i] = MakeCorpse(col);
}
for (int k = 0; k < int(EnemyKind::COUNT); ++k)
{
const EnemyDef def = EnemyDefOf(EnemyKind(k));
const uint32_t col = RGBA(def.r, def.g, def.b);
switch (EnemyKind(k))
{
case EnemyKind::SHAMBLER: enemy[k] = MakeShambler(col); break;
case EnemyKind::RUSHER: enemy[k] = MakeRusher(col); break;
case EnemyKind::SPITTER: enemy[k] = MakeSpitter(col); break;
case EnemyKind::BRUTE: enemy[k] = MakeBrute(col); break;
case EnemyKind::LURKER: enemy[k] = MakeLurker(col); break;
// Обстановка: не тварь и выглядеть как тварь не должна.
case EnemyKind::CRATE: enemy[k] = MakeCrate(col); break;
case EnemyKind::BARREL: enemy[k] = MakeBarrel(col); break;
case EnemyKind::LOCKER: enemy[k] = MakeLocker(col); break;
// Мишень приёмки: вид не менялся ни на пиксель.
default: enemy[k] = MakeCapsule(12, 16, 5.0f, col); break;
}
}
target = enemy[int(EnemyKind::DUMMY)];
for (int i = 0; i < int(ItemId::COUNT); ++i) icon[i] = MakeIcon(ItemId(i));
}
bool SpriteSet::LoadTiles(const SpriteLibrary& lib)
{
if (!lib.IsOpen()) return false;
// Пол и стены читает движок (engine/tileset.cpp) — но НЕ здесь, а последним
// шагом: этот вызов сразу подменяет набор, а весь метод обязан оставаться
// «всё или ничего». Сначала проверяем то, что грузится с этой стороны.
struct PropRow { EnemyKind kind; uint32_t asset; };
static const PropRow propRows[] = {
{EnemyKind::CRATE, uint32_t(SF_ASSET_PROP_CRATE)},
{EnemyKind::BARREL, uint32_t(SF_ASSET_PROP_BARREL)},
{EnemyKind::LOCKER, uint32_t(SF_ASSET_PROP_LOCKER)},
};
Sprite newProps[3];
for (int i = 0; i < 3; ++i)
if (!lib.Read(propRows[i].asset, 0, newProps[i])) return false;
struct IconRow { ItemId item; uint32_t asset; };
#define SF_ICON(item, asset) {ItemId::item, uint32_t(SF_ASSET_ITEM_ICON_##asset)}
static const IconRow iconRows[] = {
SF_ICON(RIFLE, RIFLE), SF_ICON(PISTOL, PISTOL), SF_ICON(SHOTGUN, SHOTGUN),
SF_ICON(SMG, SMG), SF_ICON(MAGNUM, MAGNUM), SF_ICON(BOW, BOW),
SF_ICON(SABER, SABER), SF_ICON(KNIFE, KNIFE), SF_ICON(CLEAVER, CLEAVER),
SF_ICON(JACKET, JACKET), SF_ICON(VEST, VEST), SF_ICON(PLATE, PLATE),
SF_ICON(SHROUD, SHROUD), SF_ICON(LANTERN, LANTERN), SF_ICON(CHARM, CHARM),
SF_ICON(SPURS, SPURS), SF_ICON(BANDAGE, BANDAGE), SF_ICON(STIMPACK, STIMPACK),
};
#undef SF_ICON
Sprite newIcons[int(ItemId::COUNT)];
for (int i = 0; i < int(ItemId::COUNT); ++i) newIcons[i] = icon[i];
for (const IconRow& row : iconRows)
if (!lib.Read(uint32_t(row.asset), 0, newIcons[int(row.item)])) return false;
// Тайлы последними: они подменяются внутри TileSet вместе с пересборкой
// всех тем, и откатывать это было бы некуда. К этой строке всё остальное
// уже прочитано, поэтому единственная возможная осечка — сами тайлы.
if (!tiles.LoadFromForge(lib)) return false;
for (int i = 0; i < 3; ++i) enemy[int(propRows[i].kind)] = std::move(newProps[i]);
for (int i = 0; i < int(ItemId::COUNT); ++i) icon[i] = std::move(newIcons[i]);
return true;
}
bool SpriteSet::LoadEnemies(const SpriteLibrary& lib)
{
if (!lib.IsOpen()) return false;
// Порядок — порядок EnemyKind. DUMMY и обстановка сюда не входят: мишень
// приёмки не двигается по определению, а ящик не ходит.
struct Row { EnemyKind kind; };
static const Row rows[] = {
{EnemyKind::SHAMBLER}, {EnemyKind::RUSHER}, {EnemyKind::SPITTER},
{EnemyKind::BRUTE}, {EnemyKind::LURKER},
};
// Сначала проверяем ВСЕ, потом записываем: полусобранный набор — это одна
// анимированная тварь среди четырёх статичных, что хуже пяти статичных.
for (const Row& r : rows)
for (AnimKind anim : {AnimKind::WALK, AnimKind::ATTACK, AnimKind::HURT,
AnimKind::DIE})
if (lib.AnimationLength(MonsterAsset(r.kind, anim),
uint32_t(SF_ANIM_DEFAULT)) <= 0.0f) return false;
for (const Row& r : rows)
enemyWalk[int(r.kind)] = MonsterAsset(r.kind, AnimKind::WALK);
return true;
}