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>
This commit is contained in:
z.kirill 2026-08-13 21:07:19 +03:00
parent f637a07d72
commit 7d6266aaf3
100 changed files with 14589 additions and 492 deletions

View File

@ -24,30 +24,89 @@ if(NOT raylib_FOUND)
endif()
# ---------------------------------------------------------------------------
# Исходники
# SpriteForge: загрузчик .sfa это один сторонний заголовок sfa.h из соседнего
# репозитория. Копий в игре нет; собранные ассеты и generated/ отдельно.
# ---------------------------------------------------------------------------
file(GLOB_RECURSE SQUAD_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
set(SPRITEFORGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../spriteforge" CACHE PATH
"Каталог репозитория SpriteForge (нужен только его include/sfa.h)")
add_executable(squad_proto ${SQUAD_SOURCES})
find_path(SPRITEFORGE_INCLUDE_DIR
NAMES sfa.h
HINTS "${SPRITEFORGE_DIR}/include"
NO_DEFAULT_PATH)
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(squad_proto PRIVATE raylib)
if(MSVC)
target_compile_options(squad_proto PRIVATE /W4 /utf-8 /permissive-)
else()
target_compile_options(squad_proto PRIVATE -Wall -Wextra)
if(NOT SPRITEFORGE_INCLUDE_DIR)
message(FATAL_ERROR
"sfa.h не найден в ${SPRITEFORGE_DIR}/include.\n"
"Задать путь: cmake -S . -B build -DSPRITEFORGE_DIR=<путь к репозиторию spriteforge>")
endif()
# ---------------------------------------------------------------------------
# Tile2D движок; всё остальное стоит на нём.
#
# tile2d engine/: софтверный фреймбуфер, проекции 2D, тайлмап,
# тайлсет с темами, формат комнат, генератор уровней,
# каталог сущностей, формат проекта. Про правила боя,
# оружие и отряд не знает ничего.
# tile2d_editor editor/: редактор комнат. Инструмент ДВИЖКА: открывает
# любой проект, а не один конкретный.
# squad_proto src/: игра «Descent», один из проектов движка.
#
# Граница проведена ЛИНКОВКОЙ, а не договорённостью: ни движок, ни редактор не
# видят ни одного символа из sim/ и ai/, поэтому «редактор полез в правила
# игры» ошибка сборки, а не то, что надо заметить на ревью.
#
# Раньше состав движка задавался списком файлов: каталоги src/render и src/world
# делили обе цели, и без списка граница расползлась бы при первом новом .cpp.
# Теперь у движка СВОЙ каталог, и решение «это движок» принимается тем, куда
# положен файл. Список стал бы вторым источником истины о том же самом.
# ---------------------------------------------------------------------------
file(GLOB ENGINE_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/engine/*.cpp")
file(GLOB EDITOR_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/editor/*.cpp")
file(GLOB_RECURSE GAME_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
add_library(tile2d STATIC ${ENGINE_SOURCES})
target_include_directories(tile2d PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}" # engine/..., editor/...
"${CMAKE_CURRENT_SOURCE_DIR}/generated" # spriteforge_assets.h, `sf codegen`
"${SPRITEFORGE_INCLUDE_DIR}")
target_link_libraries(tile2d PUBLIC raylib)
add_executable(squad_proto ${GAME_SOURCES})
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(squad_proto PRIVATE tile2d)
add_executable(tile2d_editor ${EDITOR_SOURCES})
target_link_libraries(tile2d_editor PRIVATE tile2d)
# Пример. Он здесь не «для демонстрации»: это ВТОРОЙ потребитель движка помимо
# игры, и только он ловит случай, когда что-то общее незаметно уехало в src/.
# Игра и редактор такого не заметят редактор не рисует мир, а игра сама и есть
# то место, куда уезжает.
file(GLOB SAMPLE_WALK_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/samples/walk/*.cpp")
add_executable(tile2d_walk ${SAMPLE_WALK_SOURCES})
target_link_libraries(tile2d_walk PRIVATE tile2d)
foreach(target tile2d squad_proto tile2d_editor tile2d_walk)
if(MSVC)
target_compile_options(${target} PRIVATE /W4 /utf-8 /permissive-)
else()
target_compile_options(${target} PRIVATE -Wall -Wextra)
endif()
endforeach()
if(WIN32)
target_link_libraries(squad_proto PRIVATE winmm)
target_link_libraries(tile2d PUBLIC winmm)
endif()
# Единый каталог для бинарника при любом генераторе (VS/Ninja/Makefiles)
set_target_properties(squad_proto PROPERTIES
# Единый каталог для бинарников при любом генераторе (VS/Ninja/Makefiles)
foreach(target squad_proto tile2d_editor tile2d_walk)
set_target_properties(${target} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin")
endforeach()

1090
editor/editor.cpp Normal file

File diff suppressed because it is too large Load Diff

175
editor/editor.h Normal file
View File

@ -0,0 +1,175 @@
#pragma once
// -----------------------------------------------------------------------------
// Редактор комнат — ОТДЕЛЬНОЕ приложение поверх движка.
//
// Он линкуется только с tile2d и не видит ни одного символа из sim/ и ai/.
// Поэтому «редактор знает про правила боя» — это ошибка линковки, а не то, что
// надо ловить на ревью.
//
// Ни одной игры по имени он тоже не знает: где содержимое, говорит проект
// (engine/project.h), а что можно ставить в комнату — его каталог сущностей.
//
// Рисует он ровно тем же софтверным фреймбуфером и той же ПРОЕКЦИЕЙ, что и
// игра: комната в редакторе выглядит так же, как в бою, потому что это один и
// тот же код, а не похожий. В проекте с видом сверху редактор тоже показывает
// вид сверху — иначе он планировал бы не то, что получится.
//
// Раскладка кадра 480x270:
// y 0..11 верхняя панель: файл, операции над комнатами
// x 0..379 холст: изометрия комнаты (или карта предпросмотра уровня)
// x 380..479 правая панель: кисти, свойства комнаты, палитра
// y 232..269 нижняя полоса поверх холста: таблица выбранной точки спавна
// -----------------------------------------------------------------------------
#include <string>
#include <vector>
#include "engine/math.h"
#include "engine/catalog.h"
#include "engine/level_gen.h"
#include "engine/project.h"
#include "engine/room.h"
#include "engine/tileset.h"
#include "engine/framebuffer.h"
#include "engine/sfa_library.h"
// Кисть. Пол, стена и дверь пишут в сетку; спавн ставит точку; ластик снимает
// и то и другое — иначе «убрать» требовало бы помнить, чем это поставлено.
enum class Tool : uint8_t
{
FLOOR = 0,
WALL,
DOOR,
SPAWN,
ERASE,
COUNT,
};
// Что делает элемент интерфейса. Действие, а не «номер кнопки»: раскладка
// панелей меняется чаще, чем набор операций.
enum class Act : uint8_t
{
NONE = 0,
TOOL, PALETTE,
ROOM_PREV, ROOM_NEXT, ROOM_NEW, ROOM_SAVE, ROOM_RELOAD,
W_DEC, W_INC, H_DEC, H_INC,
WEIGHT_DEC, WEIGHT_INC, THEME_CYCLE, START_TOGGLE,
DMIN_DEC, DMIN_INC, DMAX_DEC, DMAX_INC,
PALETTE_TAB,
CHANCE_DEC, CHANCE_INC, OPT_DEC, OPT_INC,
PREVIEW_TOGGLE, PREVIEW_RESEED, PREVIEW_DEEPER, PREVIEW_SHALLOWER, PLAYTEST,
};
struct EdRect
{
int x = 0, y = 0, w = 0, h = 0;
bool Has(float fx, float fy) const
{
return fx >= float(x) && fy >= float(y) && fx < float(x + w) && fy < float(y + h);
}
};
struct EdWidget
{
Act act = Act::NONE;
int arg = 0; // номер кисти, индекс каталога, ...
EdRect rect;
std::string label;
bool active = false; // включённое состояние (нажатая кисть)
bool enabled = true;
};
struct Editor
{
// --- данные ---------------------------------------------------------------
// Проект — первое, что поднимается: из него берутся пути к содержимому,
// вид отображения и кривая глубины. Редактор не знает ни одной игры по
// имени и открывает тот проект, который ему назвали.
Project project;
EntityCatalog catalog;
RoomLibrary lib;
TileSet tiles;
SpriteLibrary forge;
bool forgeOpen = false;
// Рабочая копия правится, а lib.rooms остаётся тем, что лежит на диске:
// без этого «не сохранять» было бы нечем осуществить.
Room room;
int roomIndex = -1;
bool dirty = false;
// --- инструменты ----------------------------------------------------------
Tool tool = Tool::WALL;
int paletteIndex = 0;
// Каталог вырос до трёх десятков записей и в столбик больше не помещается.
// Вкладки по классу — не украшение: твари, обстановка и лут ставятся в
// разные моменты работы, и мешать их в одном списке значит листать чужое.
EntityClass paletteTab = EntityClass::MONSTER;
int paletteScroll = 0;
int selX = -1, selY = -1; // выбранная точка спавна; -1 — не выбрана
// --- вид ------------------------------------------------------------------
int panX = 0, panY = 0; // сдвиг холста в пикселях
int hoverX = -1, hoverY = -1;
bool painting = false; // ЛКМ зажата: кисть тянется
bool erasing = false;
// --- предпросмотр этажа ---------------------------------------------------
bool preview = false;
uint32_t seed = 1;
// Какой этаж собирается в предпросмотре. Отдельно от диапазона комнаты:
// проверять надо не только «попадает ли комната», но и «во что превращается
// весь набор» на этой глубине.
int previewDepth = 1;
Tilemap previewMap;
GeneratedLevel previewLevel;
std::string previewError;
// --- строка состояния -----------------------------------------------------
std::string status;
float statusTimer = 0.0f;
std::vector<EdWidget> widgets;
// Поднять проект, каталог, набор комнат и тайлы. false — работать не с чем
// (нет проекта или каталога сущностей), причина уходит в status.
// projectName — имя или путь; пусто — единственный найденный проект.
bool Init(const std::string& projectName = {});
// Ввод и действия. fit — куда лёг фреймбуфер в окне.
void Update(const ScreenRect& fit, float dt);
// Комната (или карта предпросмотра) в софтверный буфер.
void DrawWorld(Framebuffer& fb);
// Панели поверх готового кадра: примитивы raylib, потому что здесь нужны
// слова, и много.
void DrawUi(const ScreenRect& fit) const;
// Выполнить действие интерфейса. Публично, потому что через него же
// работают ключи командной строки: «показать сборку уровня» с клавиши и
// из аргумента обязаны делать буквально одно и то же.
void Apply(Act act, int arg);
private:
// u — целый масштаб кадра в окне. Нужен, чтобы мерить подписи ТЕМ ЖЕ
// шрифтом и кеглем, каким они будут нарисованы: у встроенного шрифта raylib
// межбуквенный интервал зависит от размера, и посчитанная «по пять пикселей
// на букву» ширина кнопки разъезжается с настоящей.
void BuildWidgets(int u);
void LoadRoomAt(int index);
void SaveCurrent();
void ReloadLibrary();
void NewRoom();
void PaintAt(int x, int y, bool erase);
void Regenerate();
void Say(const std::string& s);
// Спрайт-маркер сущности каталога: кадр из SpriteForge, если он собран,
// иначе капсула по цвету и радиусу. Пустая палитра не должна мешать
// планировать уровень.
const Sprite* MarkerSprite(int catalogIndex) const;
mutable std::vector<Sprite> markers; // кеш, по индексу каталога
};

401
editor/main.cpp Normal file
View File

@ -0,0 +1,401 @@
// -----------------------------------------------------------------------------
// Точка входа редактора комнат. Окно, цикл, финальный блит — и всё.
//
// Приложение отдельное от игры намеренно: редактор линкуется только с tile2d,
// поэтому обратиться к правилам боя он физически не может. Общее у него с игрой
// ровно то, что и должно быть общим, — движок.
//
// Редактор принадлежит ДВИЖКУ, а не игре: он открывает любой проект и ни одной
// игры по имени не знает.
//
// Ключи:
// --windowed не разворачивать окно на весь экран
// --room <имя> открыть эту комнату сразу
// --select N выбрать N-ю точку спавна: иначе панель таблицы весов
// не снять на картинку — она появляется по клику мышью
// --level [сид] сразу показать сборку этажа из набора
// --depth N какой этаж собирать в предпросмотре
// --shot [к] [файл] снять кадр и выйти (для картинок в документации)
// --check проверить набор комнат без окна и выйти
// --project <имя> какой проект открыть (имя каталога в projects/ или путь)
// --tool <команда> инструменты без окна: room, floor, map, validate, ...
// (docs/19-agent.md; --tool help покажет все)
// --term "<кмд>" открыть терминал и сразу выполнить в нём команду
//
// В самом редакторе снизу живёт терминал (клавиша `): те же инструменты, git,
// сборка и запросы к claude/codex прямо из редактора (docs/19-agent.md).
// -----------------------------------------------------------------------------
#include <raylib.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include "engine/app.h"
#include "editor/editor.h"
#include "editor/terminal.h"
#include "editor/tools.h"
#include "engine/level_gen.h"
#include "engine/project.h"
#include "engine/framebuffer.h"
#include "engine/config.h"
namespace
{
// -----------------------------------------------------------------------------
// --check: проверка набора без окна. Это аналог `squad_proto --accept` для
// содержимого: набор комнат — такой же исходник, как код, и ломаться он обязан
// на проверке, а не на глазах у игрока.
//
// Круговой прогон через запись здесь не для полноты, а потому что записывающий
// путь — единственный, которым редактор может УНИЧТОЖИТЬ работу. Читающий
// проверяется сам собой при каждом запуске; пишущий — только здесь.
// -----------------------------------------------------------------------------
bool SameRoom(const Room& a, const Room& b)
{
if (a.name != b.name || a.w != b.w || a.h != b.h) return false;
if (a.weight != b.weight || a.theme != b.theme || a.start != b.start) return false;
for (int y = 0; y < a.h; ++y)
for (int x = 0; x < a.w; ++x)
if (a.At(x, y) != b.At(x, y)) return false;
if (a.spawns.size() != b.spawns.size()) return false;
for (const SpawnPoint& p : a.spawns)
{
const SpawnPoint* q = b.SpawnAt(p.x, p.y);
if (!q) return false;
// Шанс пишется с двумя знаками после точки, поэтому сравнивать его
// на равенство нельзя: 0.333 вернётся как 0.33, и это не ошибка.
if (std::fabs(p.chance - q->chance) > 0.006f) return false;
if (p.options.size() != q->options.size()) return false;
for (const SpawnOption& o : p.options)
if (q->WeightOf(o.id) != o.weight) return false;
}
return true;
}
int RunCheck(const char* projectName)
{
std::string why;
// Проект — первым: без него неизвестно ни где содержимое, ни по какой
// кривой проверять глубины. У движка проектов несколько, и проверять
// каждый надо ЕГО кривой, а не какой-то одной общей.
Project project;
const std::string projectDir = FindProjectDir(projectName ? projectName : "");
if (projectDir.empty() || !project.Load(projectDir, &why))
{
// «Не найден» и «их несколько» — разные беды с одинаковым исходом, и
// угадывать за игрока нельзя ни в том, ни в другом случае. Список
// доступных отвечает на оба вопроса разом.
std::printf("[FAIL] проект: %s\n",
projectDir.empty() ? "не выбран (не найден или их несколько)" : why.c_str());
for (const std::string& n : AvailableProjects())
std::printf(" доступен: --project %s\n", n.c_str());
return 1;
}
project.Apply();
std::printf("проект: %s (%s), вид %s\n", project.name.c_str(), project.dir.c_str(),
ViewModeName(project.view));
EntityCatalog catalog;
if (!catalog.Load(project.catalogPath, &why))
{
std::printf("[FAIL] каталог: %s\n", why.c_str());
return 1;
}
std::printf("каталог: %s, записей %d\n", project.catalogPath.c_str(), catalog.Count());
RoomLibrary lib;
if (!lib.Load(project.roomsDir, &why))
{
std::printf("[FAIL] набор комнат: %s\n", why.c_str());
return 1;
}
int bad = 0;
int warn = 0;
for (const std::string& f : lib.failures)
{
std::printf("[FAIL] %s\n", f.c_str());
++bad;
}
const std::filesystem::path tmp =
std::filesystem::temp_directory_path() / "tile2d_roundtrip.room";
// Заголовок латиницей: ширина в printf считается в БАЙТАХ, и кириллица в
// UTF-8 разъезжается со столбцами ровно вдвое.
std::printf("%-14s %6s %5s %6s %6s %7s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME",
"DEPTH", "STATE");
for (const Room& r : lib.rooms)
{
std::string state = RoomProblem(r);
// Записали -> прочитали -> сравнили. Расхождение означает, что кнопка
// SAVE в редакторе тихо теряет часть комнаты.
Room back;
if (state.empty())
{
if (!SaveRoom(tmp.string(), r, &why)) state = "save failed: " + why;
else if (!LoadRoom(tmp.string(), back, &why)) state = "reload failed: " + why;
else if (!SameRoom(r, back)) state = "roundtrip mismatch";
}
// id, которого нет в каталоге, — это точка, которая не даст никого.
for (const SpawnPoint& p : r.spawns)
for (const SpawnOption& o : p.options)
if (catalog.IndexOf(o.id) < 0 && state.empty())
state = "unknown id `" + o.id + "`";
if (!state.empty()) ++bad;
char depthBuf[16];
if (r.maxDepth > 0) std::snprintf(depthBuf, sizeof(depthBuf), "%d-%d", r.minDepth, r.maxDepth);
else std::snprintf(depthBuf, sizeof(depthBuf), "%d+", r.minDepth);
std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %s\n", r.name.c_str(), r.w, r.h,
r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme), depthBuf,
state.empty() ? "OK" : ("[FAIL] " + state).c_str());
}
std::error_code ec;
std::filesystem::remove(tmp, ec);
if (!lib.HasStartRoom())
{
std::printf("[FAIL] в наборе нет ни одной стартовой комнаты\n");
++bad;
}
// -------------------------------------------------------------------------
// Покрытие глубин. Главный вопрос игры про бесконечный спуск: не «хороша ли
// комната», а «есть ли чем застроить сотый этаж». Набор, у которого на
// глубине не остаётся ни одной подходящей комнаты или ни одной твари,
// обрывает забег — и обрывает молча, если это не проверять.
//
// Проверяется с запасом за последнюю новинку каталога: дальше неё этажи
// собираются из того же набора, и если он там держится, то держится и на
// любой глубине.
// -------------------------------------------------------------------------
const int probeMax = std::max(catalog.DeepestIntroduction(), 12) + 4;
std::printf("\nпокрытие глубин 1..%d\n", probeMax);
for (int d = 1; d <= probeMax; ++d)
{
const int roomsHere = lib.CountAt(d);
const bool startHere = lib.HasStartRoomAt(d);
const bool monsters = catalog.HasAnyAt(EntityClass::MONSTER, d);
const bool loot = catalog.HasAnyAt(EntityClass::LOOT, d);
if (roomsHere > 0 && startHere && monsters && loot) continue;
std::printf("[FAIL] этаж %d:%s%s%s%s\n", d, roomsHere > 0 ? "" : " нет комнат",
startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей",
loot ? "" : " нет лута");
++bad;
}
// Сборка на нескольких сидах И глубинах: комната может быть безупречной
// сама по себе и при этом никогда не стыковаться с соседями, а набор —
// перестать выбирать бюджет опасности на глубине.
Tilemap map;
GeneratedLevel lvl;
std::printf("\n%-6s %-6s %-5s %7s %8s %6s %5s %s\n", "DEPTH", "SEED", "ROOMS", "MONSTER",
"DANGER", "LOOT", "EXIT", "STATE");
for (const int d : {1, 3, 6, 10, 16, 24})
{
for (const uint32_t seed : {1u, 42u})
{
const FloorSpec spec = DescribeFloor(d);
if (!GenerateLevel(lib, catalog, spec, seed, map, lvl, &why))
{
std::printf("[FAIL] этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str());
++bad;
continue;
}
// Авария и сигнал — разные вещи, и мешать их нельзя.
//
// FAIL — этаж собран НЕПРАВИЛЬНО: точки не встали, что-то
// отрезано от старта, некуда спускаться, нет находок;
// WARN — этаж собран правильно, но набор комнат не успевает за
// кривой сложности, и глубина перестаёт ощущаться. Это
// повод дорисовать комнат, а не поломка.
const bool starved = lvl.dangerSpent < spec.dangerBudget * 0.75f;
const bool broken = lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 ||
lvl.loot.empty() || lvl.exitDistance <= 0;
if (broken) ++bad;
else if (starved) ++warn;
std::printf("%-6d %-6u %5d %7d %4.0f/%-3.0f %6d %5d %s\n", d, unsigned(seed),
lvl.roomsPlaced, lvl.monstersPlaced, double(lvl.dangerSpent),
double(spec.dangerBudget), int(lvl.loot.size()), lvl.exitDistance,
broken ? "[FAIL]" : (starved ? "[WARN] набор отстаёт от кривой" : "OK"));
}
}
std::printf(bad == 0 ? "\nИТОГ: набор в порядке\n" : "\nИТОГ: замечаний %d\n", bad);
return bad == 0 ? 0 : 1;
}
} // namespace
int main(int argc, char** argv)
{
bool windowed = false;
const char* openRoom = nullptr;
int selectSpawn = -1;
bool showLevel = false;
int previewDepth = 0; // 0 — оставить тот, что в редакторе по умолчанию
uint32_t levelSeed = 1;
int shotFrame = -1;
const char* shotFile = "editor.png";
const char* projectName = nullptr;
// --term "<команда>": открыть терминал и сразу что-то в нём выполнить.
// Нужен для картинок в документации и для запуска редактора «с делом»:
// открыл — и уже видно, что не сходится.
const char* termCmd = nullptr;
// Имя проекта разбирается ПЕРВЫМ проходом: --check возвращается из цикла
// сразу, и написанный после него --project иначе бы не действовал.
for (int i = 1; i + 1 < argc; ++i)
if (std::strcmp(argv[i], "--project") == 0) projectName = argv[i + 1];
// --tool отдаёт весь хвост командной строки инструментам и выходит. Окна
// здесь тоже нет: этими командами работает не человек с мышью, а скрипт
// или языковая модель (docs/19-agent.md).
for (int i = 1; i < argc; ++i)
if (std::strcmp(argv[i], "--tool") == 0) return RunTool(argc - i - 1, argv + i + 1);
for (int i = 1; i < argc; ++i)
{
// Проверка идёт без окна и до всего остального: её запускают из
// консоли и из сборки, где никакого дисплея может не быть вовсе.
if (std::strcmp(argv[i], "--check") == 0) return RunCheck(projectName);
else if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) ++i;
else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true;
else if (std::strcmp(argv[i], "--term") == 0 && i + 1 < argc) termCmd = argv[++i];
else if (std::strcmp(argv[i], "--room") == 0 && i + 1 < argc) openRoom = argv[++i];
else if (std::strcmp(argv[i], "--select") == 0 && i + 1 < argc)
selectSpawn = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc)
previewDepth = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--level") == 0)
{
showLevel = true;
if (i + 1 < argc && argv[i + 1][0] != '-')
levelSeed = uint32_t(std::strtoul(argv[++i], nullptr, 10));
}
else if (std::strcmp(argv[i], "--shot") == 0)
{
shotFrame = (i + 1 < argc && argv[i + 1][0] != '-') ? TextToInteger(argv[i + 1]) : 30;
if (i + 2 < argc && argv[i + 2][0] != '-') shotFile = argv[i + 2];
}
}
// Окно и кадр — движковые (engine/app.h). Esc здесь обрабатывается вручную:
// пока открыт терминал, он закрывает терминал, а не редактор — отдать Esc
// raylib значило бы терять набранную команду вместе со всем приложением.
App app;
AppConfig cfg;
cfg.title = "Tile2D — редактор комнат";
cfg.borderless = !windowed;
if (!app.Init(cfg)) return 1;
// Редактор большой (кеш маркеров, набор комнат, карта предпросмотра) — на куче.
static Editor editor;
const bool ready = editor.Init(projectName ? projectName : "");
if (!ready) std::printf("редактор: %s\n", editor.status.c_str());
if (openRoom)
{
const int i = editor.lib.IndexOf(openRoom);
if (i >= 0) editor.room = editor.lib.rooms[size_t(i)], editor.roomIndex = i;
else std::printf("нет такой комнаты: %s\n", openRoom);
}
if (selectSpawn >= 0 && selectSpawn < int(editor.room.spawns.size()))
{
editor.selX = editor.room.spawns[size_t(selectSpawn)].x;
editor.selY = editor.room.spawns[size_t(selectSpawn)].y;
}
if (showLevel && ready)
{
editor.seed = levelSeed;
if (previewDepth > 0) editor.previewDepth = previewDepth;
editor.Apply(Act::PREVIEW_TOGGLE, 0);
}
// Терминал работает в КОРНЕ РЕПОЗИТОРИЯ, а не в каталоге проекта: работают
// не только над содержимым — рядом код движка, документация и git.
static Terminal term;
{
std::error_code ec;
std::filesystem::path root = ready ? std::filesystem::path(editor.project.dir) : ".";
if (ready) root = root.parent_path().parent_path(); // projects/<имя> -> корень
if (root.empty()) root = ".";
root = std::filesystem::absolute(root, ec).lexically_normal();
// Имя проекта, а не путь: по нему инструменты найдут его сами, откуда
// бы их ни запустили.
const std::string pname =
ready ? std::filesystem::path(editor.project.dir).filename().string() : std::string();
term.Init(root.string(), pname);
}
if (termCmd)
{
term.open = true;
term.RunShellFromArgv(termCmd);
}
while (app.NextFrame())
{
// Терминал забирает ввод целиком, пока открыт: иначе набранная в строке
// `w` вставала бы кистью стены на холст.
const bool termAte = term.Update(app.winW, app.winH, app.frameDt);
if (!termAte && IsKeyPressed(KEY_ESCAPE)) app.quit = true;
if (ready)
{
if (!termAte) editor.Update(app.fit, app.frameDt);
editor.DrawWorld(app.fb);
}
else
{
app.fb.Clear(RGBA(9, 9, 11));
}
app.Present();
if (ready)
{
editor.DrawUi(app.fit);
}
else
{
// Без каталога сущностей ставить в комнату нечего, и делать вид,
// что редактор работает, нельзя.
DrawText("no project (see projects/*/project.txt)", app.fit.x + 16, app.fit.y + 16,
8 * app.fit.scale, Color{198, 84, 66, 255});
DrawText(editor.status.c_str(), app.fit.x + 16, app.fit.y + 32, 8 * app.fit.scale,
Color{206, 200, 190, 255});
}
// Терминал рисуется ПОВЕРХ всего и в пикселях окна, а не кадра: это
// инструмент, а не часть пиксельной картинки, и строк в нём должно
// помещаться столько, сколько позволяет монитор.
term.Draw(app.winW, app.winH);
app.Finish();
if (shotFrame >= 0 && --shotFrame <= 0)
{
app.Screenshot(shotFile);
break;
}
}
term.Shutdown();
app.Shutdown();
return ready ? 0 : 1;
}

682
editor/terminal.cpp Normal file
View File

@ -0,0 +1,682 @@
#include "editor/terminal.h"
#include <raylib.h>
#include <algorithm>
#include <cstring>
#include "engine/framebuffer.h"
#ifdef _WIN32
// raylib и Win32 объявляют CloseWindow, ShowCursor и DrawTextEx каждый по-своему,
// и в одном файле они не уживаются. NOGDI и NOUSER выкидывают из windows.h
// именно те две части, где это происходит: рисование и оконный интерфейс нам
// отсюда не нужны — нужны процессы и каналы.
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define NOGDI
#define NOUSER
#include <windows.h>
#endif
namespace
{
// Сколько строк держим. Не «на всякий случай»: прогон --tool map печатает
// полсотни строк, сборка — сотни, и без потолка панель съедала бы память тем
// быстрее, чем активнее ей пользуются.
constexpr size_t SCROLLBACK_MAX = 4000;
constexpr int FONT_SIZE = 16;
constexpr int LINE_H = 18;
constexpr int PAD = 8;
constexpr int BTN_H = 20;
const Color COL_BG = {10, 10, 13, 255}; // непрозрачно: подложка редактора не должна просвечивать
const Color COL_EDGE = {52, 50, 56, 255};
const Color COL_TEXT = {198, 196, 190, 255};
const Color COL_DIM = {112, 110, 106, 255};
const Color COL_ECHO = {150, 190, 230, 255}; // что мы сами запустили
const Color COL_ERR = {214, 108, 88, 255};
const Color COL_OK = {130, 200, 140, 255};
const Color COL_BTN = {26, 26, 31, 255};
const Color COL_BTN_HI = {74, 44, 36, 255};
struct Button
{
const char* label;
const char* cmd; // что запустить; nullptr — особое действие
int x = 0, w = 0;
};
// Кнопки — это не «удобство», а список того, что при работе с моделью
// запускают чаще всего. Каждая из них — команда, которую можно набрать руками;
// ничего, кроме экономии нажатий, они не делают.
Button BUTTONS[] = {
{"VALIDATE", "tile2d_editor --tool validate", 0, 0},
{"ROOMS", "tile2d_editor --tool rooms", 0, 0},
{"FLOOR", "tile2d_editor --tool floor 6", 0, 0},
{"MAP", "tile2d_editor --tool map 6", 0, 0},
{"CURVE", "tile2d_editor --tool curve 1 12", 0, 0},
{"PLAY", "squad_proto --rooms", 0, 0},
{"STOP", nullptr, 0, 0},
{"EXTERNAL", nullptr, 0, 0},
};
constexpr int BUTTON_COUNT = int(sizeof(BUTTONS) / sizeof(BUTTONS[0]));
// --- UTF-8 -------------------------------------------------------------------
// Ввод у raylib приходит кодовыми точками, вывод процессов — байтами UTF-8.
// Между ними нужен перевод, иначе набранная кириллица уедет в мусор.
void AppendUtf8(std::string& s, int cp)
{
if (cp < 0x80) s += char(cp);
else if (cp < 0x800)
{
s += char(0xC0 | (cp >> 6));
s += char(0x80 | (cp & 0x3F));
}
else if (cp < 0x10000)
{
s += char(0xE0 | (cp >> 12));
s += char(0x80 | ((cp >> 6) & 0x3F));
s += char(0x80 | (cp & 0x3F));
}
}
// Backspace обязан снимать СИМВОЛ, а не байт: иначе от кириллической буквы
// остаётся половина, и строка перестаёт быть валидным UTF-8.
void PopUtf8(std::string& s)
{
while (!s.empty() && (uint8_t(s.back()) & 0xC0) == 0x80) s.pop_back();
if (!s.empty()) s.pop_back();
}
// Убрать управляющие последовательности ANSI. Мы не эмулятор терминала:
// цвета и адресация курсора нам не нужны, а мусор вида `ESC[0m` в строке —
// нужен ещё меньше.
std::string StripAnsi(const std::string& in)
{
std::string out;
out.reserve(in.size());
for (size_t i = 0; i < in.size(); ++i)
{
const char c = in[i];
if (c == '\x1B')
{
if (i + 1 < in.size() && in[i + 1] == '[')
{
i += 2;
while (i < in.size() && !(in[i] >= '@' && in[i] <= '~')) ++i;
}
else
{
++i; // односимвольная последовательность
}
continue;
}
if (c == '\r') continue;
if (c == '\t') { out += " "; continue; }
if (uint8_t(c) < 0x20) continue;
out += c;
}
return out;
}
std::string Quote(const std::string& s)
{
std::string out = "\"";
for (const char c : s)
{
if (c == '"' || c == '\\') out += '\\';
out += c;
}
out += '"';
return out;
}
// --- состояние, которому нужны windows.h и raylib ----------------------------
struct Impl
{
Font font{};
bool fontLoaded = false;
std::string pending; // недобранный хвост строки
#ifdef _WIN32
HANDLE proc = nullptr;
HANDLE outRead = nullptr;
#endif
};
Impl g_impl;
// Шрифт с кириллицей. Встроенный шрифт raylib её не знает, и вывод модели,
// которая отвечает по-русски, превратился бы в частокол вопросов. Правило
// «тексты на экране латиницей» здесь не нарушается: оно про ИГРУ, где текст
// пишет разработчик, а тут текст приходит извне и выбора нет.
void EnsureFont()
{
if (g_impl.fontLoaded) return;
g_impl.fontLoaded = true;
std::vector<int> cps;
for (int c = 32; c < 127; ++c) cps.push_back(c);
for (int c = 0x0400; c <= 0x045F; ++c) cps.push_back(c);
cps.push_back(0x2014); // тире
cps.push_back(0x2026); // многоточие
cps.push_back(0x00AB);
cps.push_back(0x00BB);
static const char* const CANDIDATES[] = {
"C:/Windows/Fonts/consola.ttf",
"C:/Windows/Fonts/cour.ttf",
"C:/Windows/Fonts/lucon.ttf",
};
for (const char* path : CANDIDATES)
{
if (!FileExists(path)) continue;
g_impl.font = LoadFontEx(path, FONT_SIZE * 2, cps.data(), int(cps.size()));
if (g_impl.font.texture.id != 0)
{
SetTextureFilter(g_impl.font.texture, TEXTURE_FILTER_BILINEAR);
return;
}
}
g_impl.font = GetFontDefault(); // латиница есть, и на том спасибо
}
void DrawLine(const char* text, int x, int y, Color c)
{
DrawTextEx(g_impl.font, text, Vector2{float(x), float(y)}, float(FONT_SIZE), 1.0f, c);
}
int MeasureLine(const char* text)
{
return int(MeasureTextEx(g_impl.font, text, float(FONT_SIZE), 1.0f).x);
}
// Разбить строку на куски по cols символов. Ответ модели — это абзац на
// несколько экранных ширин, и без переноса он просто уезжает за правый край:
// прочитать нельзя, а что текст оборван — не видно.
//
// Считаем СИМВОЛЫ, а не байты: кириллица в UTF-8 занимает два байта, и разрез
// по байтам оставил бы половину буквы, то есть сломанный UTF-8 в середине.
void WrapInto(const std::string& s, int cols, std::vector<std::string>& out)
{
if (cols < 8) cols = 8;
size_t i = 0;
while (i < s.size())
{
size_t j = i;
int chars = 0;
while (j < s.size() && chars < cols)
{
++j;
while (j < s.size() && (uint8_t(s[j]) & 0xC0) == 0x80) ++j; // хвост символа
++chars;
}
out.push_back(s.substr(i, j - i));
i = j;
}
if (s.empty()) out.push_back(std::string());
}
} // namespace
const char* AskModeName(AskMode m)
{
switch (m)
{
case AskMode::SHELL: return "shell";
case AskMode::CLAUDE: return "claude";
case AskMode::CODEX: return "codex";
default: return "?";
}
}
void Terminal::Init(const std::string& dir, const std::string& project)
{
workDir = dir;
projectName = project;
EnsureFont();
Print("Tile2D terminal. Tab — режим (shell/claude/codex), Enter — выполнить.");
if (!projectName.empty()) Print("проект " + projectName + " (TILE2D_PROJECT), каталог " + workDir);
Print("`--tool help` покажет инструменты движка; F12 — интерактивный CLI отдельным окном.");
}
void Terminal::Shutdown()
{
Stop();
}
bool Terminal::Running() const
{
#ifdef _WIN32
return g_impl.proc != nullptr;
#else
return false;
#endif
}
void Terminal::Print(const std::string& s)
{
lines.push_back(s);
if (lines.size() > SCROLLBACK_MAX)
lines.erase(lines.begin(), lines.begin() + int(lines.size() - SCROLLBACK_MAX));
scroll = 0; // новая строка возвращает к концу: иначе вывод «не идёт»
}
int Terminal::PanelTop(int windowH) const
{
if (!open) return windowH;
return windowH - std::max(160, windowH * 2 / 5);
}
// -----------------------------------------------------------------------------
// Процесс
// -----------------------------------------------------------------------------
#ifdef _WIN32
namespace
{
void CloseChild()
{
if (g_impl.proc) { CloseHandle(g_impl.proc); g_impl.proc = nullptr; }
if (g_impl.outRead) { CloseHandle(g_impl.outRead); g_impl.outRead = nullptr; }
}
} // namespace
void Terminal::RunShell(const std::string& cmd)
{
if (cmd.empty()) return;
if (Running())
{
Print("занято: команда уже выполняется (STOP прервёт)");
return;
}
Print("$ " + cmd);
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE outWrite = nullptr;
if (!CreatePipe(&g_impl.outRead, &outWrite, &sa, 0))
{
Print("не удалось создать канал");
CloseChild();
return;
}
// Наш конец канала дочернему процессу не наследуется: иначе он держал бы
// его открытым, и признак конца вывода не пришёл бы никогда.
SetHandleInformation(g_impl.outRead, HANDLE_FLAG_INHERIT, 0);
// Ввод — устройство NUL, а не канал. Канал, в который никто никогда не
// пишет, выглядит для потомка как «данные вот-вот придут»: claude честно
// ждёт три секунды и печатает предупреждение. Пустой ввод — это EOF, и
// сказать это надо сразу.
HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa,
OPEN_EXISTING, 0, nullptr);
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = outWrite;
si.hStdError = outWrite;
si.hStdInput = nul;
PROCESS_INFORMATION pi{};
// Через cmd.exe, а не напрямую: нужны PATH, встроенные команды и то, что
// человек привык набирать в консоли. Кодовая страница 65001 — иначе
// кириллица из вывода приезжает в CP866 и рисуется мусором.
//
// Каталог с бинарниками добавляется в PATH: команды выполняются в корне
// репозитория (там содержимое, документация и git), а `tile2d_editor` и
// `squad_proto` лежат в build/bin. Без этого простое `--tool validate`
// требовало бы писать путь к самому себе.
char exePath[MAX_PATH] = {};
GetModuleFileNameA(nullptr, exePath, MAX_PATH);
std::string binDir = exePath;
const size_t slash = binDir.find_last_of("\\/");
binDir = (slash == std::string::npos) ? std::string(".") : binDir.substr(0, slash);
std::string line = "cmd.exe /d /c chcp 65001>nul & set \"PATH=" + binDir + ";%PATH%\" & ";
if (!projectName.empty()) line += "set \"TILE2D_PROJECT=" + projectName + "\" & ";
line += cmd;
std::vector<char> mutableLine(line.begin(), line.end());
mutableLine.push_back('\0');
const BOOL ok = CreateProcessA(nullptr, mutableLine.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr,
workDir.empty() ? nullptr : workDir.c_str(), &si, &pi);
CloseHandle(outWrite);
if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul);
if (!ok)
{
Print("не запустилось: " + cmd);
CloseChild();
return;
}
CloseHandle(pi.hThread);
g_impl.proc = pi.hProcess;
}
void Terminal::RunShellFromArgv(const char* cmd)
{
if (!cmd) return;
const int wide = MultiByteToWideChar(CP_ACP, 0, cmd, -1, nullptr, 0);
if (wide <= 0) { RunShell(cmd); return; }
// Не `std::vector<wchar_t> w(size_t(wide))`: это объявление ФУНКЦИИ,
// а не вектора (most vexing parse), и компилятор скажет об этом невнятно.
std::vector<wchar_t> w;
w.resize(size_t(wide));
MultiByteToWideChar(CP_ACP, 0, cmd, -1, w.data(), wide);
const int utf8 = WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, nullptr, 0, nullptr, nullptr);
if (utf8 <= 0) { RunShell(cmd); return; }
std::vector<char> u;
u.resize(size_t(utf8));
WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, u.data(), utf8, nullptr, nullptr);
RunShell(u.data());
}
void Terminal::Stop()
{
if (!Running()) return;
TerminateProcess(g_impl.proc, 1);
WaitForSingleObject(g_impl.proc, 2000);
CloseChild();
Print("[прервано]");
}
void Terminal::Poll()
{
if (!g_impl.outRead) return;
for (;;)
{
DWORD avail = 0;
if (!PeekNamedPipe(g_impl.outRead, nullptr, 0, nullptr, &avail, nullptr)) break;
if (avail == 0) break;
char buf[8192];
DWORD got = 0;
const DWORD want = (avail < sizeof(buf)) ? avail : DWORD(sizeof(buf));
if (!ReadFile(g_impl.outRead, buf, want, &got, nullptr) || got == 0) break;
g_impl.pending.append(buf, got);
for (;;)
{
const size_t nl = g_impl.pending.find('\n');
if (nl == std::string::npos) break;
Print(StripAnsi(g_impl.pending.substr(0, nl)));
g_impl.pending.erase(0, nl + 1);
}
// Очень длинная строка без перевода — не повод копить её вечно.
if (g_impl.pending.size() > 4096)
{
Print(StripAnsi(g_impl.pending));
g_impl.pending.clear();
}
}
if (g_impl.proc && WaitForSingleObject(g_impl.proc, 0) == WAIT_OBJECT_0)
{
if (!g_impl.pending.empty())
{
Print(StripAnsi(g_impl.pending));
g_impl.pending.clear();
}
DWORD code = 0;
GetExitCodeProcess(g_impl.proc, &code);
Print(code == 0 ? "[готово]" : ("[код " + std::to_string(int(code)) + "]"));
CloseChild();
}
}
void Terminal::LaunchExternal()
{
// Полный интерактивный CLI — во внешнем окне: у него альтернативный экран
// и рамки из псевдографики, и рисовать это здесь было бы враньём.
const char* cli = (mode == AskMode::CODEX) ? "codex" : "claude";
const std::string cmd = std::string("cmd.exe /c start \"Tile2D agent\" cmd /k ") + cli;
std::vector<char> line(cmd.begin(), cmd.end());
line.push_back('\0');
STARTUPINFOA si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (CreateProcessA(nullptr, line.data(), nullptr, nullptr, FALSE, 0, nullptr,
workDir.empty() ? nullptr : workDir.c_str(), &si, &pi))
{
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
Print(std::string("внешнее окно: ") + cli + " в " + workDir);
}
else
{
Print(std::string("не удалось открыть внешний терминал с ") + cli);
}
}
#else // не Windows
void Terminal::RunShell(const std::string& cmd)
{
Print("терминал реализован только под Windows: " + cmd);
}
void Terminal::Stop() {}
void Terminal::Poll() {}
void Terminal::LaunchExternal() { Print("внешний терминал: только под Windows"); }
void Terminal::RunShellFromArgv(const char* cmd) { if (cmd) RunShell(cmd); }
#endif
void Terminal::Submit()
{
const std::string line = input;
if (line.empty()) return;
input.clear();
histPos = -1;
if (history.empty() || history.back() != line) history.push_back(line);
switch (mode)
{
case AskMode::CLAUDE:
RunShell("claude -p " + Quote(line));
break;
case AskMode::CODEX:
RunShell("codex exec " + Quote(line));
break;
default:
RunShell(line);
break;
}
}
// -----------------------------------------------------------------------------
// Ввод
// -----------------------------------------------------------------------------
bool Terminal::Update(int windowW, int windowH, float dt)
{
(void)dt;
if (IsKeyPressed(KEY_GRAVE)) open = !open;
if (IsKeyPressed(KEY_F12)) { open = true; LaunchExternal(); }
if (!open) return false;
Poll();
const int top = PanelTop(windowH);
// --- кнопки ---------------------------------------------------------------
int bx = PAD;
for (int i = 0; i < BUTTON_COUNT; ++i)
{
BUTTONS[i].w = MeasureLine(BUTTONS[i].label) + 14;
BUTTONS[i].x = bx;
bx += BUTTONS[i].w + 6;
}
const Vector2 m = GetMousePosition();
const bool inPanel = m.y >= float(top);
if (inPanel && IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
{
const int by = top + PAD;
for (int i = 0; i < BUTTON_COUNT; ++i)
{
const bool hit = m.x >= float(BUTTONS[i].x) &&
m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) &&
m.y < float(by + BTN_H);
if (!hit) continue;
if (BUTTONS[i].cmd) RunShell(BUTTONS[i].cmd);
else if (std::strcmp(BUTTONS[i].label, "STOP") == 0) Stop();
else LaunchExternal();
}
}
// --- клавиши --------------------------------------------------------------
const bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
for (int cp = GetCharPressed(); cp > 0; cp = GetCharPressed())
if (cp != '`') AppendUtf8(input, cp);
if (IsKeyPressed(KEY_BACKSPACE) || IsKeyPressedRepeat(KEY_BACKSPACE)) PopUtf8(input);
if (IsKeyPressed(KEY_TAB)) mode = AskMode((int(mode) + 1) % int(AskMode::COUNT));
if (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_KP_ENTER)) Submit();
if (ctrl && IsKeyPressed(KEY_C)) Stop();
if (IsKeyPressed(KEY_ESCAPE)) { if (Running()) Stop(); else open = false; }
// История: вверх — назад по своим командам. Ровно то поведение, которое
// есть в любой оболочке; своё придумывать здесь незачем.
if (IsKeyPressed(KEY_UP) && !history.empty())
{
if (histPos < 0) histPos = int(history.size());
histPos = std::max(0, histPos - 1);
input = history[size_t(histPos)];
}
if (IsKeyPressed(KEY_DOWN) && histPos >= 0)
{
++histPos;
if (histPos >= int(history.size())) { histPos = -1; input.clear(); }
else input = history[size_t(histPos)];
}
const int rows = std::max(1, (windowH - top - PAD * 3 - BTN_H - LINE_H) / LINE_H);
const int maxScroll = std::max(0, int(lines.size()) - rows);
if (IsKeyPressed(KEY_PAGE_UP)) scroll = std::min(maxScroll, scroll + rows / 2);
if (IsKeyPressed(KEY_PAGE_DOWN)) scroll = std::max(0, scroll - rows / 2);
if (inPanel)
{
const float wheel = GetMouseWheelMove();
if (wheel != 0.0f)
scroll = std::max(0, std::min(maxScroll, scroll + int(wheel * 3.0f)));
}
(void)windowW;
return true; // терминал открыт — весь ввод его
}
void Terminal::Draw(int windowW, int windowH) const
{
if (!open) return;
const int top = PanelTop(windowH);
DrawRectangle(0, top, windowW, windowH - top, COL_BG);
DrawRectangle(0, top, windowW, 1, COL_EDGE);
// --- кнопки ---------------------------------------------------------------
const int by = top + PAD;
const Vector2 m = GetMousePosition();
for (int i = 0; i < BUTTON_COUNT; ++i)
{
const bool hover = m.x >= float(BUTTONS[i].x) &&
m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) &&
m.y < float(by + BTN_H);
DrawRectangle(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, hover ? COL_BTN_HI : COL_BTN);
DrawRectangleLines(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, COL_EDGE);
DrawLine(BUTTONS[i].label, BUTTONS[i].x + 7, by + 2, COL_TEXT);
}
// Состояние справа от кнопок: выполняется ли что-то и где именно.
{
const std::string right = (Running() ? std::string("выполняется ") : std::string()) +
workDir;
DrawLine(right.c_str(), windowW - PAD - MeasureLine(right.c_str()), by + 2,
Running() ? COL_OK : COL_DIM);
}
// --- вывод ----------------------------------------------------------------
const int bodyTop = by + BTN_H + PAD;
const int bodyBottom = windowH - PAD - LINE_H;
const int rows = std::max(1, (bodyBottom - bodyTop) / LINE_H);
// Сколько символов влезает в строку. Шаг буквы меряется по РАЗНОСТИ двух
// строк, а не по одной букве: в шаг входит межбуквенный интервал, и без
// него расчёт занижает ширину символа, а строка вылезает за край.
const int charW = std::max(1, MeasureLine("WW") - MeasureLine("W"));
const int cols = (windowW - PAD * 2) / charW;
// Переносим с конца назад, пока не набрали экран: так отматывать можно по
// ЭКРАННЫМ строкам, а не по исходным, и абзац на пять ширин не проскакивает
// мимо одним щелчком колеса.
std::vector<std::string> view;
std::vector<Color> colors;
view.reserve(size_t(rows) + 8);
int skip = scroll;
for (int i = int(lines.size()) - 1; i >= 0 && int(view.size()) < rows + skip; --i)
{
const std::string& s = lines[size_t(i)];
Color c = COL_TEXT;
if (!s.empty() && s[0] == '$') c = COL_ECHO;
else if (s.rfind("[код", 0) == 0 || s.rfind("СЛОМАНО", 0) == 0 ||
s.rfind("ОШИБКА", 0) == 0)
c = COL_ERR;
else if (s.rfind("[готово]", 0) == 0) c = COL_OK;
std::vector<std::string> wrapped;
WrapInto(s, cols, wrapped);
for (int k = int(wrapped.size()) - 1; k >= 0; --k)
{
view.push_back(wrapped[size_t(k)]);
colors.push_back(c);
}
}
int y = bodyTop;
const int firstRow = std::min(int(view.size()), rows + skip) - 1;
for (int i = firstRow; i >= skip && y < bodyBottom; --i, y += LINE_H)
DrawLine(view[size_t(i)].c_str(), PAD, y, colors[size_t(i)]);
if (scroll > 0)
{
const std::string tag = "^ отмотано на " + std::to_string(scroll);
DrawLine(tag.c_str(), windowW - PAD - MeasureLine(tag.c_str()), bodyTop, COL_DIM);
}
// --- строка ввода ---------------------------------------------------------
const int iy = windowH - PAD - LINE_H + 2;
std::string prompt = std::string(AskModeName(mode)) + (mode == AskMode::SHELL ? "$ " : "> ");
DrawLine(prompt.c_str(), PAD, iy, mode == AskMode::SHELL ? COL_DIM : COL_ECHO);
const int px = PAD + MeasureLine(prompt.c_str());
DrawLine(input.c_str(), px, iy, COL_TEXT);
// Курсор мигает от системного времени, а не от кадра: на 60 fps счётчик
// кадров дал бы разную частоту на разных мониторах.
if (int(GetTime() * 2.0) % 2 == 0)
DrawRectangle(px + MeasureLine(input.c_str()) + 1, iy + 2, 8, FONT_SIZE - 2, COL_TEXT);
}

90
editor/terminal.h Normal file
View File

@ -0,0 +1,90 @@
#pragma once
// -----------------------------------------------------------------------------
// Терминал в редакторе: работать над проектом, не выходя из него.
//
// ЧТО ЭТО НЕ ТАКОЕ. Это не эмулятор терминала. Полноценный VT100 (альтернативный
// экран, адресация курсора, рамки из псевдографики) нужен ровно одному сорту
// программ — интерактивным TUI, и `claude` без ключей именно такой. Написать
// его поверх raylib можно, но результат всё равно рассыпался бы на первом же
// символе рамки: встроенный шрифт их не знает.
//
// Поэтому здесь СТРОЧНЫЙ вывод, и это не упрощение, а выбор в пользу того,
// как с моделью работают из редактора на самом деле:
//
// * `claude -p` и `codex exec` печатают обычный текст — им эмулятор не нужен;
// * инструменты Tile2D (--tool) печатают таблицы и карты — тоже текст;
// * git, cmake, прогоны игры — текст.
//
// Полный интерактивный CLI открывается одной кнопкой во ВНЕШНЕМ терминале, в
// том же рабочем каталоге. Так у каждого способа остаётся то, в чём он хорош.
//
// Строка ввода работает в трёх режимах (Tab): команда оболочки, вопрос Claude,
// вопрос Codex. Одно поле, а не три разных — потому что работа выглядит как
// чередование: спросил, посмотрел, проверил инструментом, снова спросил.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
struct ScreenRect;
enum class AskMode : uint8_t
{
SHELL = 0, // команда оболочки как есть
CLAUDE, // claude -p "..."
CODEX, // codex exec "..."
COUNT,
};
const char* AskModeName(AskMode m);
struct Terminal
{
bool open = false;
// Скроллбек. Строки, а не поток байт: их листают, ищут глазами и копируют
// в отчёт, и все три действия требуют границ строк.
std::vector<std::string> lines;
std::string input;
std::vector<std::string> history;
int histPos = -1; // -1 — набирается новая строка, не из истории
int scroll = 0; // на сколько строк отмотано вверх от конца
AskMode mode = AskMode::SHELL;
// Где выполняются команды. Корень репозитория, а не каталог проекта:
// работа идёт над всем — код движка, содержимое, документация.
std::string workDir;
// Какой проект открыт в редакторе. Уходит детям переменной окружения
// TILE2D_PROJECT, поэтому `--tool validate` без ключей проверяет ИМЕННО
// его — и то же самое видит агент, запущенный отсюда.
std::string projectName;
void Init(const std::string& dir, const std::string& project = {});
void Shutdown();
bool Running() const;
// Ввод и кнопки. true — терминал забрал ввод себе, редактору его не
// отдавать: иначе набранная в строке `w` встанет кистью стены на холст.
bool Update(int windowW, int windowH, float dt);
void Draw(int windowW, int windowH) const;
// Забрать то, что процесс успел напечатать. Звать каждый кадр: вывод
// обязан идти потоком, а не появляться целиком по завершении.
void Poll();
void Submit(); // выполнить строку ввода по режиму
void RunShell(const std::string& cmd); // выполнить команду напрямую (UTF-8)
// То же, но строка пришла из argv. Аргументы командной строки приходят в
// системной кодировке, а всё остальное здесь — UTF-8: без перевода русская
// команда из --term отображалась бы вопросами.
void RunShellFromArgv(const char* cmd);
void Stop(); // прервать текущий процесс
void Print(const std::string& s); // своя строка в скроллбек
void LaunchExternal(); // интерактивный CLI отдельным окном
// Занимаемая часть окна снизу, в пикселях ОКНА. Редактор спрашивает её,
// чтобы не рисовать и не ловить мышь под панелью.
int PanelTop(int windowH) const;
};

591
editor/tools.cpp Normal file
View File

@ -0,0 +1,591 @@
#include "editor/tools.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "engine/catalog.h"
#include "engine/level_gen.h"
#include "engine/progression.h"
#include "engine/project.h"
#include "engine/room.h"
#include "engine/tilemap.h"
namespace
{
// --- разбор аргументов -------------------------------------------------------
struct Args
{
std::string cmd;
std::vector<std::string> rest; // позиционные, без ключей
std::string project;
const std::string& At(size_t i) const
{
static const std::string empty;
return (i < rest.size()) ? rest[i] : empty;
}
int Int(size_t i, int fallback) const
{
const std::string& s = At(i);
if (s.empty()) return fallback;
return std::atoi(s.c_str());
}
};
Args Parse(int argc, char** argv)
{
Args a;
for (int i = 0; i < argc; ++i)
{
const char* s = argv[i];
if (std::strcmp(s, "--project") == 0 && i + 1 < argc) { a.project = argv[++i]; continue; }
if (std::strcmp(s, "--tool") == 0) continue;
if (s[0] == '-') continue;
if (a.cmd.empty()) a.cmd = s;
else a.rest.push_back(s);
}
return a;
}
// --- общая часть: поднять проект ---------------------------------------------
//
// Проект нужен почти каждой команде, и «не выбран» — самая частая ошибка
// вызова. Поэтому текст отказа один на все команды и всегда со списком.
struct Loaded
{
Project project;
EntityCatalog catalog;
RoomLibrary rooms;
bool ok = false;
};
bool OpenProject(const Args& a, Loaded& out, bool needRooms = true)
{
std::string why;
const std::string dir = FindProjectDir(a.project);
if (dir.empty())
{
std::printf("ОШИБКА: проект не выбран (не найден или их несколько)\n");
for (const std::string& n : AvailableProjects())
std::printf(" доступен: --project %s\n", n.c_str());
return false;
}
if (!out.project.Load(dir, &why))
{
std::printf("ОШИБКА: %s\n", why.c_str());
return false;
}
out.project.Apply();
if (!out.catalog.Load(out.project.catalogPath, &why))
{
std::printf("ОШИБКА: каталог сущностей: %s\n", why.c_str());
return false;
}
if (needRooms && !out.rooms.Load(out.project.roomsDir, &why))
{
std::printf("ОШИБКА: набор комнат: %s\n", why.c_str());
return false;
}
out.ok = true;
return true;
}
std::string DepthRange(int minDepth, int maxDepth)
{
char buf[24];
if (maxDepth > 0) std::snprintf(buf, sizeof(buf), "%d-%d", minDepth, maxDepth);
else std::snprintf(buf, sizeof(buf), "%d+", minDepth);
return buf;
}
// --- команды -----------------------------------------------------------------
int CmdHelp()
{
std::printf(
"Tile2D tools — работа с проектом без окна.\n"
"\n"
" tile2d_editor --tool <команда> [аргументы] [--project <имя>]\n"
"\n"
"ЧТО ЕСТЬ В ПРОЕКТЕ\n"
" projects какие проекты видны отсюда\n"
" project манифест: вид, пути, кривая глубины\n"
" rooms набор комнат таблицей\n"
" room <имя> одна комната: двери, точки спавна, проблемы\n"
" ascii <имя> сетка комнаты текстом\n"
" catalog [класс] [этаж] что вообще можно ставить, с ценами и глубинами\n"
"\n"
"ЧТО ИЗ ЭТОГО ПОЛУЧАЕТСЯ\n"
" curve <от> <до> во что кривая превращает этажи с <от> по <до>\n"
" floor <этаж> [сид] собрать этаж и показать цифры\n"
" map <этаж> [сид] собранный этаж картой текстом\n"
" validate весь набор: комнаты, покрытие глубин, сборка\n"
"\n"
"Код возврата 1 означает «не выполнено или найдена поломка» — на это можно\n"
"вешать хуки и скрипты, а не читать вывод глазами.\n");
return 0;
}
int CmdProjects()
{
const std::vector<std::string> names = AvailableProjects();
if (names.empty())
{
std::printf("проектов не найдено (ожидается projects/<имя>/project.txt)\n");
return 1;
}
for (const std::string& n : names)
{
Project p;
std::string why;
const std::string dir = FindProjectDir(n);
if (dir.empty() || !p.Load(dir, &why))
{
std::printf("%-12s [СЛОМАН] %s\n", n.c_str(), why.c_str());
continue;
}
std::printf("%-12s %-8s %-8s %s\n", n.c_str(), p.name.c_str(), ViewModeName(p.view),
p.dir.c_str());
}
return 0;
}
int CmdProject(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Project& p = L.project;
const FloorCurve& c = p.curve;
std::printf("имя %s\n", p.name.c_str());
std::printf("каталог %s\n", p.dir.c_str());
std::printf("вид %s\n", ViewModeName(p.view));
std::printf("комнаты %s (%d шт.)\n", p.roomsDir.c_str(), L.rooms.Count());
std::printf("сущности %s (%d шт.)\n", p.catalogPath.c_str(), L.catalog.Count());
std::printf("первый этаж %d\n", p.startDepth);
std::printf("\nкривая глубины\n");
std::printf(" danger %.2f + %.2f*(d-1) + %.3f*(d-1)^2\n", double(c.dangerBase),
double(c.dangerLinear), double(c.dangerQuad));
std::printf(" density 1 + %.3f*(d-1), не выше %.2f\n", double(c.densityPerFloor),
double(c.densityCap));
std::printf(" loot %d + d/%d предметов, тир каждые %d этажа\n", c.lootBase,
c.floorsPerDrop, c.floorsPerTier);
std::printf(" sight 1 - %.3f*(d-1), не ниже %.2f\n", double(c.sightPerFloor),
double(c.sightFloorAt));
std::printf(" xp 1 + %.2f*(d-1)\n", double(c.xpPerFloor));
std::printf(" themes ");
for (int i = 0; i < THEME_COUNT; ++i)
std::printf(" %s c %d", ThemeName(uint8_t(i)), c.themeFrom[i]);
std::printf("\n");
return 0;
}
int CmdRooms(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
// Заголовки латиницей: ширина в printf считается в БАЙТАХ, и кириллица
// в UTF-8 разъезжается со столбцами ровно вдвое.
std::printf("%-14s %6s %5s %6s %6s %7s %6s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME",
"DEPTH", "WEIGHT", "STATE");
int bad = 0;
for (const Room& r : L.rooms.rooms)
{
std::string state = RoomProblem(r);
if (state.empty() && r.start) state = "start";
if (!RoomProblem(r).empty()) ++bad;
std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %6d %s\n", r.name.c_str(), r.w, r.h,
r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme),
DepthRange(r.minDepth, r.maxDepth).c_str(), r.weight, state.c_str());
}
for (const std::string& f : L.rooms.failures)
{
std::printf("НЕ ЧИТАЕТСЯ %s\n", f.c_str());
++bad;
}
return bad > 0 ? 1 : 0;
}
const Room* FindRoom(const RoomLibrary& lib, const std::string& name)
{
const int i = lib.IndexOf(name);
return (i >= 0) ? &lib.rooms[size_t(i)] : nullptr;
}
int CmdRoom(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Room* r = FindRoom(L.rooms, a.At(0));
if (!r)
{
std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str());
return 1;
}
std::printf("комната %s\n", r->name.c_str());
std::printf("размер %dx%d, дверей %d, вес %d\n", r->w, r->h, r->CountDoors(), r->weight);
std::printf("тема %s\n", ThemeName(r->theme));
std::printf("глубина %s%s\n", DepthRange(r->minDepth, r->maxDepth).c_str(),
r->start ? ", стартовая" : "");
const std::string problem = RoomProblem(*r);
std::printf("состояние %s\n", problem.empty() ? "в порядке" : problem.c_str());
std::printf("\nточки спавна (%d)\n", int(r->spawns.size()));
for (const SpawnPoint& p : r->spawns)
{
std::printf(" (%2d,%2d) шанс %.2f :", p.x, p.y, double(p.chance));
const int total = p.TotalWeight();
for (const SpawnOption& o : p.options)
{
const int idx = L.catalog.IndexOf(o.id);
const double share = (total > 0) ? 100.0 * double(o.weight) / double(total) : 0.0;
std::printf(" %s:%d(%.0f%%%s)", o.id.c_str(), o.weight, share,
idx < 0 ? ", НЕТ В КАТАЛОГЕ" : "");
}
std::printf("\n");
}
return problem.empty() ? 0 : 1;
}
int CmdAscii(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Room* r = FindRoom(L.rooms, a.At(0));
if (!r)
{
std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str());
return 1;
}
// Точка спавна рисуется поверх сетки цифрой — её номером в списке. Так по
// карте видно и ГДЕ она, и какую строку `spawn` смотреть.
std::printf("%s %dx%d\n\n", r->name.c_str(), r->w, r->h);
std::printf(" ");
for (int x = 0; x < r->w; ++x) std::printf("%d", x % 10);
std::printf("\n");
for (int y = 0; y < r->h; ++y)
{
std::printf("%3d ", y);
for (int x = 0; x < r->w; ++x)
{
char c = '.';
switch (r->At(x, y))
{
case RoomCell::WALL: c = '#'; break;
case RoomCell::DOOR: c = '+'; break;
default: break;
}
for (size_t i = 0; i < r->spawns.size(); ++i)
if (r->spawns[i].x == x && r->spawns[i].y == y) c = char('0' + int(i % 10));
std::printf("%c", c);
}
std::printf("\n");
}
std::printf("\n#стена +дверь .пол цифра — точка спавна (её номер в списке)\n");
return 0;
}
int CmdCatalog(const Args& a)
{
Loaded L;
if (!OpenProject(a, L, false)) return 1;
bool byClass = false;
EntityClass want = EntityClass::MONSTER;
const std::string& cls = a.At(0);
if (cls == "monster") { byClass = true; want = EntityClass::MONSTER; }
else if (cls == "prop") { byClass = true; want = EntityClass::PROP; }
else if (cls == "loot") { byClass = true; want = EntityClass::LOOT; }
else if (!cls.empty())
{
std::printf("ОШИБКА: класс бывает monster, prop или loot\n");
return 1;
}
const int depth = a.Int(1, 0); // 0 — не фильтровать по глубине
std::printf("%-12s %-8s %7s %7s %5s %s\n", "ID", "CLASS", "DEPTH", "DANGER", "TIER", "NAME");
int shown = 0;
for (int i = 0; i < L.catalog.Count(); ++i)
{
const EntityDesc& d = L.catalog.At(i);
if (byClass && d.cls != want) continue;
if (depth > 0 && !(depth >= d.minDepth && (d.maxDepth <= 0 || depth <= d.maxDepth)))
continue;
std::printf("%-12s %-8s %7s %7.1f %5d %s\n", d.id.c_str(), EntityClassName(d.cls),
DepthRange(d.minDepth, d.maxDepth).c_str(), double(d.danger), d.tier,
d.name.c_str());
++shown;
}
if (depth > 0) std::printf("\nна этаже %d доступно записей: %d\n", depth, shown);
return 0;
}
int CmdCurve(const Args& a)
{
Loaded L;
if (!OpenProject(a, L, false)) return 1;
const int from = std::max(1, a.Int(0, 1));
const int to = std::max(from, a.Int(1, from + 11));
std::printf("%-6s %-7s %8s %8s %6s %5s %7s %6s\n", "DEPTH", "THEME", "DANGER", "DENSITY",
"LOOT", "TIER", "SIGHT", "XP");
for (int d = from; d <= to; ++d)
{
const FloorSpec s = DescribeFloor(d);
std::printf("%-6d %-7s %8.1f %8.2f %6d %5d %7.2f %6.2f\n", s.depth, ThemeName(s.theme),
double(s.dangerBudget), double(s.spawnChanceScale), s.lootDrops, s.lootTier,
double(s.sightScale), double(s.xpScale));
}
return 0;
}
// Сборка этажа — общая часть для floor и map.
bool BuildFloor(Loaded& L, int depth, uint32_t seed, Tilemap& map, GeneratedLevel& lvl)
{
const FloorSpec spec = DescribeFloor(depth);
std::string why;
if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why))
{
std::printf("ОШИБКА: этаж %d сид %u не собрался: %s\n", depth, unsigned(seed), why.c_str());
return false;
}
return true;
}
int CmdFloor(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const int depth = std::max(1, a.Int(0, 1));
const uint32_t seed = uint32_t(a.Int(1, 1));
static Tilemap map;
static GeneratedLevel lvl;
if (!BuildFloor(L, depth, seed, map, lvl)) return 1;
std::printf("этаж %d, сид %u, тема %s\n", lvl.floor.depth, unsigned(seed),
ThemeName(lvl.floor.theme));
std::printf("комнат %d, коридоров %d, достижимо тайлов %d, до спуска %d шагов\n",
lvl.roomsPlaced, lvl.corridors, lvl.reachableTiles, lvl.exitDistance);
std::printf("опасность %.1f из %.1f, тварей %d (долив %d), лута %d из тира %d\n",
double(lvl.dangerSpent), double(lvl.floor.dangerBudget), lvl.monstersPlaced,
lvl.monstersFilled, int(lvl.loot.size()), lvl.floor.lootTier);
std::printf("старт (%.1f,%.1f), спуск (%.1f,%.1f), эвакуация (%.1f,%.1f)\n",
double(lvl.squadStart.x), double(lvl.squadStart.y), double(lvl.exitPos.x),
double(lvl.exitPos.y), double(lvl.extractPos.x), double(lvl.extractPos.y));
// Всё, из-за чего этаж населён не так, как задумано, печатается отдельно:
// молчаливая усушка населения — это не «баланс».
int bad = 0;
if (lvl.spawnsDropped > 0)
{
std::printf("ПОТЕРИ: срезано потолком %d: %d\n", MAX_LEVEL_SPAWNS, lvl.spawnsDropped);
++bad;
}
if (lvl.spawnsUnreachable > 0)
{
std::printf("ПОТЕРИ: недостижимо от старта: %d\n", lvl.spawnsUnreachable);
++bad;
}
if (lvl.dangerSpent < lvl.floor.dangerBudget * 0.75f)
std::printf("ЗАМЕЧАНИЕ: набор отстаёт от кривой, бюджет выбран не полностью\n");
// Кого именно выкатило — по количеству, а не списком: список из полусотни
// строк невозможно сравнить с таким же списком другого сида.
std::vector<std::pair<std::string, int>> tally;
for (const LevelSpawn& s : lvl.spawns)
{
auto it = std::find_if(tally.begin(), tally.end(),
[&](const std::pair<std::string, int>& p) { return p.first == s.id; });
if (it == tally.end()) tally.push_back({s.id, 1});
else ++it->second;
}
std::sort(tally.begin(), tally.end(),
[](const std::pair<std::string, int>& x, const std::pair<std::string, int>& y) {
return x.second > y.second;
});
std::printf("\nнаселение:");
for (const std::pair<std::string, int>& p : tally) std::printf(" %s x%d", p.first.c_str(), p.second);
std::printf("\nлут: ");
for (const LevelSpawn& s : lvl.loot) std::printf(" %s", s.id.c_str());
std::printf("\n");
return bad > 0 ? 1 : 0;
}
int CmdMap(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const int depth = std::max(1, a.Int(0, 1));
const uint32_t seed = uint32_t(a.Int(1, 1));
static Tilemap map;
static GeneratedLevel lvl;
if (!BuildFloor(L, depth, seed, map, lvl)) return 1;
// Карта строится в буфер символов, потом на неё кладутся сущности: иначе
// тварь, стоящая в дверях, потерялась бы под символом двери.
std::vector<char> grid(size_t(MAP_W) * size_t(MAP_H), ' ');
for (int y = 0; y < MAP_H; ++y)
for (int x = 0; x < MAP_W; ++x)
grid[size_t(y) * MAP_W + x] = map.IsWall(x, y) ? '#' : '.';
auto put = [&](Vec2 p, char c) {
const int x = int(p.x);
const int y = int(p.y);
if (Tilemap::InBounds(x, y)) grid[size_t(y) * MAP_W + x] = c;
};
for (const LevelSpawn& s : lvl.spawns)
{
const EntityDesc* d = L.catalog.Find(s.id);
put(s.pos, (d && d->cls == EntityClass::PROP) ? 'o' : 'm');
}
for (const LevelSpawn& s : lvl.loot) put(s.pos, '$');
put(lvl.exitPos, '>');
put(lvl.extractPos, '<');
put(lvl.squadStart, '@');
std::printf("этаж %d, сид %u, тема %s, комнат %d\n\n", lvl.floor.depth, unsigned(seed),
ThemeName(lvl.floor.theme), lvl.roomsPlaced);
for (int y = 0; y < MAP_H; ++y)
{
std::printf("%3d ", y);
for (int x = 0; x < MAP_W; ++x) std::printf("%c", grid[size_t(y) * MAP_W + x]);
std::printf("\n");
}
std::printf("\n# стена . пол @ старт > спуск < эвакуация m тварь o обстановка $ лут\n");
return 0;
}
int CmdValidate(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
int bad = 0;
int warn = 0;
for (const Room& r : L.rooms.rooms)
{
const std::string problem = RoomProblem(r);
if (!problem.empty())
{
std::printf("СЛОМАНО %s: %s\n", r.name.c_str(), problem.c_str());
++bad;
}
for (const SpawnPoint& p : r.spawns)
for (const SpawnOption& o : p.options)
if (L.catalog.IndexOf(o.id) < 0)
{
std::printf("СЛОМАНО %s (%d,%d): id `%s` нет в каталоге\n", r.name.c_str(),
p.x, p.y, o.id.c_str());
++bad;
}
}
for (const std::string& f : L.rooms.failures)
{
std::printf("СЛОМАНО не читается %s\n", f.c_str());
++bad;
}
if (!L.rooms.HasStartRoom())
{
std::printf("СЛОМАНО в наборе нет ни одной стартовой комнаты\n");
++bad;
}
// Покрытие глубин: главный вопрос бесконечного спуска — не «хороша ли
// комната», а «есть ли чем застроить сотый этаж».
const int probeMax = std::max(L.catalog.DeepestIntroduction(), 12) + 4;
for (int d = 1; d <= probeMax; ++d)
{
const bool roomsHere = L.rooms.CountAt(d) > 0;
const bool startHere = L.rooms.HasStartRoomAt(d);
const bool monsters = L.catalog.HasAnyAt(EntityClass::MONSTER, d);
const bool loot = L.catalog.HasAnyAt(EntityClass::LOOT, d);
if (roomsHere && startHere && monsters && loot) continue;
std::printf("СЛОМАНО этаж %d:%s%s%s%s\n", d, roomsHere ? "" : " нет комнат",
startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей",
loot ? "" : " нет лута");
++bad;
}
static Tilemap map;
static GeneratedLevel lvl;
for (const int d : {1, 3, 6, 10, 16, 24})
for (const uint32_t seed : {1u, 42u})
{
const FloorSpec spec = DescribeFloor(d);
std::string why;
if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why))
{
std::printf("СЛОМАНО этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str());
++bad;
continue;
}
if (lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 || lvl.loot.empty() ||
lvl.exitDistance <= 0)
{
std::printf("СЛОМАНО этаж %d сид %u: недостижимых %d, срезано %d, лута %d, "
"до спуска %d\n",
d, unsigned(seed), lvl.spawnsUnreachable, lvl.spawnsDropped,
int(lvl.loot.size()), lvl.exitDistance);
++bad;
}
else if (lvl.dangerSpent < spec.dangerBudget * 0.75f)
{
std::printf("ЗАМЕЧАНИЕ этаж %d сид %u: опасность %.0f из %.0f\n", d, unsigned(seed),
double(lvl.dangerSpent), double(spec.dangerBudget));
++warn;
}
}
std::printf("\n%s: комнат %d, сущностей %d, поломок %d, замечаний %d\n",
L.project.name.c_str(), L.rooms.Count(), L.catalog.Count(), bad, warn);
return bad > 0 ? 1 : 0;
}
} // namespace
int RunTool(int argc, char** argv)
{
const Args a = Parse(argc, argv);
if (a.cmd.empty() || a.cmd == "help") return CmdHelp();
if (a.cmd == "projects") return CmdProjects();
if (a.cmd == "project") return CmdProject(a);
if (a.cmd == "rooms") return CmdRooms(a);
if (a.cmd == "room") return CmdRoom(a);
if (a.cmd == "ascii") return CmdAscii(a);
if (a.cmd == "catalog") return CmdCatalog(a);
if (a.cmd == "curve") return CmdCurve(a);
if (a.cmd == "floor") return CmdFloor(a);
if (a.cmd == "map") return CmdMap(a);
if (a.cmd == "validate") return CmdValidate(a);
std::printf("ОШИБКА: нет команды `%s`\n\n", a.cmd.c_str());
CmdHelp();
return 1;
}

26
editor/tools.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
// -----------------------------------------------------------------------------
// Инструменты Tile2D: то, чем работают с проектом БЕЗ окна и без мыши.
//
// Зачем это существует отдельно от редактора. Содержимое проекта — текст:
// комнаты, каталог сущностей и манифест лежат в git и правятся руками. Значит,
// править их умеет кто угодно, включая языковую модель: открыл файл, изменил,
// сохранил. Чего у неё НЕТ — обратной связи. Собралась ли комната в этаж,
// достижима ли точка спавна, выбрал ли этаж бюджет опасности, во что
// превращается набор на двадцатом этаже — на эти вопросы нельзя ответить,
// глядя на текст комнаты.
//
// Поэтому инструменты — это не «API редактора», а ЗАМКНУТАЯ ПЕТЛЯ: правку
// делают файлами, а последствия читают отсюда. Всё выводится текстом, включая
// карту собранного этажа: тем же правилом живут ассеты
// (docs/12-assets.md — «проверка только текстом»), и по той же причине —
// картинку нельзя ни продиффать, ни процитировать в отчёте.
//
// tile2d_editor --tool <команда> [аргументы] [--project <имя>]
//
// Код возврата: 0 — всё в порядке, 1 — команда не выполнена или проверка
// нашла поломку. Это делает инструменты пригодными для скриптов и хуков.
// -----------------------------------------------------------------------------
// Разбирает свой хвост командной строки целиком. Возвращает код процесса.
int RunTool(int argc, char** argv);

107
engine/app.cpp Normal file
View File

@ -0,0 +1,107 @@
#include "engine/app.h"
#include <raylib.h>
#include "engine/config.h"
namespace
{
// Безрамочное окно на весь монитор. Отдельно, потому что порядок здесь важен:
// raylib разворачивает окно по ТЕКУЩЕМУ монитору, поэтому сначала размер по
// монитору, потом снятие рамки.
void GoBorderless()
{
const int mon = GetCurrentMonitor();
const int w = GetMonitorWidth(mon);
const int h = GetMonitorHeight(mon);
if (w > 0 && h > 0) SetWindowSize(w, h);
ToggleBorderlessWindowed();
}
} // namespace
bool App::Init(const AppConfig& cfg)
{
if (cfg.vsync) SetConfigFlags(FLAG_VSYNC_HINT);
const int w = (cfg.frameW > 0) ? cfg.frameW : INTERNAL_W;
const int h = (cfg.frameH > 0) ? cfg.frameH : INTERNAL_H;
InitWindow(w * WINDOW_SCALE, h * WINDOW_SCALE, cfg.title);
// Esc не закрывает окно сам. Приложение решает, что он значит: в игре это
// пауза, в редакторе — закрыть терминал. Выход по случайному нажатию
// посреди вылазки — не то, за что игрок скажет спасибо.
SetExitKey(KEY_NULL);
if (cfg.borderless) GoBorderless();
fb.Init(w, h);
screen.Init(w, h);
return IsWindowReady();
}
void App::Shutdown()
{
screen.Shutdown();
CloseWindow();
}
bool App::NextFrame()
{
if (quit || WindowShouldClose()) return false;
if (IsKeyPressed(KEY_F11)) ToggleBorderlessWindowed();
winW = GetScreenWidth();
winH = GetScreenHeight();
fit = ScreenFitRect(fb.w, fb.h, winW, winH);
frameDt = GetFrameTime();
return true;
}
int App::Advance(bool halted)
{
if (halted)
{
accumulator = 0.0;
return 0;
}
accumulator += double(frameDt);
int steps = 0;
while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME)
{
accumulator -= FIXED_DT;
++steps;
}
// Не успели за кадр — остаток выбрасывается целиком. Копить его значит
// копить долг, который никогда не отдать: следующий кадр будет таким же.
if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0;
return steps;
}
float App::Alpha() const
{
return float(accumulator / FIXED_DT);
}
void App::Present()
{
BeginDrawing();
ClearBackground(BLACK);
screen.Present(fb, winW, winH);
}
void App::Finish()
{
EndDrawing();
}
void App::Screenshot(const char* file) const
{
if (file) TakeScreenshot(file);
}

92
engine/app.h Normal file
View File

@ -0,0 +1,92 @@
#pragma once
// -----------------------------------------------------------------------------
// Приложение Tile2D: окно, кадр и ход времени.
//
// До этого окно, безрамочный режим, фреймбуфер, блиттер и fixed timestep жили
// двумя почти одинаковыми копиями — в игре и в редакторе. Третьему приложению
// пришлось бы написать третью, и она бы уже отличалась: где-то забытый
// `SetExitKey`, где-то другой потолок шагов, где-то апскейл не целым числом.
//
// Здесь НЕ инверсия управления: цикл остаётся у приложения, а App отвечает на
// вопросы «пора ли закрываться», «сколько шагов симуляции прошло» и «куда лёг
// кадр в окне». Колбэки сделали бы порядок «шаг — рендер — интерфейс»
// невидимым, а он у каждого приложения свой и в нём вся суть.
//
// Типичный цикл целиком:
//
// App app;
// app.Init({"Моя игра"});
// while (app.NextFrame())
// {
// for (int i = 0, n = app.Advance(false); i < n; ++i) game.Step(FIXED_DT);
// Render(game, app.fb, app.Alpha());
// app.Present(); // кадр на экран
// DrawUi(app.fit); // поверх кадра, примитивами raylib
// app.Finish();
// }
// app.Shutdown();
// -----------------------------------------------------------------------------
#include "engine/config.h"
#include "engine/framebuffer.h"
struct AppConfig
{
const char* title = "Tile2D";
// Безрамочное на весь монитор — обычный режим. Окно нужно для отладки и
// для съёмки картинок: на полном экране их не сравнить между собой.
bool borderless = true;
bool vsync = true;
// Разрешение кадра. По умолчанию — размер из engine/config.h.
int frameW = INTERNAL_W;
int frameH = INTERNAL_H;
};
struct App
{
Framebuffer fb;
ScreenBlitter screen;
// --- состояние текущего кадра ---------------------------------------------
int winW = 0;
int winH = 0;
// Куда лёг кадр в окне: на безрамочном экране он центрирован и вокруг него
// чёрные поля. Интерфейс и оверлей обязаны считать координаты ЧЕРЕЗ это.
ScreenRect fit;
float frameDt = 0.0f;
// Приложение может попроситься наружу само (пункт меню «выход», Esc в
// редакторе). Отдельный флаг, а не возврат из NextFrame: решение о выходе
// принимается в середине кадра, а закрыться надо в его конце.
bool quit = false;
bool Init(const AppConfig& cfg);
void Shutdown();
// Начало кадра: закрытие окна, F11, размеры, ScreenFitRect, dt.
// false — пора выходить.
bool NextFrame();
// Сколько раз выполнить шаг симуляции в этом кадре. Шаг постоянный
// (FIXED_DT), лишнее время копится до следующего кадра, потолок
// MAX_STEPS_PER_FRAME спасает от спирали смерти.
//
// halted — пауза: время НЕ копится. Иначе после меню симуляция догоняла бы
// пропущенные секунды одним рывком.
int Advance(bool halted);
// Доля незакрытого шага, [0..1). По ней интерполируется картинка: рендер
// идёт чаще симуляции, и без неё движение дёргалось бы на 60+ Гц.
float Alpha() const;
void Present(); // BeginDrawing + кадр на экран; дальше можно рисовать UI
void Finish(); // EndDrawing
// Снять кадр в файл. Здесь, а не в приложении, потому что снимать надо
// ПОСЛЕ Finish, и об этом легко забыть.
void Screenshot(const char* file) const;
private:
double accumulator = 0.0;
};

215
engine/catalog.cpp Normal file
View File

@ -0,0 +1,215 @@
#include "engine/catalog.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include "engine/text_parse.h"
namespace fs = std::filesystem;
namespace
{
// color=116,52,52 — три байта через запятую. Отдельным ключом, а не тремя:
// цвет это одно свойство, и разносить его по трём ключам значило бы разрешить
// написать два из трёх.
bool ParseColor(const std::string& v, EntityDesc& out)
{
const size_t c1 = v.find(',');
if (c1 == std::string::npos) return false;
const size_t c2 = v.find(',', c1 + 1);
if (c2 == std::string::npos) return false;
out.r = uint8_t(text::ToInt(v.substr(0, c1), 180));
out.g = uint8_t(text::ToInt(v.substr(c1 + 1, c2 - c1 - 1), 180));
out.b = uint8_t(text::ToInt(v.substr(c2 + 1), 180));
return true;
}
// depth=3 с третьего этажа и глубже
// depth=1..4 с первого по четвёртый включительно
bool ParseDepth(const std::string& v, EntityDesc& out)
{
const size_t dots = v.find("..");
if (dots == std::string::npos)
{
out.minDepth = std::max(1, text::ToInt(v, 1));
out.maxDepth = 0;
return true;
}
out.minDepth = std::max(1, text::ToInt(v.substr(0, dots), 1));
out.maxDepth = text::ToInt(v.substr(dots + 2), 0);
return out.maxDepth <= 0 || out.maxDepth >= out.minDepth;
}
// entity <id> ключ=значение ...
bool ParseEntity(const std::vector<std::string>& p, EntityDesc& out, std::string& why)
{
if (p.size() < 2)
{
why = "у записи нет id";
return false;
}
out.id = p[1];
out.name = p[1];
bool haveClass = false;
for (size_t i = 2; i < p.size(); ++i)
{
std::string key, value;
if (!text::SplitKeyValue(p[i], key, value) || key.empty() || value.empty())
{
why = "не ключ=значение: " + p[i];
return false;
}
if (key == "class")
{
if (value == "monster") out.cls = EntityClass::MONSTER;
else if (value == "prop") out.cls = EntityClass::PROP;
else if (value == "loot") out.cls = EntityClass::LOOT;
else { why = "неизвестный класс: " + value; return false; }
haveClass = true;
}
else if (key == "name") out.name = value;
else if (key == "sprite") out.sprite = (value == "-") ? std::string{} : value;
else if (key == "radius") out.radius = text::ToFloat(value, 0.34f);
else if (key == "danger") out.danger = text::ToFloat(value, 1.0f);
else if (key == "tier") out.tier = text::ToInt(value, 0);
else if (key == "color")
{
if (!ParseColor(value, out)) { why = "цвет не разбирается: " + value; return false; }
}
else if (key == "depth")
{
if (!ParseDepth(value, out)) { why = "глубина не разбирается: " + value; return false; }
}
else
{
// Молча глотать незнакомый ключ нельзя: опечатка в `dangre=3`
// означала бы тварь с ценой по умолчанию и ровно ноль признаков
// того, что что-то не так.
why = "неизвестный ключ: " + key;
return false;
}
}
if (!haveClass)
{
why = "не указан class";
return false;
}
// Обстановка и предметы в бюджет опасности не входят по определению:
// ящик не делает этаж страшнее, он делает его обжитее.
if (out.cls != EntityClass::MONSTER) out.danger = 0.0f;
return true;
}
} // namespace
const char* EntityClassName(EntityClass cls)
{
switch (cls)
{
case EntityClass::MONSTER: return "MONSTER";
case EntityClass::PROP: return "PROP";
default: return "LOOT";
}
}
bool EntityCatalog::Load(const std::string& path, std::string* err)
{
std::ifstream in(path);
if (!in)
{
if (err) *err = "cannot open catalog: " + path;
return false;
}
// Разбираем в СВОЙ вектор и подменяем только на успехе: битая правка
// каталога не должна оставлять редактор с половиной палитры.
std::vector<EntityDesc> parsed;
std::string line;
int lineNo = 0;
while (std::getline(in, line))
{
++lineNo;
const std::vector<std::string> p = text::Split(text::StripComment(line));
if (p.empty()) continue;
if (p[0] != "entity")
{
if (err) *err = "line " + std::to_string(lineNo) + ": expected `entity`";
return false;
}
EntityDesc d;
std::string why;
if (!ParseEntity(p, d, why))
{
if (err) *err = "line " + std::to_string(lineNo) + ": " + why;
return false;
}
// Дубль id молча перекрыл бы предыдущую запись, и в редакторе появились
// бы две одинаковые кнопки палитры с разным поведением.
for (const EntityDesc& prev : parsed)
{
if (prev.id != d.id) continue;
if (err) *err = "line " + std::to_string(lineNo) + ": duplicate id `" + d.id + "`";
return false;
}
parsed.push_back(std::move(d));
}
if (parsed.empty())
{
if (err) *err = "empty catalog: " + path;
return false;
}
entries = std::move(parsed);
return true;
}
int EntityCatalog::IndexOf(const std::string& id) const
{
for (size_t i = 0; i < entries.size(); ++i)
if (entries[i].id == id) return int(i);
return -1;
}
const EntityDesc* EntityCatalog::Find(const std::string& id) const
{
const int i = IndexOf(id);
return (i >= 0) ? &entries[size_t(i)] : nullptr;
}
int EntityCatalog::CountOf(EntityClass cls) const
{
int n = 0;
for (const EntityDesc& d : entries)
if (d.cls == cls) ++n;
return n;
}
bool EntityCatalog::HasAnyAt(EntityClass cls, int depth) const
{
for (const EntityDesc& d : entries)
if (d.cls == cls && d.InDepth(depth)) return true;
return false;
}
int EntityCatalog::DeepestIntroduction() const
{
int deepest = 1;
for (const EntityDesc& d : entries) deepest = std::max(deepest, d.minDepth);
return deepest;
}
// FindCatalogPath() здесь БОЛЬШЕ НЕТ — по той же причине, что и FindRoomsDir:
// путь к содержимому называет проект (engine/project.h).

111
engine/catalog.h Normal file
View File

@ -0,0 +1,111 @@
#pragma once
// -----------------------------------------------------------------------------
// Каталог размещаемых сущностей: ЧТО вообще можно поставить в комнату и на
// какой глубине это встречается.
//
// Это граница между движком и игрой. Редактор про EnemyKind и ItemId не знает
// и знать не может — он линкуется только с движком. Всё, что он видит, —
// строковые id из этого каталога: `shambler`, `crate`, `plate`. Игра переводит
// id в свои перечисления одной таблицей (sim/spawn_catalog.h), и это
// ЕДИНСТВЕННОЕ место перевода.
//
// Почему не общий enum на движок и игру: тогда добавление твари правило бы
// заголовок движка, редактор пришлось бы пересобирать вместе с игрой, а
// «движок» превратился бы в подкаталог игры. Здесь же новая тварь — это строка
// в текстовом файле плюс одна строка в таблице перевода.
//
// --- почему формат key=value -------------------------------------------------
// Первая версия каталога была позиционной: цвет, радиус и спрайт стояли по
// местам. Игра про бесконечный спуск потребовала добавить сущности глубину,
// опасность и качество, и позиционный формат на этом кончился: каждое новое
// свойство ломало бы все уже написанные строки. Ключи же дописываются с краю
// и не трогают ничего.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
// Роль сущности. Редактору важны эти три случая: они по-разному ставятся и
// по-разному попадают в уровень.
enum class EntityClass : uint8_t
{
MONSTER = 0, // тварь: ходит, бьёт, участвует в зачистке и тратит бюджет опасности
PROP, // обстановка: ящик, бочка, шкафчик. Опасности не несёт
LOOT, // предмет: лежит на полу, подбирается на ходу
};
const char* EntityClassName(EntityClass cls);
struct EntityDesc
{
std::string id; // то, что попадает в файл комнаты
std::string name; // подпись в палитре редактора
EntityClass cls = EntityClass::MONSTER;
uint8_t r = 180, g = 180, b = 180;
float radius = 0.34f;
// Имя ассета SpriteForge (`sf list`), пусто — рисовать капсулу по цвету.
// Имя, а не числовой id: каталог читают данными, а generated-заголовок
// с константами SF_ASSET_* принадлежит игре, а не движку.
std::string sprite;
// --- глубина --------------------------------------------------------------
// С какого этажа сущность вообще может появиться. Это главный рычаг всей
// игры: «чем глубже, тем страшнее» — это в первую очередь ЗДЕСЬ, а не в
// множителях урона. Громила на первом этаже ломает не баланс, а обещание.
int minDepth = 1;
// По какой этаж включительно; 0 — до бесконечности. Нужен, чтобы мелочь
// первых этажей перестала занимать место на глубине.
int maxDepth = 0;
// Цена в бюджете опасности этажа (engine/progression.h). Не урон и не HP,
// а «сколько места в голове игрока эта тварь занимает»: бюджет — это то,
// чем этаж отличается по СЛОЖНОСТИ, а не по числу тел.
float danger = 1.0f;
// Качество предмета для class=loot. Этаж выдаёт лут своего тира и ниже,
// поэтому карабин не исчезает на глубине — он просто перестаёт быть
// находкой.
int tier = 0;
bool InDepth(int depth) const
{
return depth >= minDepth && (maxDepth <= 0 || depth <= maxDepth);
}
};
// Порядок записей — порядок палитры в редакторе. Он же порядок в файле:
// каталог правится руками, и перетасовывать его при загрузке нельзя.
struct EntityCatalog
{
std::vector<EntityDesc> entries;
// Разобрать текстовый каталог. false — файла нет или он битый; err (если
// передан) получает первую же осечку с номером строки.
bool Load(const std::string& path, std::string* err = nullptr);
int Count() const { return int(entries.size()); }
const EntityDesc& At(int i) const { return entries[size_t(i)]; }
// -1 — такого id в каталоге нет. Комната, ссылающаяся на выпавший id,
// не должна ронять ни редактор, ни игру: точка просто не сработает.
int IndexOf(const std::string& id) const;
const EntityDesc* Find(const std::string& id) const;
// Сколько записей данного класса. Палитра редактора разбита на вкладки.
int CountOf(EntityClass cls) const;
// Есть ли на этой глубине хоть одна тварь и хоть один предмет. Отвечает на
// единственный вопрос, который может убить забег: «а есть ли чем населить
// сотый этаж».
bool HasAnyAt(EntityClass cls, int depth) const;
// Самая глубокая точка, дальше которой каталог перестаёт что-либо
// добавлять: максимум minDepth по всем записям. Дальше неё этаж собирается
// из того же набора, и это нормально — бесконечность не требует
// бесконечного каталога, она требует, чтобы он не кончался.
int DeepestIntroduction() const;
};
// Где лежит каталог — знает проект (engine/project.h). Движок сам его не ищет.

51
engine/config.h Normal file
View File

@ -0,0 +1,51 @@
#pragma once
// -----------------------------------------------------------------------------
// Tile2D — размеры, которые нельзя поменять, не пересобрав движок.
//
// Это НЕ настройки проекта. Настройки живут в project.txt и читаются в рантайме
// (engine/project.h); сюда попадает только то, от чего зависят размеры массивов
// и что поэтому обязано быть известно компилятору.
//
// Раньше эти числа лежали в src/tuning.h — то есть движок собирался только
// вместе с игрой и знал её имя. Теперь наоборот: tuning.h включает этот файл,
// а движок про игру не знает ничего.
// -----------------------------------------------------------------------------
// --- кадр --------------------------------------------------------------------
// Внутреннее разрешение софтверного фреймбуфера. Апскейл до окна — ЦЕЛЫМ
// множителем, поэтому окно кратно кадру.
constexpr int INTERNAL_W = 480;
constexpr int INTERNAL_H = 270;
constexpr int WINDOW_SCALE = 3;
constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE;
constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE;
// --- геометрия тайла ---------------------------------------------------------
// Размер тайла в ИЗОМЕТРИИ: ромб 32x16 (наклон 2:1) и стена 32x32.
//
// Проекция может взять другой размер (engine/projection.h): вид сверху рисует
// квадрат 32x32. Эти константы — размер по умолчанию и верхняя оценка для
// отсечения по краю кадра, а не единственно возможный тайл.
constexpr int TILE_W = 32;
constexpr int TILE_H = 16;
constexpr int TILE_HALF_W = TILE_W / 2;
constexpr int TILE_HALF_H = TILE_H / 2;
constexpr int WALL_H = 32;
// Самый большой тайл, который может выдать любая проекция. По нему считается
// запас отсечения: спрайт, торчащий в кадр краем, обязан дорисоваться.
constexpr int TILE_MAX_W = 32;
constexpr int TILE_MAX_H = 32;
// --- шаг симуляции -----------------------------------------------------------
// Fixed timestep: шаг постоянный, кадр интерполируется. MAX_STEPS_PER_FRAME —
// защита от спирали смерти (долгий кадр не должен догоняться вечно).
constexpr float FIXED_DT = 1.0f / 60.0f;
constexpr int MAX_STEPS_PER_FRAME = 5;
// --- карта -------------------------------------------------------------------
// Тайлмап — плоский массив фиксированного размера, поэтому размер карты
// компилтаймовый. Проект меньше этого предела занимает часть карты; больше —
// не соберётся, и это честнее, чем молча обрезать уровень.
constexpr int MAP_W = 48;
constexpr int MAP_H = 48;

View File

@ -1,4 +1,4 @@
#include "render/framebuffer.h"
#include "engine/framebuffer.h"
#include <raylib.h>
@ -319,6 +319,27 @@ void ScreenBlitter::Shutdown()
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;
@ -326,7 +347,8 @@ void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) con
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 = {0.0f, 0.0f, float(windowW), float(windowH)};
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);
}

View File

@ -179,6 +179,17 @@ struct Framebuffer
// Финальный вывод: UpdateTexture + DrawTexturePro, фильтрация POINT.
// Единственное место рендера, знающее про raylib.
// -----------------------------------------------------------------------------
// Куда лёг кадр внутри окна. Оверлей и UI обязаны считать координаты ЧЕРЕЗ него:
// на borderless fullscreen кадр центрирован и вокруг него чёрные поля.
struct ScreenRect
{
int x = 0, y = 0, w = 0, h = 0;
int scale = 1;
};
ScreenRect ScreenFitRect(int fbW, int fbH, int windowW, int windowH);
struct ScreenBlitter
{
void Init(int width, int height);

665
engine/level_gen.cpp Normal file
View File

@ -0,0 +1,665 @@
#include "engine/level_gen.h"
#include <algorithm>
namespace
{
// Индекс слота и обратно. Слоты нумеруются построчно, как тайлы карты.
inline int SlotIdx(int gx, int gy) { return gy * LEVEL_GRID + gx; }
// Доля лишних связей поверх остовного дерева. Ноль — этаж-дерево: из любой
// комнаты ровно один путь назад, и отряд ходит по нему туда-обратно. Единица —
// решётка без тупиков, в которой не читается планировка. Треть — компромисс:
// петли есть, тупики тоже.
constexpr float EXTRA_LINK_CHANCE = 0.33f;
// Ближе этого к точке старта долитые твари не ставятся. Отряд обязан успеть
// встать в строй: тварь, выросшая вплотную к точке входа, отнимает не
// сложностью, а тем, что решение принять негде.
constexpr float FILL_MIN_DIST_FROM_START = 9.0f;
// Сколько раз генератор пытается долить бюджет, прежде чем сдаться. Защита от
// вырожденного этажа, где свободных далёких клеток просто нет.
constexpr int FILL_ATTEMPTS = 200;
// Выбрать комнату по весу среди разрешённых на этом этаже. Вес — это «как
// часто она встречается», и он живёт в самом файле комнаты: набор правится
// добавлением файла, а не правкой кода.
int PickRoom(const RoomLibrary& lib, LevelRng& rng, int depth, bool needStart)
{
int total = 0;
for (const Room& r : lib.rooms)
{
if (!r.InDepth(depth)) continue;
if (needStart && !r.start) continue;
total += std::max(1, r.weight);
}
if (total <= 0) return -1;
int roll = rng.Below(total);
for (size_t i = 0; i < lib.rooms.size(); ++i)
{
const Room& r = lib.rooms[i];
if (!r.InDepth(depth)) continue;
if (needStart && !r.start) continue;
roll -= std::max(1, r.weight);
if (roll < 0) return int(i);
}
return -1;
}
// -----------------------------------------------------------------------------
// Прорубка. Коридор шириной 2: отряд из пяти человек в проходе шириной 1 идёт
// строго гуськом, и весь смысл строя пропадает. Ширина берётся ровно такой же,
// как у проходов захардкоженной карты (docs/04-world.md) — иначе поведение
// отряда в коридоре пришлось бы настраивать заново.
// -----------------------------------------------------------------------------
constexpr int CORRIDOR_W = 2;
// Внешнее кольцо карты остаётся камнем всегда: вне карты считается стеной, и
// коридор, дошедший до края, дал бы отряду выйти в это «вне».
inline bool CarveOk(int x, int y)
{
return x >= 1 && y >= 1 && x < MAP_W - 1 && y < MAP_H - 1;
}
void CarveCell(Tilemap& map, int x, int y, uint8_t theme)
{
if (!CarveOk(x, y)) return;
map.Set(x, y, Tile::EMPTY);
map.SetTheme(x, y, theme);
}
// Отрезок по оси, шириной CORRIDOR_W в перпендикулярном направлении.
void CarveRun(Tilemap& map, int x0, int y0, int x1, int y1, uint8_t theme)
{
const int stepX = (x1 > x0) ? 1 : (x1 < x0 ? -1 : 0);
const int stepY = (y1 > y0) ? 1 : (y1 < y0 ? -1 : 0);
// Толщина набирается поперёк хода: у вертикального прогона — по x.
const int thickX = (stepX == 0) ? 1 : 0;
const int thickY = (stepY == 0) ? 1 : 0;
int x = x0, y = y0;
for (;;)
{
for (int t = 0; t < CORRIDOR_W; ++t) CarveCell(map, x + t * thickX, y + t * thickY, theme);
if (x == x1 && y == y1) break;
x += stepX;
y += stepY;
}
}
// Где комната встречает коридор со стороны side: дверь, если она есть, иначе
// середина стены. Второе — не поблажка: комната без двери на нужной стороне
// остаётся легальной, просто генератор пробьёт её сам, и это видно на карте.
void ContactPoint(const Room& r, RoomSide side, int& outX, int& outY)
{
if (r.FirstDoorOn(side, outX, outY)) return;
r.DefaultBreachOn(side, outX, outY);
}
// Сшить два соседних слота. Путь — «вышли из двери, добрались до середины
// зазора, повернули, дошли до чужой двери»: три отрезка по осям, никакой
// диагонали. Диагональный коридор в изометрии читается как лестница и стоит
// вдвое больше тайлов.
//
// Коридор берёт тему ЭТАЖА, а не комнаты: комнаты на этаже могут быть разных
// тем, и если коридор наследует чью-то, переход между комнатами читается как
// продолжение одной из них, а не как переход.
void ConnectSlots(Tilemap& map, const RoomLibrary& lib, const GeneratedLevel& lvl, int ga, int gb,
RoomSide dir, uint8_t theme)
{
const LevelSlot& A = lvl.slots[ga];
const LevelSlot& B = lvl.slots[gb];
if (A.roomIndex < 0 || B.roomIndex < 0) return;
const Room& ra = lib.rooms[size_t(A.roomIndex)];
const Room& rb = lib.rooms[size_t(B.roomIndex)];
int ax = 0, ay = 0, bx = 0, by = 0;
ContactPoint(ra, dir, ax, ay);
ContactPoint(rb, OppositeSide(dir), bx, by);
// В координаты карты.
ax += A.originX; ay += A.originY;
bx += B.originX; by += B.originY;
const int dx = SideDX(dir);
const int dy = SideDY(dir);
// Сама дверь прорубается всегда: если её пробили по умолчанию, стены на
// этом месте больше нет, а если это нарисованная дверь — она и так пол.
CarveCell(map, ax, ay, theme);
CarveCell(map, bx, by, theme);
// Дверь шириной в один тайл упёрлась бы в коридор шириной два: узкое место
// осталось бы ровно в проёме, там, где его меньше всего ждёшь.
CarveCell(map, ax + (dy != 0 ? 1 : 0), ay + (dx != 0 ? 1 : 0), theme);
CarveCell(map, bx + (dy != 0 ? 1 : 0), by + (dx != 0 ? 1 : 0), theme);
// Шаг наружу из обеих дверей — в зазор между комнатами.
const int aox = ax + dx, aoy = ay + dy;
const int box = bx - dx, boy = by - dy;
if (dx != 0)
{
const int midX = (aox + box) / 2;
CarveRun(map, aox, aoy, midX, aoy, theme);
CarveRun(map, midX, aoy, midX, boy, theme);
CarveRun(map, midX, boy, box, boy, theme);
}
else
{
const int midY = (aoy + boy) / 2;
CarveRun(map, aox, aoy, aox, midY, theme);
CarveRun(map, aox, midY, box, midY, theme);
CarveRun(map, box, midY, box, boy, theme);
}
}
// Отпечатать комнату в карту. Дверь — это пол: генератор сам решает, куда
// от неё тянуть коридор, и оставлять её стеной означало бы запереть комнату.
void StampRoom(Tilemap& map, const Room& r, int ox, int oy)
{
for (int y = 0; y < r.h; ++y)
for (int x = 0; x < r.w; ++x)
{
const int mx = ox + x;
const int my = oy + y;
if (!Tilemap::InBounds(mx, my)) continue;
map.Set(mx, my, (r.At(x, y) == RoomCell::WALL) ? Tile::WALL : Tile::EMPTY);
map.SetTheme(mx, my, r.theme);
}
}
// Ближайший к (cx, cy) проходимый тайл. Спираль, а не поиск в ширину: искать
// надо на два-три тайла, и очередь на такой дистанции дороже перебора.
bool NearestFloor(const Tilemap& map, int cx, int cy, int& outX, int& outY)
{
for (int rad = 0; rad < ROOM_SLOT; ++rad)
{
for (int dy = -rad; dy <= rad; ++dy)
for (int dx = -rad; dx <= rad; ++dx)
{
if (std::max(std::abs(dx), std::abs(dy)) != rad) continue;
const int x = cx + dx;
const int y = cy + dy;
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
outX = x;
outY = y;
return true;
}
}
return false;
}
// Поиск в ширину от точки старта: расстояние в шагах до каждого тайла, -1 —
// недостижим. Одна заливка отвечает сразу на три вопроса: что достижимо, где
// поставить спуск (самый дальний тайл) и куда можно класть лут.
//
// Своя очередь вместо рекурсии: у карты 48x48 худший случай — 2300 вложенных
// вызовов.
void DistancesFrom(const Tilemap& map, int sx, int sy, std::vector<int>& dist)
{
dist.assign(size_t(MAP_W) * MAP_H, -1);
if (!Tilemap::InBounds(sx, sy) || map.IsWall(sx, sy)) return;
std::vector<int> queue;
queue.reserve(size_t(MAP_W) * MAP_H);
queue.push_back(sy * MAP_W + sx);
dist[size_t(queue.back())] = 0;
for (size_t head = 0; head < queue.size(); ++head)
{
const int cur = queue[head];
const int cx = cur % MAP_W;
const int cy = cur / MAP_W;
const int d = dist[size_t(cur)];
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};
for (int k = 0; k < 4; ++k)
{
const int nx = cx + dx[k];
const int ny = cy + dy[k];
if (!Tilemap::InBounds(nx, ny) || map.IsWall(nx, ny)) continue;
const size_t ni = size_t(ny) * MAP_W + nx;
if (dist[ni] >= 0) continue;
dist[ni] = d + 1;
queue.push_back(int(ni));
}
}
}
// --- население ---------------------------------------------------------------
// Один вариант, доживший до фильтра по этажу и бюджету.
struct Allowed
{
const EntityDesc* desc;
int weight;
};
// Отобрать из таблицы точки то, что разрешено на этом этаже и по карману.
// Обстановка бюджета не тратит и проходит всегда.
void CollectAllowed(const SpawnPoint& p, const EntityCatalog& catalog, int depth, float budget,
std::vector<Allowed>& out)
{
out.clear();
for (const SpawnOption& o : p.options)
{
if (o.weight <= 0) continue;
const EntityDesc* d = catalog.Find(o.id);
if (!d || !d->InDepth(depth)) continue;
if (d->danger > budget) continue;
out.push_back(Allowed{d, o.weight});
}
}
const EntityDesc* PickAllowed(const std::vector<Allowed>& list, float roll01)
{
int total = 0;
for (const Allowed& a : list) total += a.weight;
if (total <= 0) return nullptr;
float acc = roll01 * float(total);
for (const Allowed& a : list)
{
acc -= float(a.weight);
if (acc < 0.0f) return a.desc;
}
return list.back().desc;
}
// Самая дешёвая тварь, доступная на этаже. Ею доливается остаток бюджета, и
// ею же проверяется, есть ли смысл продолжать долив.
const EntityDesc* CheapestMonster(const EntityCatalog& catalog, int depth)
{
const EntityDesc* best = nullptr;
for (int i = 0; i < catalog.Count(); ++i)
{
const EntityDesc& d = catalog.At(i);
if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue;
if (!best || d.danger < best->danger) best = &d;
}
return best;
}
// Тварь для долива. Здесь живёт главное правило глубины: когда МЕСТ мало, а
// бюджета много, этаж обязан становиться злее, а не многолюднее.
//
// Тел в мире не может быть больше MAX_LEVEL_SPAWNS, и на глубине бюджет
// неизбежно перерастает это число. Если бы долив продолжал брать кого попало,
// глубже определённого этажа игра переставала бы меняться вовсе: все места
// заняты мелочью, бюджет не выбран, двадцатый этаж равен пятнадцатому.
//
// Поэтому на каждое место считается, сколько опасности ОБЯЗАНО на него
// прийтись, и всё, что дешевле, отбрасывается. По мере заполнения карты порог
// растёт сам собой, и хвост этажа набирается самыми дорогими тварями.
const EntityDesc* PickFillMonster(const EntityCatalog& catalog, int depth, float budget,
int slotsLeft, float roll01)
{
const float needPerSlot = (slotsLeft > 0) ? budget / float(slotsLeft) : budget;
std::vector<Allowed> list;
std::vector<Allowed> fallback;
for (int i = 0; i < catalog.Count(); ++i)
{
const EntityDesc& d = catalog.At(i);
if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue;
if (d.danger > budget) continue;
// Вес пропорционален цене: дорогое выпадает чаще, но дешёвое не
// исчезает — этаж из одних громил тоже перестаёт быть интересным.
const Allowed a{&d, std::max(1, int(d.danger * 2.0f))};
fallback.push_back(a);
if (d.danger >= needPerSlot) list.push_back(a);
}
// Порог недостижим (самая дорогая тварь дешевле, чем нужно на место) —
// берём что есть: недобрать бюджет лучше, чем не поставить никого.
return PickAllowed(list.empty() ? fallback : list, roll01);
}
// Предмет для этажа: тир не выше, чем у этажа, и глубина подходит. Ближе к
// потолку тира — вероятнее: находка обязана становиться лучше с глубиной, но
// бинт не должен исчезать совсем.
const EntityDesc* PickLoot(const EntityCatalog& catalog, int depth, int tierCap, float roll01)
{
std::vector<Allowed> list;
for (int i = 0; i < catalog.Count(); ++i)
{
const EntityDesc& d = catalog.At(i);
if (d.cls != EntityClass::LOOT || !d.InDepth(depth)) continue;
if (d.tier > tierCap) continue;
const int gap = tierCap - d.tier;
list.push_back(Allowed{&d, (gap == 0) ? 4 : (gap == 1 ? 2 : 1)});
}
return PickAllowed(list, roll01);
}
} // namespace
bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor,
uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err)
{
if (lib.rooms.empty())
{
if (err) *err = "room set is empty";
return false;
}
const int depth = std::max(1, floor.depth);
if (lib.CountAt(depth) <= 0)
{
if (err) *err = "no rooms allowed at depth " + std::to_string(depth);
return false;
}
// Сид этажа мешается с глубиной: иначе один и тот же сид давал бы на всех
// этажах одну планировку, отличающуюся только населением.
LevelRng rng(seed ^ (uint32_t(depth) * 0x9E3779B9u));
GeneratedLevel lvl;
lvl.seed = seed;
lvl.floor = floor;
// --- 1. кто где стоит ----------------------------------------------------
for (int gy = 0; gy < LEVEL_GRID; ++gy)
for (int gx = 0; gx < LEVEL_GRID; ++gx)
{
const int idx = SlotIdx(gx, gy);
const int pick = PickRoom(lib, rng, depth, false);
if (pick < 0) continue;
const Room& r = lib.rooms[size_t(pick)];
lvl.slots[idx].roomIndex = pick;
// Комната центрируется в слоте: зазор до соседей одинаков со всех
// сторон, и коридору есть куда лечь при любой её ширине.
lvl.slots[idx].originX = gx * ROOM_SLOT + (ROOM_SLOT - r.w) / 2;
lvl.slots[idx].originY = gy * ROOM_SLOT + (ROOM_SLOT - r.h) / 2;
++lvl.roomsPlaced;
}
if (lvl.roomsPlaced == 0)
{
if (err) *err = "no room could be picked";
return false;
}
// --- 2. стартовая комната ------------------------------------------------
// Отряд обязан начинать там, где это задумано автором комнаты. Если после
// розыгрыша ни одной стартовой не выпало — ставим её принудительно в
// случайный слот, а не двигаем отряд в первую попавшуюся комнату.
int startSlot = -1;
for (int i = 0; i < LEVEL_SLOTS; ++i)
{
const int ri = lvl.slots[i].roomIndex;
if (ri >= 0 && lib.rooms[size_t(ri)].start) { startSlot = i; break; }
}
if (startSlot < 0 && lib.HasStartRoomAt(depth))
{
const int forced = PickRoom(lib, rng, depth, true);
if (forced >= 0)
{
startSlot = rng.Below(LEVEL_SLOTS);
const Room& r = lib.rooms[size_t(forced)];
if (lvl.slots[startSlot].roomIndex < 0) ++lvl.roomsPlaced;
lvl.slots[startSlot].roomIndex = forced;
lvl.slots[startSlot].originX =
(startSlot % LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.w) / 2;
lvl.slots[startSlot].originY =
(startSlot / LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.h) / 2;
}
}
if (startSlot < 0)
for (int i = 0; i < LEVEL_SLOTS && startSlot < 0; ++i)
if (lvl.slots[i].roomIndex >= 0) startSlot = i;
// --- 3. камень, потом комнаты -------------------------------------------
for (int y = 0; y < MAP_H; ++y)
for (int x = 0; x < MAP_W; ++x)
{
map.Set(x, y, Tile::WALL);
map.SetTheme(x, y, floor.theme);
}
for (int i = 0; i < LEVEL_SLOTS; ++i)
{
const LevelSlot& s = lvl.slots[i];
if (s.roomIndex < 0) continue;
StampRoom(map, lib.rooms[size_t(s.roomIndex)], s.originX, s.originY);
}
// --- 4. остовное дерево по слотам ---------------------------------------
// Обход в глубину со случайным порядком соседей. Дерево гарантирует, что
// из стартовой комнаты достижима каждая: этаж, часть которого отрезана,
// это не «интересная планировка», а брак.
bool visited[LEVEL_SLOTS] = {};
std::vector<int> stack;
stack.push_back(startSlot >= 0 ? startSlot : 0);
visited[stack.back()] = true;
// Связи запоминаются и режутся ПОСЛЕ обхода: прорубка на ходу меняла бы
// карту под ногами у следующих шагов, и порядок обхода стал бы важен.
struct Link { int a; int b; RoomSide dir; };
std::vector<Link> links;
while (!stack.empty())
{
const int cur = stack.back();
const int cx = cur % LEVEL_GRID;
const int cy = cur / LEVEL_GRID;
// Непосещённые соседи в случайном порядке.
int cand[4];
RoomSide dirs[4];
int n = 0;
for (int d = 0; d < 4; ++d)
{
const RoomSide side = RoomSide(d);
const int nx = cx + SideDX(side);
const int ny = cy + SideDY(side);
if (nx < 0 || ny < 0 || nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue;
const int ni = SlotIdx(nx, ny);
if (visited[ni] || lvl.slots[ni].roomIndex < 0) continue;
cand[n] = ni;
dirs[n] = side;
++n;
}
if (n == 0) { stack.pop_back(); continue; }
const int k = rng.Below(n);
links.push_back(Link{cur, cand[k], dirs[k]});
visited[cand[k]] = true;
stack.push_back(cand[k]);
}
// --- 5. лишние связи -----------------------------------------------------
for (int gy = 0; gy < LEVEL_GRID; ++gy)
for (int gx = 0; gx < LEVEL_GRID; ++gx)
{
const int a = SlotIdx(gx, gy);
if (lvl.slots[a].roomIndex < 0) continue;
// Только восток и юг: иначе каждая пара рассматривалась бы дважды.
const RoomSide sides[2] = {RoomSide::EAST, RoomSide::SOUTH};
for (const RoomSide side : sides)
{
const int nx = gx + SideDX(side);
const int ny = gy + SideDY(side);
if (nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue;
const int b = SlotIdx(nx, ny);
if (lvl.slots[b].roomIndex < 0) continue;
const bool already =
std::any_of(links.begin(), links.end(), [a, b](const Link& l) {
return (l.a == a && l.b == b) || (l.a == b && l.b == a);
});
if (already) continue;
if (rng.Float01() >= EXTRA_LINK_CHANCE) continue;
links.push_back(Link{a, b, side});
}
}
for (const Link& l : links)
{
ConnectSlots(map, lib, lvl, l.a, l.b, l.dir, floor.theme);
++lvl.corridors;
}
// --- 6. где встаёт отряд и где спуск -------------------------------------
// Старт считается ДО населения: и долив тварей, и лут смотрят на дистанцию
// от него, а заливка карты ни одного броска не тратит, поэтому сид от
// перестановки не страдает.
// Запасная точка — центр карты, а не место старта отряда из игры: движок
// не знает, что за отряд к нему приедет. Реально она берётся только если
// стартовой комнаты нет, а без неё этаж и так не собирается.
lvl.squadStart = Vec2{float(MAP_W) * 0.5f, float(MAP_H) * 0.5f};
if (startSlot >= 0)
{
const LevelSlot& s = lvl.slots[startSlot];
const Room& r = lib.rooms[size_t(s.roomIndex)];
int sx = 0, sy = 0;
if (NearestFloor(map, s.originX + r.w / 2, s.originY + r.h / 2, sx, sy))
lvl.squadStart = Vec2{float(sx) + 0.5f, float(sy) + 0.5f};
}
// Вошли и выйдем наверх в одном месте: лестница одна.
lvl.extractPos = lvl.squadStart;
std::vector<int> dist;
DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist);
// Спуск — самый дальний ДОСТИЖИМЫЙ тайл. Не «дальняя комната»: комната
// может оказаться отрезанной, а этот выбор недостижимым быть не может
// по построению.
int exitTile = -1;
std::vector<int> freeTiles; // достижимый пол: пригодится луту и доливу
freeTiles.reserve(1024);
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
if (dist[size_t(i)] < 0) continue;
++lvl.reachableTiles;
freeTiles.push_back(i);
if (exitTile < 0 || dist[size_t(i)] > dist[size_t(exitTile)]) exitTile = i;
}
lvl.exitPos = lvl.squadStart;
if (exitTile >= 0)
{
lvl.exitPos = Vec2{float(exitTile % MAP_W) + 0.5f, float(exitTile / MAP_W) + 0.5f};
lvl.exitDistance = dist[size_t(exitTile)];
}
// Занятые клетки: на спуск и друг на друга ничего не кладём.
std::vector<uint8_t> taken(size_t(MAP_W) * MAP_H, 0);
if (exitTile >= 0) taken[size_t(exitTile)] = 1;
// --- 7. население комнатных точек ----------------------------------------
// Обход слотов в фиксированном порядке: розыгрыш обязан зависеть от сида,
// а не от того, в каком порядке мы решили пройтись по карте.
float budget = floor.dangerBudget;
std::vector<Allowed> allowed;
for (int i = 0; i < LEVEL_SLOTS; ++i)
{
const LevelSlot& s = lvl.slots[i];
if (s.roomIndex < 0) continue;
const Room& r = lib.rooms[size_t(s.roomIndex)];
for (const SpawnPoint& p : r.spawns)
{
// Броски делаются ВСЕГДА и в том же порядке, даже когда точка не
// сработает: иначе полный список уводил бы последовательность вбок
// относительно неполного, и сид перестал бы значить одно и то же.
const float rollChance = rng.Float01();
const float rollPick = rng.Float01();
// Плотность этажа — это множитель к шансу точки. Шанс из комнаты
// остаётся тем, что задумал автор; глубина его только сгущает.
const float chance = std::min(1.0f, p.chance * floor.spawnChanceScale);
if (rollChance >= chance) continue;
CollectAllowed(p, catalog, depth, budget, allowed);
const EntityDesc* d = PickAllowed(allowed, rollPick);
if (!d) continue;
const int mx = s.originX + p.x;
const int my = s.originY + p.y;
if (!Tilemap::InBounds(mx, my) || map.IsWall(mx, my)) continue;
const size_t ti = size_t(my) * MAP_W + mx;
if (dist[ti] < 0) { ++lvl.spawnsUnreachable; continue; }
// Потолок — свойство симуляции (маска задетых целей в клинке,
// tuning.h). Молча срезать хвост нельзя: цифра уходит в отчёт.
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) { ++lvl.spawnsDropped; continue; }
budget -= d->danger;
lvl.dangerSpent += d->danger;
if (d->cls == EntityClass::MONSTER && d->danger > 0.0f) ++lvl.monstersPlaced;
taken[ti] = 1;
lvl.spawns.push_back(LevelSpawn{d->id, Vec2{float(mx) + 0.5f, float(my) + 0.5f}});
}
}
// --- 8. долив бюджета ----------------------------------------------------
// Без этого шага глубина упирается в число точек, нарисованных в комнатах:
// на двадцатом этаже все точки уже срабатывают и уже выдают самое дорогое,
// и дальше этаж не становится страшнее вообще. Долив превращает бюджет из
// потолка в норму, которую этаж обязан выбрать.
const EntityDesc* cheapest = CheapestMonster(catalog, depth);
for (int attempt = 0; attempt < FILL_ATTEMPTS && cheapest && budget >= cheapest->danger;
++attempt)
{
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) break;
if (freeTiles.empty()) break;
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
const float rollPick = rng.Float01();
if (taken[size_t(tile)]) continue;
const Vec2 pos{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f};
if (Distance(pos, lvl.squadStart) < FILL_MIN_DIST_FROM_START) continue;
const EntityDesc* d =
PickFillMonster(catalog, depth, budget, MAX_LEVEL_SPAWNS - int(lvl.spawns.size()), rollPick);
if (!d) break;
budget -= d->danger;
lvl.dangerSpent += d->danger;
++lvl.monstersPlaced;
++lvl.monstersFilled;
taken[size_t(tile)] = 1;
lvl.spawns.push_back(LevelSpawn{d->id, pos});
}
// --- 9. лут --------------------------------------------------------------
const int wantLoot = std::min(MAX_LEVEL_LOOT, std::max(0, floor.lootDrops));
for (int attempt = 0; attempt < FILL_ATTEMPTS && int(lvl.loot.size()) < wantLoot; ++attempt)
{
if (freeTiles.empty()) break;
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
const float rollPick = rng.Float01();
if (taken[size_t(tile)]) continue;
const EntityDesc* d = PickLoot(catalog, depth, floor.lootTier, rollPick);
if (!d) break;
taken[size_t(tile)] = 1;
lvl.loot.push_back(
LevelSpawn{d->id, Vec2{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f}});
}
out = std::move(lvl);
return true;
}

155
engine/level_gen.h Normal file
View File

@ -0,0 +1,155 @@
#pragma once
// -----------------------------------------------------------------------------
// Сборка ЭТАЖА из комнат. Детерминирована по сиду и глубине, и только по ним:
// один и тот же набор комнат плюс тот же сид и тот же этаж дают побайтово ту же
// карту и то же население.
//
// Где живёт случайность. В симуляции её нет и не будет (docs/02-architecture.md,
// «Детерминизм»): RNG крутится ЗДЕСЬ, один раз, при сборке этажа, и к моменту
// первого Game::Step всё уже разложено. Поэтому прогон остаётся воспроизводимым,
// а `--accept` вообще не проходит по этому коду — приёмка меряется на прежней
// захардкоженной планировке.
//
// Раскладка. Карта 48x48 делится на сетку LEVEL_GRID x LEVEL_GRID слотов по
// ROOM_SLOT тайлов. В каждый слот ставится комната (не больше ROOM_MAX_*), она
// центрируется, и вокруг неё остаётся камень. Слоты сшиваются коридорами: сперва
// остовное дерево — оно гарантирует, что дойти можно до каждой комнаты, — потом
// несколько лишних связей, чтобы этаж не был деревом-кишкой.
//
// Что делает глубина. Всё, кроме планировки:
// * из набора отбираются комнаты, разрешённые на этом этаже;
// * из таблиц спавна отбираются сущности, разрешённые на этом этаже;
// * население тратит БЮДЖЕТ ОПАСНОСТИ, а не просто выпадает по шансу;
// * остаток бюджета доливается тварями вне комнатных точек — иначе глубина
// упиралась бы в число точек, нарисованных в комнатах, и переставала расти;
// * лут раскладывается тиром этажа.
// Сама кривая — в engine/progression.h, здесь только её применение.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
#include "engine/math.h"
#include "engine/catalog.h"
#include "engine/progression.h"
#include "engine/room.h"
#include "engine/tilemap.h"
constexpr int LEVEL_GRID = MAP_W / ROOM_SLOT;
constexpr int LEVEL_SLOTS = LEVEL_GRID * LEVEL_GRID;
// Потолок на лежащие предметы. Не «на всякий случай»: каждый предмет —
// сущность ECS и иконка в списке отрисовки, а этаж, засыпанный лутом,
// перестаёт быть находкой.
constexpr int MAX_LEVEL_LOOT = 24;
// Потолок на населённость этажа. Раньше здесь стояла игровая константа
// MAX_TARGETS из src/tuning.h — то есть генератор уровней знал, сколько битов
// в маске удара клинком. Теперь у движка своё число, а игра проверяет, что оно
// влезает в её пределы (static_assert в src/tuning.h).
//
// Это не формальность: бюджет опасности на глубине неизбежно перерастает любое
// такое число, и лишнее не теряется молча — оно уходит в GeneratedLevel::
// spawnsDropped и в отчёт --check.
constexpr int MAX_LEVEL_SPAWNS = 64;
static_assert(MAP_W == MAP_H, "генератор рассчитан на квадратную карту");
static_assert(MAP_W % ROOM_SLOT == 0, "карта обязана делиться на слоты нацело");
// -----------------------------------------------------------------------------
// Xorshift32. Свой, а не <random>: стандартные распределения не обязаны давать
// одинаковую последовательность на разных реализациях библиотеки, а сид этажа
// обязан значить одно и то же везде.
// -----------------------------------------------------------------------------
struct LevelRng
{
uint32_t state;
explicit LevelRng(uint32_t seed) : state(seed ? seed : 0x9E3779B9u) {}
uint32_t Next()
{
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return state;
}
// [0, 1). Двадцать четыре бита: больше во float всё равно не влезает.
float Float01() { return float(Next() >> 8) * (1.0f / 16777216.0f); }
// [0, n). n <= 0 — ноль: вызывающему не приходится проверять пустой список.
int Below(int n) { return (n > 0) ? int(Next() % uint32_t(n)) : 0; }
};
// Одна сущность, которую генератор выкатил в мир. id — строка каталога:
// движок не знает, во что она превратится в игре.
struct LevelSpawn
{
std::string id;
Vec2 pos{};
};
struct LevelSlot
{
int roomIndex = -1; // индекс в RoomLibrary; -1 — слот пуст
int originX = 0; // угол комнаты в тайлах карты
int originY = 0;
};
struct GeneratedLevel
{
uint32_t seed = 0;
FloorSpec floor;
Vec2 squadStart{};
// Спуск на следующий этаж. Самая дальняя от старта ДОСТИЖИМАЯ точка: этаж
// обязан заставить пройти себя, а не открываться в двух шагах от входа.
Vec2 exitPos{};
// Эвакуация: та же точка, откуда отряд вошёл на этаж, — лестница наверх.
// Отдельным полем, а не «это же squadStart», потому что смысл у них разный
// и разойтись они могут: точку входа однажды захочется двигать, а выход
// наверх — нет.
//
// Совпадение входа и выхода — не экономия, а суть напряжения: спуск лежит
// на дальнем конце этажа, и решив уйти, отряд идёт обратно через всё, что
// успел растревожить, — раненый и с полными руками.
Vec2 extractPos{};
std::vector<LevelSpawn> spawns; // твари и обстановка
std::vector<LevelSpawn> loot; // предметы на полу
LevelSlot slots[LEVEL_SLOTS];
int roomsPlaced = 0;
int corridors = 0;
// Сколько опасности реально выпущено на этаж. Не равно бюджету: точек может
// не хватить, и это нормально. Сильно меньше бюджета на глубине — признак,
// что набор комнат перестал успевать за кривой.
float dangerSpent = 0.0f;
int monstersPlaced = 0;
// Сколько тварей долито вне комнатных точек, чтобы выбрать бюджет.
int monstersFilled = 0;
// Сколько точек спавна выпало из-за потолка MAX_LEVEL_SPAWNS. Не ноль — этаж
// населён не так, как задумано, и это обязано быть видно, а не молча съедено.
int spawnsDropped = 0;
// Сколько выпало как недостижимые от точки старта. Не ноль — где-то в наборе
// комнат есть карман, отрезанный от собственной двери: тварь в нём не сможет
// ни дойти до отряда, ни быть убитой, и этаж тихо станет легче.
int spawnsUnreachable = 0;
// Сколько тайлов пола достижимо от старта, и как далеко до спуска (в шагах
// по сетке). Обе цифры — про то, «есть ли на этаже что проходить».
int reachableTiles = 0;
int exitDistance = 0;
const LevelSlot& Slot(int gx, int gy) const { return slots[gy * LEVEL_GRID + gx]; }
};
// Собирает планировку прямо в map (включая слой тем) и население в out.
// false — из набора нельзя собрать этаж; map при этом не трогается.
//
// catalog нужен, потому что глубина и цена сущности живут в нём: без каталога
// генератор не может ни отобрать тварей по этажу, ни посчитать бюджет.
bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor,
uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err = nullptr);

112
engine/microfont.cpp Normal file
View File

@ -0,0 +1,112 @@
#include "engine/microfont.h"
#include <cstring>
namespace
{
// Знак — пять строк по три бита, старший бит слева. Читается как картинка,
// если смотреть на единицы: именно поэтому таблица записана двоичными
// литералами, а не шестнадцатеричными.
struct Glyph
{
char c;
uint8_t rows[MICRO_H];
};
const Glyph GLYPHS[] = {
{'0', {0b111, 0b101, 0b101, 0b101, 0b111}},
{'1', {0b010, 0b110, 0b010, 0b010, 0b111}},
{'2', {0b111, 0b001, 0b111, 0b100, 0b111}},
{'3', {0b111, 0b001, 0b011, 0b001, 0b111}},
{'4', {0b101, 0b101, 0b111, 0b001, 0b001}},
{'5', {0b111, 0b100, 0b111, 0b001, 0b111}},
{'6', {0b111, 0b100, 0b111, 0b101, 0b111}},
{'7', {0b111, 0b001, 0b010, 0b010, 0b010}},
{'8', {0b111, 0b101, 0b111, 0b101, 0b111}},
{'9', {0b111, 0b101, 0b111, 0b001, 0b111}},
{'A', {0b111, 0b101, 0b111, 0b101, 0b101}},
{'B', {0b110, 0b101, 0b110, 0b101, 0b110}},
{'C', {0b111, 0b100, 0b100, 0b100, 0b111}},
{'D', {0b110, 0b101, 0b101, 0b101, 0b110}},
{'E', {0b111, 0b100, 0b110, 0b100, 0b111}},
{'F', {0b111, 0b100, 0b110, 0b100, 0b100}},
{'G', {0b111, 0b100, 0b101, 0b101, 0b111}},
{'H', {0b101, 0b101, 0b111, 0b101, 0b101}},
{'I', {0b111, 0b010, 0b010, 0b010, 0b111}},
{'J', {0b001, 0b001, 0b001, 0b101, 0b111}},
{'K', {0b101, 0b101, 0b110, 0b101, 0b101}},
{'L', {0b100, 0b100, 0b100, 0b100, 0b111}},
{'M', {0b101, 0b111, 0b111, 0b101, 0b101}},
{'N', {0b110, 0b101, 0b101, 0b101, 0b101}},
{'O', {0b111, 0b101, 0b101, 0b101, 0b111}},
{'P', {0b111, 0b101, 0b111, 0b100, 0b100}},
{'Q', {0b111, 0b101, 0b101, 0b111, 0b011}},
{'R', {0b111, 0b101, 0b110, 0b101, 0b101}},
{'S', {0b111, 0b100, 0b111, 0b001, 0b111}},
{'T', {0b111, 0b010, 0b010, 0b010, 0b010}},
{'U', {0b101, 0b101, 0b101, 0b101, 0b111}},
{'V', {0b101, 0b101, 0b101, 0b101, 0b010}},
{'W', {0b101, 0b101, 0b111, 0b111, 0b101}},
{'X', {0b101, 0b101, 0b010, 0b101, 0b101}},
{'Y', {0b101, 0b101, 0b010, 0b010, 0b010}},
{'Z', {0b111, 0b001, 0b010, 0b100, 0b111}},
{'-', {0b000, 0b000, 0b111, 0b000, 0b000}},
{'+', {0b000, 0b010, 0b111, 0b010, 0b000}},
{':', {0b000, 0b010, 0b000, 0b010, 0b000}},
{'.', {0b000, 0b000, 0b000, 0b000, 0b010}},
{'/', {0b001, 0b001, 0b010, 0b100, 0b100}},
{'%', {0b101, 0b001, 0b010, 0b100, 0b101}},
{'!', {0b010, 0b010, 0b010, 0b000, 0b010}},
};
const Glyph* Find(char c)
{
// Строчные приводятся к заглавным: подпись не должна пропадать из-за того,
// что кто-то написал её в другом регистре.
if (c >= 'a' && c <= 'z') c = char(c - 'a' + 'A');
for (const Glyph& g : GLYPHS)
if (g.c == c) return &g;
return nullptr;
}
} // namespace
int MicroTextWidth(const char* s)
{
if (!s || !*s) return 0;
const int n = int(std::strlen(s));
return n * MICRO_ADVANCE - 1;
}
void MicroText(Framebuffer& fb, int x, int y, const char* s, uint32_t color)
{
if (!s) return;
for (int i = 0; s[i]; ++i)
{
const Glyph* g = Find(s[i]);
const int gx = x + i * MICRO_ADVANCE;
if (!g) continue; // пробел и всё незнакомое — просто пропуск
for (int row = 0; row < MICRO_H; ++row)
{
const uint8_t bits = g->rows[row];
for (int col = 0; col < MICRO_W; ++col)
if (bits & (1u << (MICRO_W - 1 - col))) fb.Put(gx + col, y + row, color);
}
}
}
void MicroTextShadow(Framebuffer& fb, int x, int y, const char* s, uint32_t color, uint32_t shadow)
{
// Подложка — четыре сдвига по осям. Диагонали не нужны: на 3x5 они
// съедают просвет внутри знака, и цифра превращается в кляксу.
MicroText(fb, x - 1, y, s, shadow);
MicroText(fb, x + 1, y, s, shadow);
MicroText(fb, x, y - 1, s, shadow);
MicroText(fb, x, y + 1, s, shadow);
MicroText(fb, x, y, s, color);
}

39
engine/microfont.h Normal file
View File

@ -0,0 +1,39 @@
#pragma once
// -----------------------------------------------------------------------------
// Растровый шрифт 3x5 для софтверного фреймбуфера.
//
// Зачем он появился. HUD прототипа был принципиально бессловесным: на 480x270
// шрифт raylib либо нечитаем, либо занимает четверть экрана, и всё нужное
// показывалось формой и цветом (render/hud.h). Для игры про бесконечный спуск
// этого перестало хватать ровно на одном: НОМЕР ЭТАЖА — это число, и показать
// его формой нельзя. Двадцать семь зарубок на краю экрана никто не считает.
//
// Поэтому здесь минимальный шрифт, а не raylib-текст: правило «ни одного
// примитива raylib в игровой сцене» остаётся в силе, потому что этот шрифт
// пишет в тот же самый фреймбуфер, попадает под ту же сетку пикселей и под
// ту же световую рампу, если понадобится.
//
// 3x5 — предел читаемости на этом разрешении. Знаков ровно столько, сколько
// нужно: цифры, заглавная латиница и несколько разделителей. Строчных нет
// намеренно: на пяти пикселях высоты они неотличимы от заглавных.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "engine/framebuffer.h"
constexpr int MICRO_W = 3; // ширина знака
constexpr int MICRO_H = 5;
constexpr int MICRO_ADVANCE = MICRO_W + 1; // шаг между знаками
// Ширина строки в пикселях (без хвостового пробела).
int MicroTextWidth(const char* s);
// Написать строку. (x, y) — левый верхний угол первого знака.
// Неизвестный символ рисуется как пробел: подпись обязана уцелеть целиком,
// даже если в неё попало что-то неожиданное.
void MicroText(Framebuffer& fb, int x, int y, const char* s, uint32_t color);
// То же, но с тёмной подложкой в один пиксель вокруг: HUD ложится поверх
// произвольного пола, и без подложки светлая надпись пропадает на светлом.
void MicroTextShadow(Framebuffer& fb, int x, int y, const char* s, uint32_t color,
uint32_t shadow);

39
engine/pixel.cpp Normal file
View File

@ -0,0 +1,39 @@
#include "engine/pixel.h"
#include <algorithm>
#include "engine/math.h"
uint32_t ShadeColor(uint32_t c, float k)
{
const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f);
const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f);
const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f);
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
}
void DiamondRowN(int y, int dw, int dh, int& x0, int& x1)
{
const int half = std::max(1, dh / 2);
const int k = (y < half) ? y : (dh - 1 - y);
const int width = std::max(1, (dw / half) * (k + 1));
x0 = (dw - width) / 2;
x1 = x0 + width;
}
void OutlineSilhouette(Sprite& s, uint32_t outline)
{
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 border =
x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 ||
AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 ||
AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0;
if (border) s.Set(x, y, outline);
}
}
}

29
engine/pixel.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
// -----------------------------------------------------------------------------
// Мелочь, общая для всякого, кто рисует спрайты руками: движок и игра.
//
// Вынесено из render/sprites.cpp, потому что тайлы пола и стен переехали в
// движок (engine/tileset.h), а рисуются они тем же кодом, что и всё остальное.
// Держать две копии наклона ромба — верный способ получить пол и обстановку
// в разных изометриях.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "engine/framebuffer.h"
// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется).
uint32_t ShadeColor(uint32_t c, float k);
// Занятая часть строки y для ромба dw x dh. Шаг ступеньки — dw/(dh/2), то есть
// ровно наклон 2:1: всё, что стоит на полу, обязано быть в одной изометрии
// с полом, иначе объект выглядит приклеенным к экрану.
void DiamondRowN(int y, int dw, int dh, int& x0, int& x1);
// Ромба размером с плиту пола здесь БОЛЬШЕ НЕТ: форму плиты знает проекция
// (g_proj.TileRow), и при виде сверху плита не ромб, а квадрат. Помощник,
// рисующий ромб мимо проекции, — это ровно тот способ снова получить пол и
// обстановку в разных видах.
// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный
// 4-сосед (или край спрайта), перекрашивается в outline.
void OutlineSilhouette(Sprite& s, uint32_t outline);

69
engine/progression.cpp Normal file
View File

@ -0,0 +1,69 @@
#include "engine/progression.h"
#include <algorithm>
#include <cstdio>
namespace
{
// --- отделка -----------------------------------------------------------------
// Полосы этажей по темам. Порядок — это порядок погружения: обжитая сталь,
// заброшенная ржавчина, заражённая плоть, мёртвая наледь. Границы полос —
// данные проекта (FloorCurve::themeFrom), сам порядок — нет: тема с номером
// больше обязана быть «глубже», иначе спуск станет чересполосицей.
uint8_t ThemeAtDepth(int depth)
{
uint8_t theme = 0;
for (int i = 0; i < THEME_COUNT; ++i)
if (depth >= g_curve.themeFrom[i]) theme = uint8_t(i);
return theme;
}
} // namespace
FloorSpec DescribeFloor(int depth)
{
const FloorCurve& c = g_curve;
FloorSpec s;
s.depth = std::max(1, depth);
const float d = float(s.depth);
const float over = d - 1.0f; // насколько глубже первого
s.theme = ThemeAtDepth(s.depth);
// Опасность: линейный рост плюс небольшой квадратичный хвост. Чистая
// линейка на тридцатом этаже читается как «то же самое, только больше»;
// чистая экспонента убивает забег ровно там, где игрок начал понимать
// правила. Хвост даёт ускорение, которое ЗАМЕТНО, но не обрывает спуск.
s.dangerBudget = c.dangerBase + c.dangerLinear * over + c.dangerQuad * over * over;
// Плотность растёт до упора и там встаёт. Дальше расти нельзя: точки
// спавна в комнатах конечны, и множитель выше упора просто означал бы
// «все точки сработали всегда», то есть одинаковые этажи.
s.spawnChanceScale = std::min(c.densityCap, 1.0f + c.densityPerFloor * over);
// Лут. Тир поднимается медленнее опасности, поэтому спуск всегда чуть
// опережает снаряжение — иначе отряд к десятому этажу одет так, что
// дальше можно не смотреть.
s.lootTier = (c.floorsPerTier > 0) ? (s.depth - 1) / c.floorsPerTier : 0;
s.lootDrops = c.lootBase + ((c.floorsPerDrop > 0) ? s.depth / c.floorsPerDrop : 0);
// Обзор сжимается до упора и там встаёт. Ниже — это уже не «страшно», а
// «не видно, куда идти»: игрок перестаёт различать своих, и хоррор
// превращается в неудобство.
s.sightScale = std::max(c.sightFloorAt, 1.0f - c.sightPerFloor * over);
// Опыт растёт медленнее опасности намеренно: спускаться должно быть
// выгодно, но не настолько, чтобы прыгать через этажи было единственной
// разумной игрой.
s.xpScale = 1.0f + c.xpPerFloor * over;
return s;
}
void FloorLabel(const FloorSpec& spec, char* buf, int cap)
{
if (!buf || cap <= 0) return;
std::snprintf(buf, size_t(cap), "D%d %s", spec.depth, ThemeName(spec.theme));
}

106
engine/progression.h Normal file
View File

@ -0,0 +1,106 @@
#pragma once
// -----------------------------------------------------------------------------
// Что означает глубина N.
//
// Игра — это бесконечный спуск: чем глубже, тем страшнее окружение, злее твари,
// богаче находки. Весь этот наклон живёт ЗДЕСЬ, в одной функции, и больше
// нигде. Причина простая: кривая сложности — это то, что правят чаще всего и
// правят на ощупь. Размазанная по генератору, каталогу и рендеру, она через
// месяц перестаёт поддаваться правке вообще, потому что «сделать десятый этаж
// чуть злее» превращается в раскопки по трём файлам.
//
// Чего здесь НЕТ намеренно:
// * множителей урона и здоровья тварей. «Тот же бредущий, но со втрое
// большим HP» — это не страшнее, это дольше. Глубина меняет, КТО выходит
// и сколько их, а не сколько раз надо выстрелить в одного и того же;
// * потолка. Функция определена для любого N: спуск бесконечен, и этаж 200
// обязан собраться, даже если каталог кончился на 12-м.
//
// Всё, что отсюда выходит, — данные. Ни рендер, ни симуляция про «глубину» не
// знают, они читают готовые числа.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "engine/theme.h"
struct FloorSpec
{
int depth = 1;
// Отделка этажа. Комнаты со своей темой её сохраняют — тема этажа
// достаётся коридорам и тем комнатам, которые от неё не отказались.
uint8_t theme = 0;
// Сколько «опасности» разрешено выпустить на этаж (цены — в каталоге).
// Главный рычаг сложности: он растёт всегда, а состав того, на что его
// тратят, меняется вместе с ним.
float dangerBudget = 4.0f;
// Множитель шанса срабатывания точек спавна. Бюджет отвечает за то,
// НАСКОЛЬКО злой этаж, этот множитель — за то, насколько он плотный.
float spawnChanceScale = 1.0f;
// Лут: сколько предметов лежит на этаже и какого качества.
int lootDrops = 2;
int lootTier = 0;
// Насколько сжат обзор. Render-only: свет и туман войны на симуляцию не
// влияют (docs/02-architecture.md), поэтому темнеть с глубиной безопасно.
// Именно это и делает окружение страшнее — не новые спрайты, а то, что
// видно всё меньше.
float sightScale = 1.0f;
// Опыт за этаж — множитель к цене убитого. Растёт медленнее опасности:
// спускаться должно быть выгодно, но не настолько, чтобы пропуск этажей
// стал единственной разумной игрой.
float xpScale = 1.0f;
};
// -----------------------------------------------------------------------------
// Коэффициенты кривой. Раньше они стояли числами прямо в DescribeFloor, то есть
// «чуть злее с десятого этажа» требовало пересборки движка. Теперь это данные
// проекта (project.txt), и ФОРМА кривой при этом остаётся в коде: проект гнёт
// её, но не может подменить, скажем, линейный рост экспонентой.
//
// Так и задумано. Проект, которому нужна другая форма, — это повод завести ещё
// одно поле здесь, а не язык выражений в текстовом файле: кривую правят на
// ощупь и часто, и она обязана оставаться читаемой целиком за один взгляд.
// -----------------------------------------------------------------------------
struct FloorCurve
{
// Опасность: base + linear*(d-1) + quad*(d-1)^2.
float dangerBase = 3.0f;
float dangerLinear = 2.2f;
float dangerQuad = 0.10f;
// Плотность точек: 1 + perFloor*(d-1), но не выше cap.
float densityPerFloor = 0.05f;
float densityCap = 1.5f;
// Лут: base + d/floorsPerDrop предметов, тир — ступень на floorsPerTier.
int lootBase = 2;
int floorsPerDrop = 4;
int floorsPerTier = 3;
// Обзор: 1 - perFloor*(d-1), но не ниже floorAt.
float sightPerFloor = 0.035f;
float sightFloorAt = 0.60f;
// Опыт: 1 + perFloor*(d-1).
float xpPerFloor = 0.15f;
// С какого этажа начинается каждая тема отделки. Последняя полоса открытая:
// дальше тема не меняется, потому что менять её больше не на что.
int themeFrom[THEME_COUNT] = {1, 4, 8, 13};
};
// Активная кривая. Ставится один раз при загрузке проекта, как и g_proj.
inline FloorCurve g_curve;
// Кривая. Определена для любого depth >= 1.
FloorSpec DescribeFloor(int depth);
// Подпись этажа для HUD: "D3 RUST". Здесь, а не в HUD, потому что имя темы
// этажа — часть кривой, и знать, какая тема на каком этаже, HUD не обязан.
// Пишет в buf не больше cap символов, включая ноль.
void FloorLabel(const FloorSpec& spec, char* buf, int cap);

225
engine/project.cpp Normal file
View File

@ -0,0 +1,225 @@
#include "engine/project.h"
#include <algorithm>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include "engine/text_parse.h"
namespace fs = std::filesystem;
namespace
{
constexpr const char* MANIFEST = "project.txt";
constexpr const char* PROJECTS_DIR = "projects";
// Откуда смотрим. Игра запускается и из корня репозитория, и из build/bin —
// оба способа рабочие, и требовать за это ключ было бы налогом на каждый запуск.
const char* const BASES[] = {".", "..", "../..", "../../.."};
bool HasManifest(const fs::path& dir)
{
std::error_code ec;
return fs::exists(dir / MANIFEST, ec);
}
// Число с плавающей точкой из лексемы по индексу; нет лексемы — прежнее значение
// остаётся. Так частично заполненная строка не обнуляет незаданные поля.
void Take(const std::vector<std::string>& parts, size_t i, float& out)
{
if (i < parts.size()) out = text::ToFloat(parts[i], out);
}
void Take(const std::vector<std::string>& parts, size_t i, int& out)
{
if (i < parts.size()) out = text::ToInt(parts[i], out);
}
} // namespace
bool Project::Load(const std::string& projectDir, std::string* err)
{
auto fail = [&](const std::string& why) {
if (err) *err = why;
return false;
};
const fs::path root(projectDir);
const fs::path manifest = root / MANIFEST;
std::ifstream in(manifest);
if (!in) return fail("нет " + manifest.string());
*this = Project{};
dir = projectDir;
std::string line;
int lineNo = 0;
std::string rooms = "rooms";
std::string catalog = "catalog/entities.txt";
while (std::getline(in, line))
{
++lineNo;
const std::vector<std::string> parts = text::Split(text::Trim(text::StripComment(line)));
if (parts.empty()) continue;
const std::string& key = parts[0];
if (key == "name") name = text::JoinFrom(parts, 1);
else if (key == "rooms") rooms = text::JoinFrom(parts, 1);
else if (key == "catalog") catalog = text::JoinFrom(parts, 1);
else if (key == "startdepth") Take(parts, 1, startDepth);
else if (key == "view")
{
// Неизвестный вид — это ошибка, а не повод молча остаться в
// изометрии: проект, написанный под вид сверху, в изометрии
// выглядит не «чуть иначе», а сломанным.
if (parts.size() < 2 || !ParseViewMode(parts[1].c_str(), view))
return fail(manifest.string() + ": строка " + std::to_string(lineNo) +
": неизвестный вид отображения");
}
else if (key == "danger")
{
Take(parts, 1, curve.dangerBase);
Take(parts, 2, curve.dangerLinear);
Take(parts, 3, curve.dangerQuad);
}
else if (key == "density")
{
Take(parts, 1, curve.densityPerFloor);
Take(parts, 2, curve.densityCap);
}
else if (key == "loot")
{
Take(parts, 1, curve.lootBase);
Take(parts, 2, curve.floorsPerDrop);
Take(parts, 3, curve.floorsPerTier);
}
else if (key == "sight")
{
Take(parts, 1, curve.sightPerFloor);
Take(parts, 2, curve.sightFloorAt);
}
else if (key == "xp") Take(parts, 1, curve.xpPerFloor);
else if (key == "themes")
{
for (int i = 0; i < THEME_COUNT; ++i) Take(parts, size_t(i) + 1, curve.themeFrom[i]);
}
else
{
// Опечатка в ключе — самая дорогая ошибка в текстовом формате:
// строка просто не действует, и это выглядит как «движок не
// слушается». Поэтому она валит загрузку, а не пропускается.
return fail(manifest.string() + ": строка " + std::to_string(lineNo) +
": неизвестный ключ " + key);
}
}
roomsDir = (root / rooms).string();
catalogPath = (root / catalog).string();
std::error_code ec;
if (!fs::is_directory(fs::path(roomsDir), ec))
return fail("нет каталога комнат " + roomsDir);
if (!fs::exists(fs::path(catalogPath), ec))
return fail("нет каталога сущностей " + catalogPath);
// Полосы отделки обязаны идти по возрастанию: тема с большим номером — это
// «глубже». Перепутанные границы дали бы этаж, у которого тема зависит от
// порядка сравнений, а не от глубины.
for (int i = 1; i < THEME_COUNT; ++i)
if (curve.themeFrom[i] < curve.themeFrom[i - 1])
return fail(manifest.string() + ": полосы отделки идут не по возрастанию");
if (startDepth < 1) startDepth = 1;
return true;
}
void Project::Apply() const
{
g_proj = Projection::For(view);
g_curve = curve;
}
std::string FindProjectDir(const std::string& wantedArg)
{
std::error_code ec;
// Ключ важнее окружения, окружение важнее угадывания. Переменная нужна
// терминалу редактора и запущенным из него агентам: инструмент, вызванный
// изнутри открытого проекта, обязан говорить про ЭТОТ проект, а дописывать
// --project в чужие команды строковой подстановкой — способ однажды
// подставить его не туда.
std::string wanted = wantedArg;
if (wanted.empty())
{
#ifdef _MSC_VER
// getenv_s, а не getenv: под /W4 второй — предупреждение, а собираемся
// мы без предупреждений, и глушить их ради одной строки нельзя.
char buf[512] = {};
size_t len = 0;
if (getenv_s(&len, buf, sizeof(buf), "TILE2D_PROJECT") == 0 && len > 1) wanted = buf;
#else
const char* env = std::getenv("TILE2D_PROJECT");
if (env && *env) wanted = env;
#endif
}
// 1. Явный путь — как дали, так и берём.
if (!wanted.empty())
{
if (HasManifest(fs::path(wanted))) return wanted;
// 2. Или это имя проекта: <база>/projects/<имя>.
for (const char* b : BASES)
{
const fs::path p = fs::path(b) / PROJECTS_DIR / wanted;
if (HasManifest(p)) return p.string();
}
return {};
}
// 3. Без имени: проект под ногами, потом единственный в projects/.
for (const char* b : BASES)
{
if (HasManifest(fs::path(b))) return fs::path(b).string();
const fs::path pdir = fs::path(b) / PROJECTS_DIR;
if (!fs::is_directory(pdir, ec)) continue;
std::vector<std::string> found;
for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec))
if (e.is_directory(ec) && HasManifest(e.path())) found.push_back(e.path().string());
// Несколько проектов и ни одного названного — выбирать за игрока
// нельзя: молча взятый «первый попавшийся» читается как «движок открыл
// не то». Пусть скажут, какой.
if (found.size() == 1) return found[0];
if (found.size() > 1) return {};
}
return {};
}
std::vector<std::string> AvailableProjects()
{
std::error_code ec;
std::vector<std::string> names;
for (const char* b : BASES)
{
const fs::path pdir = fs::path(b) / PROJECTS_DIR;
if (!fs::is_directory(pdir, ec)) continue;
for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec))
if (e.is_directory(ec) && HasManifest(e.path()))
names.push_back(e.path().filename().string());
if (!names.empty()) break; // ближайший каталог projects/ и есть нужный
}
std::sort(names.begin(), names.end());
return names;
}

68
engine/project.h Normal file
View File

@ -0,0 +1,68 @@
#pragma once
// -----------------------------------------------------------------------------
// Проект: всё, что отличает одну игру на Tile2D от другой.
//
// Движок не знает ни одного имени содержимого. Он знает, что у проекта есть
// каталог сущностей, набор комнат, вид отображения и кривая глубины, — а какие
// именно твари, комнаты и числа за этим стоят, читается из project.txt.
//
// Отсюда правило, которое легко нарушить и трудно заметить: НИ ОДИН путь к
// содержимому не может быть зашит в код движка. Раньше движок сам искал
// assets/rooms и assets/catalog, то есть в нём было зашито имя ровно одной
// игры; такой «движок» работает с одним проектом и притворяется общим.
//
// Формат — построчный `ключ значение`, как у комнат и каталога. Причина та же:
// файл лежит в git, правится руками и обязан читаться в diff.
//
// name DESCENT
// view iso # iso | topdown
// rooms rooms # каталог с *.room, относительно проекта
// catalog catalog/entities.txt
// startdepth 1
// danger 3.0 2.2 0.10 # база, линейный рост, квадратичный хвост
// density 0.05 1.5 # прирост за этаж, упор
// loot 2 4 3 # база, этажей на +1 предмет, этажей на тир
// sight 0.035 0.60 # сжатие за этаж, нижний упор
// xp 0.15 # прирост множителя опыта за этаж
// themes 1 4 8 13 # с какого этажа начинается каждая тема
// -----------------------------------------------------------------------------
#include <string>
#include <vector>
#include "engine/progression.h"
#include "engine/projection.h"
struct Project
{
std::string dir; // корень проекта
std::string name = "UNTITLED";
ViewMode view = ViewMode::ISO_2_1;
// Пути уже склеены с корнем проекта: вызывающему не приходится знать,
// относительно чего они были записаны.
std::string roomsDir;
std::string catalogPath;
int startDepth = 1;
FloorCurve curve;
// false — проект не читается; err объясняет, чего именно не хватает.
bool Load(const std::string& projectDir, std::string* err = nullptr);
// Поставить проект на движок: вид отображения и кривую глубины. Отдельно от
// Load намеренно — прочитать манифест, чтобы показать его в списке, и
// ПЕРЕКЛЮЧИТЬ на него движок это разные действия.
void Apply() const;
};
// Где искать проект. wanted — либо путь к каталогу проекта, либо просто имя
// (тогда ищется projects/<имя>). Пусто — берётся переменная окружения
// TILE2D_PROJECT, а если и её нет — единственный найденный проект.
//
// Поиск идёт от текущего каталога вверх: запуск из корня репозитория и запуск
// из build/bin одинаково рабочие, и это не должно требовать ключей.
std::string FindProjectDir(const std::string& wanted = {});
// Имена проектов, которые видны отсюда, — для сообщения об ошибке и для списка
// в редакторе. «Проект не найден» без ответа на «а какие есть» бесполезно.
std::vector<std::string> AvailableProjects();

100
engine/projection.cpp Normal file
View File

@ -0,0 +1,100 @@
#include "engine/projection.h"
#include <algorithm>
#include <cstring>
namespace
{
struct ModeInfo
{
const char* name;
int tileW, tileH, wallH;
};
// Размеры подобраны так, чтобы плита занимала на экране сопоставимую площадь:
// иначе смена вида меняла бы ещё и масштаб, и сравнить два вида было бы нельзя.
const ModeInfo MODES[VIEW_MODE_COUNT] = {
{"iso", TILE_W, TILE_H, WALL_H},
{"topdown", TILE_MAX_W, TILE_MAX_H, TILE_MAX_H},
};
} // namespace
const char* ViewModeName(ViewMode m)
{
const int i = int(m);
return (i >= 0 && i < VIEW_MODE_COUNT) ? MODES[i].name : "?";
}
bool ParseViewMode(const char* name, ViewMode& out)
{
if (!name) return false;
for (int i = 0; i < VIEW_MODE_COUNT; ++i)
if (std::strcmp(name, MODES[i].name) == 0)
{
out = ViewMode(i);
return true;
}
return false;
}
Projection Projection::For(ViewMode m)
{
const ModeInfo& info = MODES[int(m) < VIEW_MODE_COUNT ? int(m) : 0];
Projection p;
p.mode = m;
p.tileW = info.tileW;
p.tileH = info.tileH;
p.wallH = info.wallH;
return p;
}
Vec2 Projection::WorldToScreen(Vec2 w) const
{
if (mode == ViewMode::TOP_DOWN) return {w.x * float(tileW), w.y * float(tileH)};
// sx = (wx - wy) * tileW/2
// sy = (wx + wy) * tileH/2
return {(w.x - w.y) * float(tileW) * 0.5f, (w.x + w.y) * float(tileH) * 0.5f};
}
Vec2 Projection::ScreenToWorld(Vec2 s) const
{
if (mode == ViewMode::TOP_DOWN) return {s.x / float(tileW), s.y / float(tileH)};
const float a = s.x / float(tileW); // = (wx - wy) / 2
const float b = s.y / float(tileH); // = (wx + wy) / 2
return {b + a, b - a};
}
float Projection::Depth(Vec2 w) const
{
// В изометрии «дальше от камеры» — это меньшая сумма координат; при виде
// сверху глубины как таковой нет, и порядок задаёт только строка экрана.
return (mode == ViewMode::TOP_DOWN) ? w.y : (w.x + w.y);
}
void Projection::TileRow(int y, int& x0, int& x1) const
{
if (mode == ViewMode::TOP_DOWN)
{
x0 = 0;
x1 = tileW;
return;
}
// Ступенька ромба — tileW/(tileH/2), то есть ровно наклон 2:1.
const int half = std::max(1, tileH / 2);
const int k = (y < half) ? y : (tileH - 1 - y);
const int width = std::max(1, (tileW / half) * (k + 1));
x0 = (tileW - width) / 2;
x1 = x0 + width;
}
int Projection::WallAnchorY() const
{
// Изометрия: низ блока садится на ромб пола, поэтому крыша поднята над
// центром плиты на высоту тайла. Вид сверху: подниматься некуда.
return (mode == ViewMode::TOP_DOWN) ? tileH / 2 : tileH / 2 + tileH;
}

75
engine/projection.h Normal file
View File

@ -0,0 +1,75 @@
#pragma once
// -----------------------------------------------------------------------------
// Проекция: как мир кладётся на экран.
//
// Это ЕДИНСТВЕННОЕ место, где движок знает про изометрию. Мир везде декартов
// (тайл = 1.0 юнита), симуляция, ИИ и коллизии об этом файле не знают вообще —
// правило «src/ai/ не знает про изометрию» этим и держится.
//
// Проекция задаёт четыре вещи, и больше ничего:
// * куда мировая точка попадает на экран и обратно;
// * чем сортируется список отрисовки (кто «ближе к камере»);
// * какой формы плита пола — ромб или квадрат;
// * насколько крыша стены поднята над своей плитой.
//
// Всё остальное — спрайты, свет, кровь, эффекты — считается ЧЕРЕЗ эти четыре
// ответа и потому переживает смену вида. Абстракция с одной реализацией была бы
// самообманом, поэтому режима сразу два: изометрия 2:1 и вид сверху.
//
// Вид выбирается ОДИН раз при старте (проектом или ключом --view) и дальше не
// меняется: тайлсет рисуется под форму плиты, и смена вида на ходу означала бы
// перерисовку всех спрайтов посреди кадра.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "engine/config.h"
#include "engine/math.h"
enum class ViewMode : uint8_t
{
ISO_2_1 = 0, // ромб 32x16, наклон 2:1 — классическая изометрия
TOP_DOWN, // квадрат 32x32, вид строго сверху
};
constexpr int VIEW_MODE_COUNT = 2;
const char* ViewModeName(ViewMode m);
// Разбор имени из project.txt или из ключа --view. false — имя не опознано,
// out не тронут (звать с уже осмысленным значением по умолчанию).
bool ParseViewMode(const char* name, ViewMode& out);
struct Projection
{
ViewMode mode = ViewMode::ISO_2_1;
int tileW = TILE_W; // размер плиты пола на экране
int tileH = TILE_H;
int wallH = WALL_H; // высота спрайта стены целиком
static Projection For(ViewMode m);
// Мир -> экран и обратно. Обратная строго обратна прямой: по ней считается
// направление WASD, и рассогласование увело бы отряд не туда, куда жмут.
Vec2 WorldToScreen(Vec2 w) const;
Vec2 ScreenToWorld(Vec2 s) const;
// Ключ сортировки: чем больше, тем ближе к камере.
float Depth(Vec2 w) const;
// Занятая часть строки y у плиты пола: ромб или вся строка.
void TileRow(int y, int& x0, int& x1) const;
// Куда внутри спрайта стены попадает центр её плиты. В изометрии крыша
// поднята над полом на высоту тайла, при виде сверху подниматься некуда.
int WallAnchorY() const;
// Есть ли у стены видимые вертикальные грани. При виде сверху их нет, и
// рисовать их значило бы показывать стену в двух проекциях сразу.
bool WallHasSides() const { return mode == ViewMode::ISO_2_1; }
};
// Активная проекция. Глобальная по той же причине, что и g_tune: её читают
// рендер, редактор и генерация тайлов, и протаскивать её параметром через
// каждый блит значило бы переписать половину рендера ради значения, которое
// за весь запуск не меняется ни разу.
inline Projection g_proj;

564
engine/room.cpp Normal file
View File

@ -0,0 +1,564 @@
#include "engine/room.h"
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include "engine/text_parse.h"
namespace fs = std::filesystem;
namespace
{
constexpr char CH_EMPTY = '.';
constexpr char CH_WALL = '#';
constexpr char CH_DOOR = '+';
char CharOf(RoomCell c)
{
switch (c)
{
case RoomCell::WALL: return CH_WALL;
case RoomCell::DOOR: return CH_DOOR;
default: return CH_EMPTY;
}
}
bool CellOf(char ch, RoomCell& out)
{
switch (ch)
{
case CH_EMPTY: out = RoomCell::EMPTY; return true;
case CH_WALL: out = RoomCell::WALL; return true;
case CH_DOOR: out = RoomCell::DOOR; return true;
default: return false;
}
}
// Обход рамки по стороне: сколько на ней клеток и какая клетка под номером i.
int SideLength(const Room& r, RoomSide side)
{
return (side == RoomSide::NORTH || side == RoomSide::SOUTH) ? r.w : r.h;
}
void SideCell(const Room& r, RoomSide side, int i, int& x, int& y)
{
switch (side)
{
case RoomSide::NORTH: x = i; y = 0; break;
case RoomSide::SOUTH: x = i; y = r.h - 1; break;
case RoomSide::WEST: x = 0; y = i; break;
default: x = r.w - 1; y = i; break;
}
}
} // namespace
// -----------------------------------------------------------------------------
// Точка спавна
// -----------------------------------------------------------------------------
int SpawnPoint::TotalWeight() const
{
int total = 0;
for (const SpawnOption& o : options) total += (o.weight > 0) ? o.weight : 0;
return total;
}
int SpawnPoint::Pick(float roll01) const
{
const int total = TotalWeight();
if (total <= 0) return -1;
// Бросок приводится к [0, total) и идёт по накопленной сумме. Никаких
// делений на веса: одинаковый бросок обязан давать одинаковый результат
// на любой платформе, а плавающая арифметика здесь только одна — умножение.
float acc = roll01 * float(total);
for (size_t i = 0; i < options.size(); ++i)
{
const int wgt = (options[i].weight > 0) ? options[i].weight : 0;
if (wgt <= 0) continue;
acc -= float(wgt);
if (acc < 0.0f) return int(i);
}
// Бросок ровно 1.0 или накопленная погрешность: отдаём последний непустой.
for (int i = int(options.size()) - 1; i >= 0; --i)
if (options[size_t(i)].weight > 0) return i;
return -1;
}
int SpawnPoint::WeightOf(const std::string& id) const
{
for (const SpawnOption& o : options)
if (o.id == id) return o.weight;
return 0;
}
void SpawnPoint::SetWeight(const std::string& id, int weight)
{
for (size_t i = 0; i < options.size(); ++i)
{
if (options[i].id != id) continue;
if (weight > 0) options[i].weight = weight;
else options.erase(options.begin() + long(i));
return;
}
if (weight > 0) options.push_back(SpawnOption{id, weight});
}
// -----------------------------------------------------------------------------
// Комната
// -----------------------------------------------------------------------------
Room::Room()
{
for (RoomCell& c : cells) c = RoomCell::WALL;
ClearInterior();
FillBorder();
}
int Room::SpawnIndexAt(int x, int y) const
{
for (size_t i = 0; i < spawns.size(); ++i)
if (spawns[i].x == x && spawns[i].y == y) return int(i);
return -1;
}
SpawnPoint* Room::SpawnAt(int x, int y)
{
const int i = SpawnIndexAt(x, y);
return (i >= 0) ? &spawns[size_t(i)] : nullptr;
}
const SpawnPoint* Room::SpawnAt(int x, int y) const
{
const int i = SpawnIndexAt(x, y);
return (i >= 0) ? &spawns[size_t(i)] : nullptr;
}
SpawnPoint& Room::TouchSpawnAt(int x, int y)
{
if (SpawnPoint* p = SpawnAt(x, y)) return *p;
SpawnPoint p;
p.x = x;
p.y = y;
spawns.push_back(std::move(p));
return spawns.back();
}
void Room::RemoveSpawnAt(int x, int y)
{
const int i = SpawnIndexAt(x, y);
if (i >= 0) spawns.erase(spawns.begin() + long(i));
}
void Room::Resize(int newW, int newH)
{
newW = std::max(ROOM_MIN_W, std::min(ROOM_MAX_W, newW));
newH = std::max(ROOM_MIN_H, std::min(ROOM_MAX_H, newH));
if (newW == w && newH == h) return;
// Содержимое переезжает как есть, левым верхним углом: якорь комнаты —
// её угол, и «расти вправо-вниз» единственное, что предсказуемо на глаз.
RoomCell old[ROOM_MAX_W * ROOM_MAX_H];
std::copy(std::begin(cells), std::end(cells), std::begin(old));
const int oldW = w, oldH = h;
for (RoomCell& c : cells) c = RoomCell::WALL;
w = newW;
h = newH;
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x)
{
const bool had = (x < oldW && y < oldH);
RoomCell c = had ? old[size_t(y) * ROOM_MAX_W + x] : RoomCell::EMPTY;
// Дверь, оказавшаяся ВНУТРИ новой комнаты, — это уже не дверь.
// Оставить её значило бы отдать генератору проход в никуда.
if (c == RoomCell::DOOR && !(x == 0 || y == 0 || x == w - 1 || y == h - 1))
c = RoomCell::EMPTY;
Set(x, y, c);
}
FillBorder();
// Точки, выехавшие за новую границу или попавшие в стену, снимаются здесь
// же: иначе они молча висели бы в файле и не срабатывали никогда.
spawns.erase(std::remove_if(spawns.begin(), spawns.end(),
[this](const SpawnPoint& p) { return !Walkable(p.x, p.y); }),
spawns.end());
}
void Room::FillBorder()
{
for (int x = 0; x < w; ++x)
{
if (At(x, 0) == RoomCell::EMPTY) Set(x, 0, RoomCell::WALL);
if (At(x, h - 1) == RoomCell::EMPTY) Set(x, h - 1, RoomCell::WALL);
}
for (int y = 0; y < h; ++y)
{
if (At(0, y) == RoomCell::EMPTY) Set(0, y, RoomCell::WALL);
if (At(w - 1, y) == RoomCell::EMPTY) Set(w - 1, y, RoomCell::WALL);
}
// Углы дверью быть не могут: коридор пришлось бы тянуть сразу в две
// стороны, и генератор не смог бы выбрать, в какую именно.
Set(0, 0, RoomCell::WALL);
Set(w - 1, 0, RoomCell::WALL);
Set(0, h - 1, RoomCell::WALL);
Set(w - 1, h - 1, RoomCell::WALL);
}
void Room::ClearInterior()
{
for (int y = 1; y < h - 1; ++y)
for (int x = 1; x < w - 1; ++x)
Set(x, y, RoomCell::EMPTY);
spawns.clear();
}
int Room::CountDoors() const
{
int n = 0;
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x)
if (At(x, y) == RoomCell::DOOR) ++n;
return n;
}
int Room::CountDoorsOn(RoomSide side) const
{
int n = 0;
const int len = SideLength(*this, side);
for (int i = 0; i < len; ++i)
{
int x = 0, y = 0;
SideCell(*this, side, i, x, y);
if (At(x, y) == RoomCell::DOOR) ++n;
}
return n;
}
bool Room::FirstDoorOn(RoomSide side, int& outX, int& outY) const
{
const int len = SideLength(*this, side);
for (int i = 0; i < len; ++i)
{
int x = 0, y = 0;
SideCell(*this, side, i, x, y);
if (At(x, y) != RoomCell::DOOR) continue;
outX = x;
outY = y;
return true;
}
return false;
}
void Room::DefaultBreachOn(RoomSide side, int& outX, int& outY) const
{
SideCell(*this, side, SideLength(*this, side) / 2, outX, outY);
}
// Сообщения об ошибках — латиницей, и это не мелочь: их показывает редактор,
// а встроенный шрифт raylib кириллицы не знает и рисует её вопросами.
// Комментарии при этом остаются русскими: их читают в исходнике, а не на экране.
std::string RoomProblem(const Room& r)
{
if (!Room::SizeOk(r.w, r.h)) return "size out of range";
if (r.theme >= THEME_COUNT) return "unknown theme";
for (int x = 0; x < r.w; ++x)
for (int y = 0; y < r.h; ++y)
if (r.At(x, y) == RoomCell::DOOR && !r.OnBorder(x, y))
return "door is not on the border";
if (r.CountDoors() == 0) return "no doors at all";
int floorCells = 0;
for (int y = 1; y < r.h - 1; ++y)
for (int x = 1; x < r.w - 1; ++x)
if (r.At(x, y) == RoomCell::EMPTY) ++floorCells;
if (floorCells == 0) return "no floor inside";
for (const SpawnPoint& p : r.spawns)
{
if (!r.Walkable(p.x, p.y)) return "spawn point inside a wall";
if (p.options.empty()) return "spawn point has an empty table";
}
return {};
}
// -----------------------------------------------------------------------------
// Чтение и запись
// -----------------------------------------------------------------------------
namespace
{
// spawn <x> <y> <chance> <id:вес> ...
bool ParseSpawn(const std::vector<std::string>& p, SpawnPoint& out)
{
if (p.size() < 5) return false;
out.x = text::ToInt(p[1], -1);
out.y = text::ToInt(p[2], -1);
out.chance = text::ToFloat(p[3], 1.0f);
if (out.chance < 0.0f) out.chance = 0.0f;
if (out.chance > 1.0f) out.chance = 1.0f;
for (size_t i = 4; i < p.size(); ++i)
{
std::string id, weight;
if (!text::SplitPair(p[i], id, weight)) return false;
const int wgt = text::ToInt(weight, 0);
if (id.empty() || wgt <= 0) return false;
out.options.push_back(SpawnOption{id, wgt});
}
return !out.options.empty();
}
} // namespace
bool LoadRoom(const std::string& path, Room& out, std::string* err)
{
std::ifstream in(path);
if (!in)
{
if (err) *err = "cannot open file";
return false;
}
Room r;
// Планировка приходит целиком блоком `tiles`; пока он не прочитан, размер
// ещё не известен, и заводить сетку не по чему.
bool sized = false;
bool tiled = false;
int lineNo = 0;
std::string line;
auto fail = [&](const char* what) {
if (err) *err = "line " + std::to_string(lineNo) + ": " + what;
return false;
};
while (std::getline(in, line))
{
++lineNo;
const std::vector<std::string> p = text::Split(text::StripComment(line));
if (p.empty()) continue;
const std::string& key = p[0];
if (key == "name" && p.size() >= 2) { r.name = p[1]; continue; }
if (key == "weight" && p.size() >= 2) { r.weight = std::max(1, text::ToInt(p[1], 1)); continue; }
if (key == "theme" && p.size() >= 2) { r.theme = uint8_t(text::ToInt(p[1], 0)); continue; }
if (key == "start" && p.size() >= 2) { r.start = text::ToInt(p[1], 0) != 0; continue; }
// depth <min> [max]; max опущен или 0 — до бесконечности.
if (key == "depth" && p.size() >= 2)
{
r.minDepth = std::max(1, text::ToInt(p[1], 1));
r.maxDepth = (p.size() >= 3) ? text::ToInt(p[2], 0) : 0;
if (r.maxDepth > 0 && r.maxDepth < r.minDepth) return fail("max глубины меньше min");
continue;
}
if (key == "size" && p.size() >= 3)
{
const int w = text::ToInt(p[1], 0);
const int h = text::ToInt(p[2], 0);
if (!Room::SizeOk(w, h)) return fail("bad room size");
r.w = w;
r.h = h;
for (RoomCell& c : r.cells) c = RoomCell::WALL;
sized = true;
continue;
}
if (key == "tiles")
{
if (!sized) return fail("tiles before size");
if (tiled) return fail("duplicate tiles block");
// Строки сетки читаются СЫРЫМИ: '#' здесь означает стену, а не
// начало комментария, и через StripComment их пропускать нельзя.
for (int y = 0; y < r.h; ++y)
{
if (!std::getline(in, line)) return fail("tiles block truncated");
++lineNo;
// Хвостовой \r от файла с windows-переводами строк — не тайл.
while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back();
if (int(line.size()) != r.w) return fail("row length differs from room width");
for (int x = 0; x < r.w; ++x)
{
RoomCell c = RoomCell::WALL;
if (!CellOf(line[size_t(x)], c)) return fail("unknown tile char");
r.Set(x, y, c);
}
}
tiled = true;
continue;
}
if (key == "spawn")
{
if (!tiled) return fail("spawn before tiles block");
SpawnPoint sp;
if (!ParseSpawn(p, sp)) return fail("malformed spawn record");
if (!r.Walkable(sp.x, sp.y)) return fail("spawn point off the room floor");
if (r.SpawnIndexAt(sp.x, sp.y) >= 0) return fail("two spawn points on one cell");
r.spawns.push_back(std::move(sp));
continue;
}
return fail("unknown field");
}
if (!tiled)
{
if (err) *err = "no tiles block";
return false;
}
// Имя по умолчанию — имя файла: оно и так уникально в каталоге, а поле
// `name` нужно ровно для того, чтобы имя пережило переименование файла.
if (r.name.empty()) r.name = fs::path(path).stem().string();
out = std::move(r);
return true;
}
bool SaveRoom(const std::string& path, const Room& r, std::string* err)
{
std::error_code ec;
fs::create_directories(fs::path(path).parent_path(), ec);
std::ofstream f(path, std::ios::binary); // \n без \r: файл лежит в git
if (!f)
{
if (err) *err = "cannot open file for writing";
return false;
}
f << "# комната: планировка и таблицы спавна. Формат — engine/room.h\n";
f << "name " << (r.name.empty() ? "room" : r.name) << "\n";
f << "size " << r.w << " " << r.h << "\n";
f << "weight " << r.weight << "\n";
f << "theme " << int(r.theme) << "\n";
f << "start " << (r.start ? 1 : 0) << "\n";
f << "depth " << r.minDepth << " " << r.maxDepth << "\n";
f << "tiles\n";
for (int y = 0; y < r.h; ++y)
{
for (int x = 0; x < r.w; ++x) f << CharOf(r.At(x, y));
f << "\n";
}
// Точки пишутся в порядке обхода сетки, а не в порядке добавления: иначе
// перестановка кистей в редакторе давала бы diff на ровном месте.
std::vector<const SpawnPoint*> sorted;
sorted.reserve(r.spawns.size());
for (const SpawnPoint& p : r.spawns) sorted.push_back(&p);
std::sort(sorted.begin(), sorted.end(), [](const SpawnPoint* a, const SpawnPoint* b) {
return (a->y != b->y) ? (a->y < b->y) : (a->x < b->x);
});
for (const SpawnPoint* p : sorted)
{
if (p->options.empty()) continue; // пустая таблица в файле — мусор
char chance[16];
std::snprintf(chance, sizeof(chance), "%.2f", double(p->chance));
f << "spawn " << p->x << " " << p->y << " " << chance;
for (const SpawnOption& o : p->options) f << " " << o.id << ":" << o.weight;
f << "\n";
}
if (!f.good())
{
if (err) *err = "write failed";
return false;
}
return true;
}
// -----------------------------------------------------------------------------
// Набор комнат
// -----------------------------------------------------------------------------
bool RoomLibrary::Load(const std::string& dirPath, std::string* err)
{
rooms.clear();
failures.clear();
dir = dirPath;
std::error_code ec;
if (!fs::is_directory(dirPath, ec))
{
if (err) *err = "no rooms directory: " + dirPath;
return false;
}
// Порядок обхода каталога файловой системой не определён, а генератор
// обязан быть детерминирован по сиду. Поэтому сначала собираем имена
// и сортируем, и только потом читаем.
std::vector<fs::path> files;
for (const fs::directory_entry& e : fs::directory_iterator(dirPath, ec))
if (e.is_regular_file(ec) && e.path().extension() == ".room") files.push_back(e.path());
std::sort(files.begin(), files.end());
for (const fs::path& p : files)
{
Room r;
std::string why;
if (LoadRoom(p.string(), r, &why))
rooms.push_back(std::move(r));
else
failures.push_back(p.filename().string() + ": " + why);
}
if (rooms.empty())
{
if (err) *err = "no readable rooms in " + dirPath;
return false;
}
return true;
}
int RoomLibrary::TotalWeight() const
{
int total = 0;
for (const Room& r : rooms) total += std::max(1, r.weight);
return total;
}
int RoomLibrary::IndexOf(const std::string& name) const
{
for (size_t i = 0; i < rooms.size(); ++i)
if (rooms[i].name == name) return int(i);
return -1;
}
bool RoomLibrary::HasStartRoom() const
{
for (const Room& r : rooms)
if (r.start) return true;
return false;
}
int RoomLibrary::CountAt(int depth) const
{
int n = 0;
for (const Room& r : rooms)
if (r.InDepth(depth)) ++n;
return n;
}
bool RoomLibrary::HasStartRoomAt(int depth) const
{
for (const Room& r : rooms)
if (r.start && r.InDepth(depth)) return true;
return false;
}
// FindRoomsDir() здесь БОЛЬШЕ НЕТ. Каталог комнат называет проект
// (engine/project.h), и движок, который сам ищет чьё-то assets/rooms, — это
// движок с зашитым именем ровно одной игры.

198
engine/room.h Normal file
View File

@ -0,0 +1,198 @@
#pragma once
// -----------------------------------------------------------------------------
// Комната — ШАБЛОН, а не готовый кусок карты.
//
// В файле лежит планировка (пол, стены, двери) и точки спавна, но НЕ лежит
// ответ на вопрос «кто здесь стоит». Точка спавна несёт таблицу весов по id
// каталога и шанс срабатывания: одна и та же комната, поставленная дважды,
// населена по-разному. Именно поэтому уровень, собранный из комнат, читается
// как уровень, а не как одна и та же кишка, повторённая пять раз.
//
// Размер ограничен ROOM_MAX_*, потому что генератор ставит комнаты в слоты
// фиксированного размера (engine/level_gen.h). Комната меньше слота — вокруг
// неё остаётся камень, и в нём режутся коридоры; комната размером в слот
// оставила бы коридорам ноль места.
//
// Формат текстовый и построчный: комнаты лежат в git, и правка планировки
// обязана читаться в diff. Бинарный формат сэкономил бы килобайты и отнял бы
// единственный способ увидеть, что именно изменилось.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
#include "engine/theme.h"
// Слот генератора — 16 тайлов (48/3). Комната не может занять его целиком:
// хотя бы по тайлу с каждой стороны остаётся под коридоры и рамку.
constexpr int ROOM_SLOT = 16;
constexpr int ROOM_MAX_W = 14;
constexpr int ROOM_MAX_H = 14;
constexpr int ROOM_MIN_W = 5;
constexpr int ROOM_MIN_H = 5;
enum class RoomCell : uint8_t
{
EMPTY = 0, // пол
WALL = 1,
DOOR = 2, // проход наружу: пол, к которому генератор тянет коридор
};
// Стороны в мировых осях: y растёт на юг, x — на восток.
enum class RoomSide : uint8_t
{
NORTH = 0,
EAST,
SOUTH,
WEST,
COUNT,
};
inline int SideDX(RoomSide s) { return (s == RoomSide::EAST) ? 1 : (s == RoomSide::WEST ? -1 : 0); }
inline int SideDY(RoomSide s) { return (s == RoomSide::SOUTH) ? 1 : (s == RoomSide::NORTH ? -1 : 0); }
inline RoomSide OppositeSide(RoomSide s) { return RoomSide((int(s) + 2) % 4); }
// Один вариант в таблице точки спавна. id — строка каталога, а не индекс:
// каталог правится руками, и перестановка строк в нём не должна молча менять
// население всех комнат разом.
struct SpawnOption
{
std::string id;
int weight = 1;
};
// Точка спавна: место плюс таблица «кто здесь может оказаться».
struct SpawnPoint
{
int x = 0;
int y = 0;
float chance = 1.0f; // вероятность, что точка вообще сработает
std::vector<SpawnOption> options; // пусто — точка не даёт никого
int TotalWeight() const;
// Выбрать вариант по броску [0..1). -1 — таблица пуста или все веса нулевые.
int Pick(float roll01) const;
// Вес по id; 0 — такого варианта в таблице нет.
int WeightOf(const std::string& id) const;
// Выставить вес. 0 убирает вариант из таблицы: нулевые веса в файле —
// это мусор, который переживает правку и потом путает.
void SetWeight(const std::string& id, int weight);
};
struct Room
{
std::string name; // имя файла без расширения
int w = 12;
int h = 10;
int weight = 1; // вес комнаты при выборе генератором
uint8_t theme = 0;
bool start = false; // годится как стартовая комната отряда
// --- глубина --------------------------------------------------------------
// С какого этажа комната встречается и по какой (0 — до бесконечности).
// Планировка — тоже часть «чем глубже, тем хуже»: широкая арена первых
// этажей и теснота, где нельзя развернуть строй, — это разные игры, и
// отдавать их случайности на всех этажах поровну незачем.
int minDepth = 1;
int maxDepth = 0;
bool InDepth(int depth) const
{
return depth >= minDepth && (maxDepth <= 0 || depth <= maxDepth);
}
RoomCell cells[ROOM_MAX_W * ROOM_MAX_H] = {};
std::vector<SpawnPoint> spawns;
Room(); // пустая комната с рамкой стен
static bool SizeOk(int w, int h)
{
return w >= ROOM_MIN_W && w <= ROOM_MAX_W && h >= ROOM_MIN_H && h <= ROOM_MAX_H;
}
bool InBounds(int x, int y) const
{
return unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h);
}
// Вне комнаты — стена. То же правило, что у Tilemap: оно убирает граничные
// проверки из всего, что ходит по сетке.
RoomCell At(int x, int y) const
{
return InBounds(x, y) ? cells[size_t(y) * ROOM_MAX_W + x] : RoomCell::WALL;
}
void Set(int x, int y, RoomCell c)
{
if (InBounds(x, y)) cells[size_t(y) * ROOM_MAX_W + x] = c;
}
bool Walkable(int x, int y) const { return At(x, y) != RoomCell::WALL; }
bool OnBorder(int x, int y) const
{
return InBounds(x, y) && (x == 0 || y == 0 || x == w - 1 || y == h - 1);
}
// --- точки спавна ---------------------------------------------------------
int SpawnIndexAt(int x, int y) const;
SpawnPoint* SpawnAt(int x, int y);
const SpawnPoint* SpawnAt(int x, int y) const;
// Точка на этом месте либо уже есть, либо заводится пустой.
SpawnPoint& TouchSpawnAt(int x, int y);
void RemoveSpawnAt(int x, int y);
// --- правка планировки ----------------------------------------------------
// Размер меняется с сохранением содержимого; всё, что вышло за новую
// границу, теряется вместе с точками спавна на нём.
void Resize(int newW, int newH);
void FillBorder(); // обвести рамкой стен, двери сохранить
void ClearInterior(); // выскоблить всё внутри рамки
// --- двери ----------------------------------------------------------------
// Двери обязаны стоять на рамке: генератор тянет коридор НАРУЖУ, и дверь
// посреди комнаты вела бы в собственную стену.
int CountDoors() const;
int CountDoorsOn(RoomSide side) const;
// Первая дверь на стороне. false — на этой стороне дверей нет.
bool FirstDoorOn(RoomSide side, int& outX, int& outY) const;
// Куда генератор пробьёт стену, если дверей на стороне нет: середина стороны.
void DefaultBreachOn(RoomSide side, int& outX, int& outY) const;
};
// Что в комнате не так. Пустая строка — всё в порядке. Не «загрузилась ли»,
// а «сыграет ли»: комнату без дверей генератор поставить не сможет.
std::string RoomProblem(const Room& r);
bool LoadRoom(const std::string& path, Room& out, std::string* err = nullptr);
bool SaveRoom(const std::string& path, const Room& r, std::string* err = nullptr);
// -----------------------------------------------------------------------------
// Набор комнат: всё, что лежит в assets/rooms/*.room.
// -----------------------------------------------------------------------------
struct RoomLibrary
{
std::string dir;
std::vector<Room> rooms;
// Комнаты, которые не загрузились: имя файла и причина. Редактор обязан
// показать их списком, а не молча недосчитаться комнаты в наборе.
std::vector<std::string> failures;
// Порядок — алфавитный по имени файла: генератор детерминирован по сиду,
// а порядок обхода каталога файловой системой таковым не является.
bool Load(const std::string& dirPath, std::string* err = nullptr);
int Count() const { return int(rooms.size()); }
int TotalWeight() const;
int IndexOf(const std::string& name) const;
bool HasStartRoom() const;
// Есть ли чем застроить этот этаж. Разделено на два вопроса, потому что
// это две РАЗНЫЕ поломки забега: «нет обычных комнат» и «нет стартовой».
int CountAt(int depth) const;
bool HasStartRoomAt(int depth) const;
};
// Где лежит набор — знает проект (engine/project.h). Движок сам его не ищет.

418
engine/sfa_library.cpp Normal file
View File

@ -0,0 +1,418 @@
#include "engine/sfa_library.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
// sfa.h — чужой заголовок из репозитория SpriteForge, единственный экземпляр
// на всю игру лежит здесь. Он держит весь код в статических функциях, поэтому
// без глушилки предупреждений /W4 и -Wall тонут в шуме про неиспользованное.
#if defined(_MSC_VER)
# pragma warning(push, 0)
#elif defined(__GNUC__)
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wunused-function"
#endif
#include "sfa.h"
#if defined(_MSC_VER)
# pragma warning(pop)
# pragma warning(disable : 4505) // C4505 приходит в конце файла, а не в блоке выше
#elif defined(__GNUC__)
# pragma GCC diagnostic pop
#endif
namespace fs = std::filesystem;
namespace
{
// Полный оборот в сотых долях градуса: .sfa хранит направления именно так.
constexpr int FULL_TURN_CD = 36000;
constexpr double RAD_TO_CD = 18000.0 / 3.14159265358979323846;
constexpr float MS_TO_SEC = 0.001f;
// Тот же хеш, которым `sf codegen` считает SF_ASSET_*: FNV-1a, 32 бита, по
// байтам UTF-8. Совпадение обязано быть точным, иначе ассет не найдётся.
uint32_t Fnv1a(const std::string& s)
{
uint32_t h = 2166136261u;
for (const char c : s) h = (h ^ uint8_t(c)) * 16777619u;
return h;
}
bool ReadWhole(const fs::path& path, std::vector<uint8_t>& out)
{
std::error_code ec;
const auto size = fs::file_size(path, ec);
if (ec) return false;
std::ifstream in(path, std::ios::binary);
if (!in) return false;
out.resize(size_t(size));
if (out.empty()) return false;
in.read(reinterpret_cast<char*>(out.data()), std::streamsize(out.size()));
return bool(in);
}
// Кадр .sfa -> Sprite на холсте по размеру кадра, привязка ровно в pivot.
// Дальше игра работает только с этим спрайтом: RLE распаковывается один раз
// за загрузку библиотеки.
bool DecodeFrame(const sfa_view& view, const sfa_frame& frame, const uint8_t* palette, Sprite& out)
{
Sprite sprite;
sprite.Init(int(frame.width), int(frame.height), 0); // прозрачный фон
sprite.ax = int(frame.pivot_x);
sprite.ay = int(frame.pivot_y);
if (sprite.px.empty()) return false;
// SFA_BLEND_OPAQUE ставит альфу 255 ровно на записанных пикселях, а порядок
// каналов в sfa_mix (R в младшем байте) совпадает с RGBA() фреймбуфера.
sfa_surface surface{sprite.px.data(), sprite.w, sprite.h, sprite.w};
if (sfa_blit(&view, &frame, palette, nullptr, surface, sprite.ax, sprite.ay,
SFA_BLEND_OPAQUE) != SFA_OK)
return false;
out = std::move(sprite);
return true;
}
} // namespace
bool SpriteLibrary::Open(const std::string& dir)
{
// Копия до Close(): аргументом легко приезжает Dir() этой же библиотеки
// (`lib.Open(lib.Dir())`), а Close() эту строку обнуляет — дальше каталог
// читался бы по висячей ссылке.
const std::string path = dir;
Close();
const fs::path root(path);
// Палитра общая на всю библиотеку: индексы в кадрах без неё бессмысленны,
// и читать её надо до ассетов — кадры распаковываются прямо здесь.
std::vector<uint8_t> pack;
if (!ReadWhole(root / "palettes.sfp", pack)) return false;
sfp_view pv{};
const uint8_t* rgb = nullptr;
if (sfp_open(pack.data(), pack.size(), &pv) != SFA_OK) return false;
if (sfp_palette(&pv, 0, &rgb) != SFA_OK) return false;
std::memcpy(palette_.data(), rgb, palette_.size());
// Имя файла — это и есть строковый ID ассета. Больше строковых ID в игре
// нет нигде: дальше работают только числа из generated/.
std::error_code ec;
for (const fs::directory_entry& entry : fs::directory_iterator(root, ec))
{
if (!entry.is_regular_file(ec)) continue;
if (entry.path().extension() != ".sfa") continue;
std::vector<uint8_t> bytes;
if (!ReadWhole(entry.path(), bytes)) continue;
Asset asset;
asset.name = entry.path().stem().string();
if (!DecodeAsset(bytes.data(), bytes.size(), asset)) continue;
assets_.emplace(Fnv1a(asset.name), std::move(asset));
}
if (ec) { Close(); return false; }
dir_ = path;
stamp_ = ReloadStamp();
open_ = !assets_.empty();
if (!open_) Close();
return open_;
}
void SpriteLibrary::Close()
{
assets_.clear();
palette_.fill(0);
dir_.clear();
stamp_ = Stamp{};
open_ = false;
}
bool SpriteLibrary::DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const
{
sfa_view view{};
if (sfa_open(bytes, size, &view) != SFA_OK) return false;
out.frames.resize(size_t(view.frame_count));
out.durationsMs.resize(size_t(view.frame_count));
for (uint32_t i = 0; i < view.frame_count; ++i)
{
sfa_frame frame{};
if (sfa_get_frame(&view, i, &frame) != SFA_OK) return false;
if (!DecodeFrame(view, frame, palette_.data(), out.frames[i])) return false;
out.durationsMs[i] = frame.duration_ms;
}
out.clips.reserve(view.animation_count);
for (uint32_t i = 0; i < view.animation_count; ++i)
{
sfa_animation anim{};
if (sfa_get_animation(&view, uint16_t(i), &anim) != SFA_OK) return false;
// Диапазон клипа проверяется здесь и только здесь: если он сходится,
// выборка кадра дальше не может выйти за плоский список.
const uint64_t last = uint64_t(anim.first_frame) +
uint64_t(anim.frames_per_direction) * view.direction_count;
if (anim.frames_per_direction == 0 || last > view.frame_count) return false;
out.clips.push_back(AnimClip{anim.name_hash, int(anim.first_frame),
int(anim.frames_per_direction)});
}
out.dirs.reserve(view.direction_count);
for (uint32_t i = 0; i < view.direction_count; ++i)
{
sfa_direction dir{};
if (sfa_get_direction(&view, uint16_t(i), &dir) != SFA_OK) return false;
// Угол знаковый: 315 градусов приезжают и как 31500, и как -4500.
int cd = int(dir.centidegrees) % FULL_TURN_CD;
if (cd < 0) cd += FULL_TURN_CD;
out.dirs.push_back(cd);
}
return !out.frames.empty() && !out.clips.empty() && !out.dirs.empty();
}
const SpriteLibrary::Asset* SpriteLibrary::FindAsset(uint32_t assetId) const
{
const auto it = assets_.find(assetId);
return (it == assets_.end()) ? nullptr : &it->second;
}
const AnimClip* SpriteLibrary::FindClip(const Asset& asset, uint32_t animNameHash)
{
// Клипов у ассета единицы, хеш-таблица тут дороже перебора.
for (const AnimClip& clip : asset.clips)
if (clip.nameHash == animNameHash) return &clip;
return nullptr;
}
float SpriteLibrary::ClipLength(const Asset& asset, const AnimClip& clip)
{
// Тайминг берётся с направления 0: кадры у направлений одни и те же, а
// разъехавшиеся длительности превратили бы поворот на месте в рывок.
float total = 0.0f;
for (int i = 0; i < clip.framesPerDir; ++i)
total += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC;
return total;
}
int SpriteLibrary::FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop)
{
const float length = ClipLength(asset, clip);
if (length <= 0.0f) return 0; // все кадры нулевой длительности — статичная поза
// Отрицательное время и NaN трактуем как начало клипа: сущность в первом
// кадре выглядит нормально, а пропуск отрисовки — нет.
float t = (timeSec > 0.0f) ? timeSec : 0.0f;
if (loop)
{
t = std::fmod(t, length);
if (t < 0.0f) t = 0.0f;
}
float acc = 0.0f;
for (int i = 0; i < clip.framesPerDir; ++i)
{
acc += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC;
if (t < acc) return i;
}
return clip.framesPerDir - 1; // незацикленный клип замирает на последнем кадре
}
int SpriteLibrary::FrameCount(uint32_t assetId) const
{
const Asset* asset = FindAsset(assetId);
return asset ? int(asset->frames.size()) : 0;
}
int SpriteLibrary::AnimationCount(uint32_t assetId) const
{
const Asset* asset = FindAsset(assetId);
return asset ? int(asset->clips.size()) : 0;
}
bool SpriteLibrary::FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const
{
const Asset* asset = FindAsset(assetId);
if (!asset) return false;
const AnimClip* clip = FindClip(*asset, animNameHash);
if (!clip) return false;
out = *clip;
return true;
}
int SpriteLibrary::DirectionCount(uint32_t assetId) const
{
const Asset* asset = FindAsset(assetId);
return asset ? int(asset->dirs.size()) : 0;
}
int SpriteLibrary::DirectionOf(uint32_t assetId, float angleRad) const
{
const Asset* asset = FindAsset(assetId);
if (!asset || asset->dirs.size() < 2) return 0;
const double turn = double(angleRad) * RAD_TO_CD;
if (!std::isfinite(turn)) return 0; // угол из мусора — не повод считать fmod
int cd = int(std::fmod(turn, double(FULL_TURN_CD)));
if (cd < 0) cd += FULL_TURN_CD;
// Набор направлений не обязан быть равномерным, поэтому не деление, а поиск
// ближайшего по кругу: 350 градусов должны попасть в направление 0.
int best = 0;
int bestDist = FULL_TURN_CD;
for (size_t i = 0; i < asset->dirs.size(); ++i)
{
int dist = std::abs(asset->dirs[i] - cd);
if (dist > FULL_TURN_CD / 2) dist = FULL_TURN_CD - dist;
if (dist < bestDist)
{
bestDist = dist;
best = int(i);
}
}
return best;
}
const Sprite* SpriteLibrary::Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex,
float timeSec, bool loop) const
{
const Asset* asset = FindAsset(assetId);
if (!asset) return nullptr;
const AnimClip* clip = FindClip(*asset, animNameHash);
if (!clip) return nullptr;
// Направление вне набора — не повод не нарисовать сущность.
const int dirs = int(asset->dirs.size());
const int dir = (dirIndex < 0) ? 0 : ((dirIndex >= dirs) ? dirs - 1 : dirIndex);
// Раскладка направленчески-мажорная, границы клипа проверены на загрузке.
const size_t index = size_t(clip->firstFrame) + size_t(dir) * size_t(clip->framesPerDir) +
size_t(FrameInClip(*asset, *clip, timeSec, loop));
if (index >= asset->frames.size()) return nullptr;
return &asset->frames[index];
}
float SpriteLibrary::AnimationLength(uint32_t assetId, uint32_t animNameHash) const
{
const Asset* asset = FindAsset(assetId);
if (!asset) return 0.0f;
const AnimClip* clip = FindClip(*asset, animNameHash);
return clip ? ClipLength(*asset, *clip) : 0.0f;
}
bool SpriteLibrary::Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const
{
const Asset* asset = FindAsset(assetId);
if (!asset || frame < 0 || frame >= int(asset->frames.size())) return false;
const Sprite& src = asset->frames[size_t(frame)];
// Холст по размеру кадра — копия как есть, привязка уже стоит в pivot.
const bool padded = place.canvasW > 0 && place.canvasH > 0;
if (!padded)
{
out = src;
if (place.anchorX >= 0) out.ax = place.anchorX;
if (place.anchorY >= 0) out.ay = place.anchorY;
return true;
}
Sprite sprite;
sprite.Init(place.canvasW, place.canvasH, 0);
sprite.ax = (place.anchorX >= 0) ? place.anchorX : place.pivotX;
sprite.ay = (place.anchorY >= 0) ? place.anchorY : place.pivotY;
// Тот же перенос, что делал бы sfa_blit с якорем (pivotX, pivotY): pivot
// кадра совмещается с заданной точкой холста, прозрачное не пишется.
const int x0 = place.pivotX - src.ax;
const int y0 = place.pivotY - src.ay;
for (int y = 0; y < src.h; ++y)
for (int x = 0; x < src.w; ++x)
{
const uint32_t c = src.px[size_t(y) * size_t(src.w) + size_t(x)];
if (AlphaOf(c) != 0) sprite.Set(x0 + x, y0 + y, c);
}
out = std::move(sprite);
return true;
}
const char* SpriteLibrary::NameOf(uint32_t assetId) const
{
const Asset* asset = FindAsset(assetId);
return asset ? asset->name.c_str() : "";
}
std::vector<uint32_t> SpriteLibrary::Ids() const
{
std::vector<uint32_t> ids;
ids.reserve(assets_.size());
for (const auto& pair : assets_) ids.push_back(pair.first);
std::sort(ids.begin(), ids.end(), [this](uint32_t a, uint32_t b) {
return assets_.at(a).name < assets_.at(b).name;
});
return ids;
}
SpriteLibrary::Stamp SpriteLibrary::ReloadStamp() const
{
Stamp stamp;
if (dir_.empty()) return stamp;
std::error_code ec;
const fs::path path = fs::path(dir_) / "reload.json";
const auto time = fs::last_write_time(path, ec);
if (ec) return stamp;
stamp.time = time.time_since_epoch().count();
stamp.size = uint64_t(fs::file_size(path, ec));
if (ec) return Stamp{};
return stamp;
}
bool SpriteLibrary::PollReload()
{
if (!open_) return false;
const Stamp fresh = ReloadStamp();
if (fresh.time == 0 || !(fresh != stamp_)) return false;
// `sf watch` обновляет reload.json последним, когда выходные файлы уже на
// месте. Но если пересборка всё же долетела не целиком, старая библиотека
// обязана остаться рабочей: собираем новую рядом и подменяем только целую.
// Заодно это единственный честный способ пережить смену набора анимаций.
SpriteLibrary next;
if (!next.Open(dir_))
{
stamp_ = fresh; // иначе неудачную попытку будет видно каждый кадр
return false;
}
*this = std::move(next);
return true;
}
std::string FindSpriteLibraryDir()
{
// Запуск из корня репозитория и запуск из build/bin — оба рабочие.
static const char* const candidates[] = {"build/sprites", "sprites", "../sprites",
"../../build/sprites"};
std::error_code ec;
for (const char* candidate : candidates)
if (fs::exists(fs::path(candidate) / "palettes.sfp", ec)) return candidate;
return {};
}

160
engine/sfa_library.h Normal file
View File

@ -0,0 +1,160 @@
#pragma once
// -----------------------------------------------------------------------------
// Чтение библиотеки спрайтов, собранной SpriteForge (`sf build`).
//
// Игра не знает про YAML, генераторы и PNG. Она видит только каталог с файлами
// `<asset_id>.sfa` и общей палитрой `palettes.sfp`, а обращается к ассетам по
// числовым идентификаторам из generated/spriteforge_assets.h. Строковых ID
// ассетов в коде игры быть не должно.
//
// Слой ровно один: .sfa (индексы палитры + RLE по строкам) -> игровой Sprite
// (RGBA8 с альфа-тестом). Дальше по коду разницы между процедурным спрайтом
// и собранным нет.
//
// Распаковка идёт целиком на Open() и только там. Ленивое декодирование стоило
// бы RLE-прохода и аллокации холста на КАЖДУЮ анимированную сущность в КАЖДОМ
// кадре игры; готовые Sprite лежат в памяти, наружу отдаются ссылки на них.
// -----------------------------------------------------------------------------
#include <array>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#include "engine/framebuffer.h"
#include "spriteforge_assets.h" // sf_asset_id, SF_ASSET_*, SF_ANIM_*
// Куда положить кадр .sfa на холст игрового спрайта.
//
// SpriteForge обрезает кадр по силуэту, поэтому размер кадра плывёт вместе с
// содержимым. Тайлам это не подходит: ShadeQuad считается в координатах
// спрайта и предполагает канонический холст с ромбом в известном месте
// (Lighting::QuadFor). Placement кладёт кадр на такой холст: pivot из .sfa
// совмещается с (pivotX, pivotY).
struct FramePlacement
{
int canvasW = 0; // 0 — холст по размеру кадра, без дополнения
int canvasH = 0;
int pivotX = 0; // куда на холсте попадает pivot кадра
int pivotY = 0;
int anchorX = -1; // Sprite::ax; -1 — привязка в точке pivot
int anchorY = -1; // Sprite::ay
};
// Один клип внутри ассета. Имя приходит только хешем (SF_ANIM_*): строковых
// имён анимаций в игре, как и строковых ID ассетов, быть не должно.
struct AnimClip
{
uint32_t nameHash = 0;
int firstFrame = 0; // индекс первого кадра клипа в плоском списке кадров
int framesPerDir = 0; // кадров на одно направление
};
class SpriteLibrary
{
public:
// Открыть каталог сборки (обычно build/sprites). Файлы читаются целиком
// и распаковываются сразу: битый ассет просто не попадает в набор.
bool Open(const std::string& dir);
void Close();
bool IsOpen() const { return open_; }
const std::string& Dir() const { return dir_; }
int AssetCount() const { return int(assets_.size()); }
// Все кадры ассета скопом, по всем анимациям и направлениям.
int FrameCount(uint32_t assetId) const;
// Кадр как игровой Sprite. false — нет такого ассета или кадра; out в этом
// случае не трогается. Копия: вызывающий волен её пережить и подшаманить
// (так грузятся тайлы, им нужен канонический холст).
bool Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const;
bool Read(uint32_t assetId, int frame, Sprite& out) const
{
return Read(assetId, frame, FramePlacement{}, out);
}
// --- анимации ---
//
// Раскладка кадров в .sfa НАПРАВЛЕНЧЕСКИ-МАЖОРНАЯ, клипы идут подряд:
// index = clip.firstFrame + dirIndex * clip.framesPerDir + frameInClip
// Поэтому firstFrame анимации — это не ноль, а конец предыдущей.
int AnimationCount(uint32_t assetId) const;
// Клип по хешу имени (SF_ANIM_* из generated/spriteforge_assets.h).
bool FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const;
// Сколько направлений отрисовано у ассета; 1 — вид один на все стороны.
int DirectionCount(uint32_t assetId) const;
// Индекс направления, ближайшего к мировому углу (радианы, как Agent::facing).
// Нет ассета — 0: рисовать всё равно нечего, а ветвиться у вызывающего лишнее.
int DirectionOf(uint32_t assetId, float angleRad) const;
// Кадр анимации: timeSec — время от её начала, loop — зациклить, иначе клип
// замирает на последнем кадре (смерть так и остаётся лежать).
// nullptr — нет такого ассета или анимации.
//
// ВНИМАНИЕ: указатель живёт до ближайшего PollReload(), вернувшего true.
// После пересборки библиотеки все выданные раньше кадры недействительны,
// держать их между кадрами игры нельзя.
const Sprite* Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex,
float timeSec, bool loop) const;
// Длительность клипа целиком, сек. 0 — нет такой анимации либо все кадры
// нулевой длительности (статичная поза).
float AnimationLength(uint32_t assetId, uint32_t animNameHash) const;
// Watch-режим `sf watch` атомарно переписывает reload.json после успешной
// пересборки. Вызов раз в кадр; true — библиотека перечитана и спрайты,
// взятые из неё раньше, устарели. Смена набора анимаций переваривается
// сама собой: пересобирается вся библиотека целиком, а не отдельные кадры.
bool PollReload();
// Имя ассета (оно же имя файла) — только для отладочного отчёта.
const char* NameOf(uint32_t assetId) const;
// Все идентификаторы, отсортированные по имени. Тоже только для отчёта.
std::vector<uint32_t> Ids() const;
private:
struct Asset
{
std::string name;
std::vector<Sprite> frames; // уже RGBA8; индекс — как в .sfa
std::vector<uint16_t> durationsMs; // длительность кадра, параллельно frames
std::vector<AnimClip> clips;
std::vector<int> dirs; // сотые доли градуса, приведённые к [0, 36000)
};
// Отпечаток reload.json: время правки и размер. 0 — файла нет.
struct Stamp
{
int64_t time = 0;
uint64_t size = 0;
bool operator!=(const Stamp& o) const { return time != o.time || size != o.size; }
};
// Разобрать .sfa целиком: все кадры в Sprite, клипы, направления.
// false — файл битый или его раскладка не сходится сама с собой.
bool DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const;
const Asset* FindAsset(uint32_t assetId) const;
static const AnimClip* FindClip(const Asset& asset, uint32_t animNameHash);
static float ClipLength(const Asset& asset, const AnimClip& clip);
static int FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop);
Stamp ReloadStamp() const;
std::unordered_map<uint32_t, Asset> assets_;
std::array<uint8_t, 768> palette_{}; // 256 RGB-троек, индекс 0 прозрачен
std::string dir_;
Stamp stamp_;
bool open_ = false;
};
// Первый каталог сборки, в котором лежит palettes.sfp: рядом с бинарником
// (build/bin -> ../sprites) или относительно корня репозитория. Пустая строка —
// библиотека не собрана.
std::string FindSpriteLibraryDir();

107
engine/text_parse.h Normal file
View File

@ -0,0 +1,107 @@
#pragma once
// -----------------------------------------------------------------------------
// Разбор строчных текстовых форматов движка: каталог сущностей и файлы комнат.
//
// Свой микро-парсер, а не YAML/JSON: оба формата — это «слово и несколько
// значений в строке», и внешняя библиотека ради них тянула бы в движок
// зависимость тяжелее всего остального движка вместе взятого. Оба файла
// правятся руками и лежат в git, поэтому формат обязан быть построчным и
// diff-читаемым — это требование к формату, а не следствие лени.
// -----------------------------------------------------------------------------
#include <cstdlib>
#include <string>
#include <vector>
namespace text
{
// Комментарий — от '#' до конца строки. Внутри значений '#' не встречается:
// в тайловой сетке комнаты решётка означает стену, но сетка читается целыми
// строками и через этот разбор не проходит.
inline std::string StripComment(const std::string& line)
{
const size_t hash = line.find('#');
return (hash == std::string::npos) ? line : line.substr(0, hash);
}
inline std::string Trim(const std::string& s)
{
size_t a = 0;
size_t b = s.size();
while (a < b && (s[a] == ' ' || s[a] == '\t' || s[a] == '\r')) ++a;
while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b;
return s.substr(a, b - a);
}
// Разбить по пробелам. Пустых лексем не возвращает.
inline std::vector<std::string> Split(const std::string& s)
{
std::vector<std::string> out;
size_t i = 0;
while (i < s.size())
{
while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r')) ++i;
const size_t start = i;
while (i < s.size() && s[i] != ' ' && s[i] != '\t' && s[i] != '\r') ++i;
if (i > start) out.push_back(s.substr(start, i - start));
}
return out;
}
// Разбить надвое по разделителю. false — разделителя нет.
//
// Разделителей два, и они РАЗНЫЕ намеренно: `ключ=значение` — это свойство
// записи (каталог сущностей), `имя:вес` — элемент таблицы (веса точки спавна).
// Один символ на оба случая читался бы одинаково в двух разных ролях, и
// таблица весов внутри строки свойств стала бы неразличима.
inline bool SplitAt(const std::string& s, char sep, std::string& key, std::string& value)
{
const size_t at = s.find(sep);
if (at == std::string::npos) return false;
key = s.substr(0, at);
value = s.substr(at + 1);
return true;
}
// `имя:вес` — элемент таблицы.
inline bool SplitPair(const std::string& s, std::string& key, std::string& value)
{
return SplitAt(s, ':', key, value);
}
// `ключ=значение` — свойство записи.
inline bool SplitKeyValue(const std::string& s, std::string& key, std::string& value)
{
return SplitAt(s, '=', key, value);
}
inline int ToInt(const std::string& s, int fallback = 0)
{
if (s.empty()) return fallback;
char* end = nullptr;
const long v = std::strtol(s.c_str(), &end, 10);
return (end && *end == '\0') ? int(v) : fallback;
}
inline float ToFloat(const std::string& s, float fallback = 0.0f)
{
if (s.empty()) return fallback;
char* end = nullptr;
const double v = std::strtod(s.c_str(), &end);
return (end && *end == '\0') ? float(v) : fallback;
}
// Склеить хвост лексем обратно в строку — для полей, где значение с пробелами
// (подпись в палитре, теги комнаты).
inline std::string JoinFrom(const std::vector<std::string>& parts, size_t first)
{
std::string out;
for (size_t i = first; i < parts.size(); ++i)
{
if (!out.empty()) out += ' ';
out += parts[i];
}
return out;
}
} // namespace text

21
engine/theme.h Normal file
View File

@ -0,0 +1,21 @@
#pragma once
// -----------------------------------------------------------------------------
// Тема отделки — НАБОР тайлов пола и стен, которым отрисован тайл карты.
//
// Отдельный крошечный заголовок, потому что тему знают двое, и по разным
// причинам: формат комнаты (engine/room.h) хранит её как данные, тайлсет
// (engine/tileset.h) по ней выбирает спрайты. Свести их в один заголовок
// значило бы затащить рендер в формат файла.
//
// Пока темы получаются перекраской базового набора: это честная заглушка, а не
// временное решение, — она даёт различимые на глаз материалы, не требуя ни
// одного собранного ассета. Когда для темы соберут свой тайлсет, поменяется
// engine/tileset.cpp, а формат комнат и уже нарисованные комнаты — нет.
// -----------------------------------------------------------------------------
#include <cstdint>
constexpr int THEME_COUNT = 4;
// Имя темы для палитры редактора. Неизвестный номер — "?": редактор обязан
// показать чужую тему как чужую, а не как первую попавшуюся.
const char* ThemeName(uint8_t theme);

View File

@ -1,4 +1,4 @@
#include "world/tilemap.h"
#include "engine/tilemap.h"
#include <limits>
@ -19,6 +19,9 @@ constexpr float INF_F = std::numeric_limits<float>::infinity();
void Tilemap::Generate()
{
for (int i = 0; i < MAP_W * MAP_H; ++i) tiles[i] = Tile::EMPTY;
// Захардкоженная планировка — одна тема на всю карту. Разные темы приходят
// только с уровнем, собранным из комнат (engine/level_gen.h).
for (int i = 0; i < MAP_W * MAP_H; ++i) theme[i] = 0;
auto fill = [&](int x0, int y0, int x1, int y1, Tile t) {
for (int y = y0; y <= y1; ++y)

View File

@ -6,8 +6,8 @@
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
#include "tuning.h"
#include "engine/math.h"
#include "engine/config.h"
enum class Tile : uint8_t
{
@ -19,6 +19,13 @@ struct Tilemap
{
Tile tiles[MAP_W * MAP_H] = {};
// Тема отделки тайла: какой НАБОР спрайтов пола и стен на нём лежит.
// Слой отдельный от tiles намеренно — проходимость и внешний вид это два
// разных вопроса, и смешав их в одном значении, мы получили бы отдельный
// тип тайла на каждое сочетание. Симуляция этот слой не читает вообще:
// рейкаст, коллизии и правила видят только Tile.
uint8_t theme[MAP_W * MAP_H] = {};
void Generate(); // единственный захардкоженный уровень
static bool InBounds(int x, int y) { return unsigned(x) < unsigned(MAP_W) && unsigned(y) < unsigned(MAP_H); }
@ -26,6 +33,9 @@ struct Tilemap
Tile At(int x, int y) const { return InBounds(x, y) ? tiles[y * MAP_W + x] : Tile::WALL; }
void Set(int x, int y, Tile t) { if (InBounds(x, y)) tiles[y * MAP_W + x] = t; }
uint8_t ThemeAt(int x, int y) const { return InBounds(x, y) ? theme[y * MAP_W + x] : 0; }
void SetTheme(int x, int y, uint8_t t) { if (InBounds(x, y)) theme[y * MAP_W + x] = t; }
// Вне карты считаем стеной — так рейкасты и коллизии не убегают наружу.
bool IsWall(int x, int y) const { return At(x, y) == Tile::WALL; }
bool IsWallAt(Vec2 p) const { return IsWall(int(std::floor(p.x)), int(std::floor(p.y))); }

69
engine/tilemap_draw.cpp Normal file
View File

@ -0,0 +1,69 @@
#include "engine/tilemap_draw.h"
#include <algorithm>
#include <cmath>
#include "engine/view2d.h"
namespace
{
// Запас за краями кадра: спрайт, торчащий внутрь краем, обязан дорисоваться.
// Считается по самому большому тайлу, а не по текущей проекции, — это верхняя
// оценка, и лишние блиты по кромке дешевле мигающих стен.
constexpr int MARGIN_X = TILE_MAX_W;
constexpr int MARGIN_Y = WALL_H + TILE_MAX_H;
bool OffScreen(int sx, int sy)
{
return sx < -MARGIN_X || sx > INTERNAL_W + MARGIN_X || sy < -MARGIN_Y ||
sy > INTERNAL_H + MARGIN_Y;
}
} // namespace
void DrawTilemap(Framebuffer& fb, const Tilemap& map, const TileSet& tiles, int ox, int oy)
{
// Пол — сплошным проходом: он лежит ПОД всем, и сортировать его не с чем.
for (int ty = 0; ty < MAP_H; ++ty)
for (int tx = 0; tx < MAP_W; ++tx)
{
if (map.IsWall(tx, ty)) continue; // под стеной пола не видно
const Vec2 c = v2d::WorldToScreen({float(tx) + 0.5f, float(ty) + 0.5f});
const int sx = int(std::lround(c.x)) + ox;
const int sy = int(std::lround(c.y)) + oy;
if (OffScreen(sx, sy)) continue;
fb.Blit(tiles.Floor(tx, ty, map.ThemeAt(tx, ty)), sx, sy);
}
// Стены — по возрастанию глубины, и глубину считает ПРОЕКЦИЯ: в изометрии
// это x+y, при виде сверху — строка экрана. Обход «по диагоналям x+y»
// выглядел бы правильным и молча ломался при смене вида, поэтому порядок
// не зашит в обход, а взят у v2d::Depth через общий список отрисовки.
static std::vector<v2d::DrawItem> items;
items.clear();
for (int ty = 0; ty < MAP_H; ++ty)
for (int tx = 0; tx < MAP_W; ++tx)
{
if (!map.IsWall(tx, ty)) continue;
const Vec2 world{float(tx) + 0.5f, float(ty) + 0.5f};
const Vec2 c = v2d::WorldToScreen(world);
const int sx = int(std::lround(c.x)) + ox;
const int sy = int(std::lround(c.y)) + oy;
if (OffScreen(sx, sy)) continue;
v2d::DrawItem it;
it.depth = v2d::Depth(world);
it.spr = &tiles.Wall(tx, ty, map.ThemeAt(tx, ty));
it.x = sx;
it.y = sy;
items.push_back(it);
}
v2d::SortByDepth(items);
v2d::FlushDrawList(fb, items);
}

21
engine/tilemap_draw.h Normal file
View File

@ -0,0 +1,21 @@
#pragma once
// -----------------------------------------------------------------------------
// Нарисовать карту тайлсетом через активную проекцию. Без света, без крови,
// без сущностей — только пол и стены.
//
// Это ДЕФОЛТ движка: проект, у которого нет своего слоя света, показывает карту
// этим и больше ничего не пишет. Игра Descent им не пользуется намеренно — её
// проход по полу вплавляет свет в каждый блит (src/render/scene.cpp), и
// вынести это сюда значило бы затащить в движок туман войны.
//
// Две реализации прохода по тайлам — не дублирование, а разные задачи:
// «показать карту» и «нарисовать сцену со светом». Общее у них — порядок
// сортировки, и он один на всех, потому что живёт в проекции.
// -----------------------------------------------------------------------------
#include "engine/framebuffer.h"
#include "engine/tilemap.h"
#include "engine/tileset.h"
// (ox, oy) — смещение сцены в пикселях кадра; берётся у камеры
// (v2d::Camera::ScreenOffset).
void DrawTilemap(Framebuffer& fb, const Tilemap& map, const TileSet& tiles, int ox, int oy);

292
engine/tileset.cpp Normal file
View File

@ -0,0 +1,292 @@
#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;
}

69
engine/tileset.h Normal file
View File

@ -0,0 +1,69 @@
#pragma once
// -----------------------------------------------------------------------------
// Тайлы пола и стен — по набору на каждую тему отделки.
//
// Живёт в движке, а не в render/sprites.h, ровно по одной причине: этим
// набором рисует и игра, и редактор комнат. Редактор линкуется только с
// движком и про бойцов, тварей и предметы не знает, поэтому тащить ради двух
// спрайтов весь SpriteSet он не может и не должен.
//
// Выбор варианта плиты — чистая функция от координат тайла, без состояния и без
// генератора: пол обязан быть одинаковым в любом прогоне, иначе запечённая
// кровь легла бы после перезапуска на другую плиту (render/blood.h).
// -----------------------------------------------------------------------------
#include <cstdint>
#include "engine/hash.h"
#include "engine/theme.h"
#include "engine/framebuffer.h"
#include "engine/config.h"
class SpriteLibrary;
// Сколько разных «рисунков» может лежать на плите пола: ровная, треснувшая,
// решётка, засыпанная трухой. Шахматка при этом остаётся — вариант ложится
// ПОВЕРХ неё, а не вместо: без чередования двух тонов изометрическая сетка
// перестаёт читаться, и пол превращается в кашу.
constexpr int FLOOR_VARIANTS = 4;
struct TileSet
{
// [тема][чётность шахматки][вариант рисунка].
Sprite floor[THEME_COUNT][2][FLOOR_VARIANTS];
Sprite wall[THEME_COUNT][2]; // industrial / biomass-infested, 32x32
// Процедурный набор. Не зависит ни от одного файла на диске: игра и
// редактор обязаны запускаться на голом репозитории.
void Generate();
// Заменить базовую тему на тайлы из библиотеки SpriteForge и пересобрать
// по ней остальные. Всё или ничего: при любой осечке набор остаётся тем,
// что дал Generate(), и возвращается false. Вызывать только после Generate().
bool LoadFromForge(const SpriteLibrary& lib);
const Sprite& Floor(int tx, int ty, uint8_t theme) const
{
// Раскладка намеренно неравномерная: ровная плита занимает больше
// половины карты. Равные доли превращают пол в пёстрый ковёр, на
// котором не видно ни крови, ни тварей.
static const int kPick[8] = {0, 0, 0, 0, 0, 1, 2, 3};
const uint32_t h = Hash2(uint32_t(tx) * 2654435761u, uint32_t(ty));
return floor[Clamp(theme)][(tx + ty) & 1][kPick[h & 7u]];
}
const Sprite& Wall(int tx, int ty, uint8_t theme) const
{
// Редкие заражённые блоки, детерминированно по координате.
const uint32_t h = Hash2(uint32_t(tx) * 2246822519u, uint32_t(ty));
return wall[Clamp(theme)][(h & 7u) == 0u ? 1 : 0];
}
private:
// Комната может сослаться на тему, которой в этой сборке движка ещё нет.
// Падать из-за этого нельзя: неизвестная тема рисуется базовой.
static int Clamp(uint8_t theme) { return (theme < THEME_COUNT) ? int(theme) : 0; }
// Темы 1..N-1 — перекраска темы 0. Вызывается и после Generate(), и после
// LoadFromForge(): иначе привезённые тайлы остались бы только в базовой.
void BuildThemes();
};

View File

@ -1,35 +1,30 @@
#pragma once
// -----------------------------------------------------------------------------
// Изометрия существует ТОЛЬКО здесь и в рендере. Симуляция, ИИ и коллизии
// работают в декартовом мире (top-down) и об этом файле не знают.
// Слой отрисовки 2D: камера, список отрисовки, сортировка по глубине.
//
// sx = (wx - wy) * 16
// sy = (wx + wy) * 8
// Раньше файл назывался render/iso.h и считал изометрию прямо здесь. Теперь её
// считает engine/projection.h, а тут остаётся то, что от вида не зависит:
// собрать список, отсортировать, вылить в фреймбуфер.
//
// Мир везде декартов (тайл = 1.0 юнита). Симуляция, ИИ и коллизии об этом файле
// не знают.
// -----------------------------------------------------------------------------
#include <algorithm>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
#include "tuning.h"
#include "engine/config.h"
#include "engine/framebuffer.h"
#include "engine/math.h"
#include "engine/projection.h"
namespace iso
namespace v2d
{
inline Vec2 WorldToScreen(Vec2 w)
{
return {(w.x - w.y) * float(TILE_HALF_W), (w.x + w.y) * float(TILE_HALF_H)};
}
inline Vec2 ScreenToWorld(Vec2 s)
{
const float a = s.x / float(TILE_W); // = (wx - wy) / 2
const float b = s.y / float(TILE_H); // = (wx + wy) / 2
return {b + a, b - a};
}
inline Vec2 WorldToScreen(Vec2 w) { return g_proj.WorldToScreen(w); }
inline Vec2 ScreenToWorld(Vec2 s) { return g_proj.ScreenToWorld(s); }
// Ключ сортировки по глубине: чем больше, тем «ближе к камере».
inline float Depth(Vec2 w) { return w.x + w.y; }
inline float Depth(Vec2 w) { return g_proj.Depth(w); }
// Направление в ЭКРАННЫХ осях -> направление в мире (WASD, спек 5).
inline Vec2 ScreenDirToWorld(Vec2 screenDir)
@ -90,4 +85,4 @@ inline void FlushDrawList(Framebuffer& fb, const std::vector<DrawItem>& items)
}
}
} // namespace iso
} // namespace v2d

View File

@ -53,6 +53,10 @@
"typeinfo": "cpp"
},
"git.autoRepositoryDetection": false,
"git.enabled": false
"git.enabled": false,
"makefile.configureOnOpen": true,
"workbench.editorAssociations": {
"*.sprite": "d2rmodding.spriteViewer"
}
}
}

157
samples/walk/main.cpp Normal file
View File

@ -0,0 +1,157 @@
// -----------------------------------------------------------------------------
// Tile2D walk — самое маленькое приложение, которое имеет смысл.
//
// Ни игры, ни редактора: открыть проект, собрать этаж, походить по нему
// камерой. Существует ради одного вопроса — сколько кода нужно, чтобы что-то
// показать на этом движке. Ответ виден целиком на одном экране, и если он
// однажды перестанет туда влезать, это будет значить, что движок оброс тем,
// что должно было остаться в игре.
//
// Проверяет заодно то, что нельзя проверить ни игрой, ни редактором: движок
// собирается и работает БЕЗ них. Игра могла бы незаметно унести к себе что-то
// общее — тайлсет, проекцию, цикл, — и никто бы не заметил, пока не появился
// второй потребитель. Вот он.
//
// tile2d_walk [--project <имя>] [--depth N] [--seed N] [--windowed] [--shot N]
// WASD / стрелки — идти, R — пересобрать этаж, Esc — выход
// -----------------------------------------------------------------------------
#include <raylib.h>
#include <cstdio>
#include <cstring>
#include "engine/app.h"
#include "engine/catalog.h"
#include "engine/level_gen.h"
#include "engine/project.h"
#include "engine/room.h"
#include "engine/tilemap.h"
#include "engine/tilemap_draw.h"
#include "engine/tileset.h"
#include "engine/view2d.h"
int main(int argc, char** argv)
{
const char* projectName = nullptr;
int depth = 1;
uint32_t seed = 1;
bool windowed = false;
int shotFrame = -1; // --shot N: снять кадр и выйти (картинки в документацию)
for (int i = 1; i < argc; ++i)
{
if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) projectName = argv[++i];
else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc) depth = std::atoi(argv[++i]);
else if (std::strcmp(argv[i], "--seed") == 0 && i + 1 < argc)
seed = uint32_t(std::atoi(argv[++i]));
else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true;
else if (std::strcmp(argv[i], "--shot") == 0 && i + 1 < argc)
shotFrame = std::atoi(argv[++i]);
}
// --- проект: пути, вид отображения, кривая глубины -------------------------
Project project;
std::string why;
const std::string dir = FindProjectDir(projectName ? projectName : "");
if (dir.empty() || !project.Load(dir, &why))
{
std::printf("проект не открыт: %s\n", dir.empty() ? "не выбран" : why.c_str());
for (const std::string& n : AvailableProjects()) std::printf(" доступен: %s\n", n.c_str());
return 1;
}
project.Apply(); // ставит проекцию и кривую на движок
EntityCatalog catalog;
RoomLibrary rooms;
if (!catalog.Load(project.catalogPath, &why) || !rooms.Load(project.roomsDir, &why))
{
std::printf("содержимое не читается: %s\n", why.c_str());
return 1;
}
// --- этаж -----------------------------------------------------------------
static Tilemap map;
static GeneratedLevel level;
auto build = [&]() {
if (GenerateLevel(rooms, catalog, DescribeFloor(depth), seed, map, level, &why)) return true;
std::printf("этаж не собрался: %s\n", why.c_str());
return false;
};
if (!build()) return 1;
std::printf("%s: этаж %d (%s), сид %u, комнат %d, тварей %d\n", project.name.c_str(),
level.floor.depth, ThemeName(level.floor.theme), unsigned(seed), level.roomsPlaced,
level.monstersPlaced);
// --- окно и цикл ----------------------------------------------------------
App app;
AppConfig cfg;
cfg.title = "Tile2D walk";
cfg.borderless = !windowed;
if (!app.Init(cfg)) return 1;
TileSet tiles;
tiles.Generate();
v2d::Camera cam;
cam.SnapTo(level.squadStart);
Vec2 pos = level.squadStart;
while (app.NextFrame())
{
// Ввод в ЭКРАННЫХ осях переводится в мировые проекцией — тем же
// способом, что и в игре. Без этого WASD в изометрии ведёт вбок.
Vec2 move{};
if (IsKeyDown(KEY_A) || IsKeyDown(KEY_LEFT)) move.x -= 1.0f;
if (IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT)) move.x += 1.0f;
if (IsKeyDown(KEY_W) || IsKeyDown(KEY_UP)) move.y -= 1.0f;
if (IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN)) move.y += 1.0f;
const int steps = app.Advance(false);
for (int i = 0; i < steps; ++i)
{
if (LengthSq(move) > 0.0f)
{
const Vec2 step = v2d::ScreenDirToWorld(move);
pos = pos + step * (6.0f * FIXED_DT);
map.ResolveCircle(pos, 0.3f); // сквозь стены не ходим
}
}
if (IsKeyPressed(KEY_R))
{
++seed;
if (build()) { pos = level.squadStart; cam.SnapTo(pos); }
}
if (IsKeyPressed(KEY_ESCAPE)) app.quit = true;
cam.Follow(pos, app.frameDt, 9.0f);
int ox = 0, oy = 0;
cam.ScreenOffset(ox, oy);
app.fb.Clear(RGBA(8, 8, 10));
DrawTilemap(app.fb, map, tiles, ox, oy);
// Где стоим, где спуск. Кружком, а не спрайтом: спрайты — дело игры,
// движок про то, как выглядит отряд, не знает ничего.
auto dot = [&](Vec2 w, uint32_t c) {
const Vec2 s = v2d::WorldToScreen(w);
app.fb.CircleFilled(int(s.x) + ox, int(s.y) + oy, 3, c);
};
dot(level.exitPos, RGBA(60, 200, 90));
dot(pos, RGBA(240, 230, 200));
app.Present();
app.Finish();
if (shotFrame >= 0 && --shotFrame <= 0)
{
app.Screenshot("walk.png");
break;
}
}
app.Shutdown();
return 0;
}

View File

@ -37,6 +37,7 @@ bool ConeClearOfAllies(const Agent& shooter, Vec2 muzzle, int shooterIndex, Vec2
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
if (!ally.alive) continue; // упавший больше не перекрывает линию
float t = 0.0f;
const float d = DistancePointToSegment(ally.pos, muzzle, targetPos, &t);
if (d < ally.bodyRadius + half + k * t) return false;
@ -71,6 +72,20 @@ int PredictAllyPositions(const Agent& ally, float t, Vec2 out[2])
} // namespace
// -----------------------------------------------------------------------------
// Приоритет цели: обстановка идёт последней
// -----------------------------------------------------------------------------
bool EnemyNearby(Vec2 from, const SolverContext& ctx)
{
const float r = g_tune.propYieldRadius;
for (const Target& t : *ctx.targets)
{
if (!t.alive || t.IsProp()) continue;
if (Distance(from, t.pos) <= r) return true;
}
return false;
}
// -----------------------------------------------------------------------------
// 6.3 LaneClear
// -----------------------------------------------------------------------------
@ -113,6 +128,7 @@ bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const Sol
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
if (!ally.alive) continue;
const float r = ally.bodyRadius + shooter.gun.def.bulletRadius + g_tune.safetyMargin +
g_tune.shotPredictSlack * MaxAgentSpeed() * t +
spreadK * speed * t;
@ -135,6 +151,7 @@ int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius)
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
if (targets[i].IsProp()) continue; // хлам ближайшей целью не бывает
const float d = Distance(from, targets[i].pos);
if (d < bestD)
{
@ -182,23 +199,36 @@ void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx)
}
// --- авто: ближайшая цель, до которой линия чиста ---
// Два прохода, и порядок между ними — это и есть приоритет. Сначала твари;
// обстановка рассматривается только когда противника в поле зрения нет
// вообще, иначе боец бросал бы врага ради ящика за его спиной.
auto nearestClear = [&](bool wantProps) {
int best = -1;
float bestD = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
if (targets[i].IsProp() != wantProps) continue;
const float d = Distance(a.pos, targets[i].pos);
if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue;
if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue;
best = i;
bestD = d;
}
return best;
};
int best = nearestClear(false);
const bool onProp = (best < 0 && !EnemyNearby(a.pos, ctx));
if (onProp) best = nearestClear(true);
if (best >= 0)
{
a.currentTargetId = best;
a.hasLane = true;
a.priorityLane = (ctx.assignedTargetId < 0);
// Линия к хламу приоритетной не бывает: приоритет запрещает решателю
// искать позицию получше, а ради ящика боец переступать обязан.
a.priorityLane = (ctx.assignedTargetId < 0) && !onProp;
a.laneEnd = targets[best].pos;
return;
}
@ -238,6 +268,9 @@ float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext&
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
// Покрытие считается по ПРОТИВНИКАМ. Позиция, с которой видно три
// ящика, огневой не является, а «слишком близко» к бочке — не штраф.
if (targets[i].IsProp()) continue;
++total;
nearestDist = std::min(nearestDist, Distance(cand, targets[i].pos));
if (LaneClearFrom(cand, index, targets[i].pos, ctx)) ++shootable;
@ -302,6 +335,7 @@ bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveRes
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
if (!ctx.agents[k].alive) continue;
if (Distance(p, ctx.agents[k].pos) < g_tune.candMinAlly)
{
out.valid[i] = false;

View File

@ -11,7 +11,7 @@
#include "ai/lane_registry.h"
#include "sim/agent.h"
#include "sim/target.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
struct SolverContext
{
@ -25,6 +25,20 @@ struct SolverContext
int assignedTargetId = -1; // ручной режим (6.8); -1 = авто
};
// --- приоритет цели ----------------------------------------------------------
// Есть ли живая ТВАРЬ (не обстановка) в радиусе propYieldRadius от точки.
//
// Правило приоритета разрушаемой обстановки, одно на оба класса оружия: пока
// такая тварь есть, ящик, бочка и шкафчик целью не становятся ни у стрелка
// (firing_solver.cpp), ни у клинка (melee.cpp). Один общий предикат тут не
// украшение — разъехавшись, стрелок и клинок начали бы спорить, что важнее.
//
// Радиус СВОЙ, а не дальность ствола: с дальностью правило читалось бы как
// «не достаю — стреляю по ящику», и подошедшую тварь боец встречал бы на
// перезарядке. Стены не спасают намеренно: тварь за углом в пяти шагах — всё
// ещё повод не заниматься мебелью.
bool EnemyNearby(Vec2 from, const SolverContext& ctx);
// --- 6.3 ---------------------------------------------------------------------
// Линия от дула стрелка (в точке from, ствол смотрит на цель) до targetPos.
// Порядок проверок как в спеке: свои -> геометрия -> дистанция.

View File

@ -8,7 +8,7 @@
//
// Модуль ai/ не знает ни про рендер, ни про изометрию.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "engine/math.h"
#include "tuning.h"
struct FireLane

View File

@ -64,9 +64,14 @@ void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
}
else
{
// Обстановка — только когда вокруг чисто. Предикат общий со стрелком
// (EnemyNearby): ящик за спиной не уводит с боя ни клинок, ни ствол.
const bool wantProps = !EnemyNearby(a.pos, ctx);
float bestCost = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (targets[i].IsProp() != wantProps) continue;
if (!MeleeCandidate(a, ctx, i)) continue;
// Цена = дистанция + штраф за каждого своего, уже вцепившегося
@ -76,6 +81,7 @@ void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
if (!ctx.agents[k].alive) continue;
if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i)
cost += g_tune.meleeClaimPenalty;
}
@ -124,6 +130,7 @@ bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverConte
{
if (i == index) continue;
const Agent& ally = ctx.agents[i];
if (!ally.alive) continue; // упавший под лезвие не попадает
const Vec2 probe[2] = {ally.pos, ally.pos + ally.vel * horizon};
for (const Vec2& q : probe)

View File

@ -4,7 +4,7 @@
// в headless-прогонах заполняется скриптом. Отладочные клавиши (F1..F4, тюнинг)
// сюда не попадают — они не влияют на симуляцию.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "engine/math.h"
struct InputState
{

View File

@ -1,6 +1,7 @@
#include "debug/harness.h"
#include <chrono>
#include <filesystem>
#include <cstdarg>
#include <cstdio>
@ -8,7 +9,7 @@
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "render/iso.h"
#include "engine/view2d.h"
namespace
{
@ -58,7 +59,7 @@ constexpr float kWaypointTimeout = 20.0f;
// Мировое направление -> экранные оси: ровно то, что делает игрок клавишами.
Vec2 MoveInputToward(const Game& g, Vec2 goal)
{
return Normalized(iso::WorldToScreen(Normalized(goal - g.squad.anchor)));
return Normalized(v2d::WorldToScreen(Normalized(goal - g.squad.anchor)));
}
// -----------------------------------------------------------------------------
@ -601,3 +602,368 @@ int RunAccept()
std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL");
return allPass ? 0 : 1;
}
// -----------------------------------------------------------------------------
// --descend N: забег на N этажей без окна.
//
// Проверяет ровно то, что нельзя проверить ни скриншотом, ни `--check`:
// цепочку «спуск -> пересборка мира -> перенос отряда». Ошибка здесь не
// падает и не рисуется — она выглядит как отряд, приехавший вниз с пустыми
// руками, и заметить её на глаз почти невозможно.
//
// Симуляция не шагает: здесь важна сборка, а не бой.
// -----------------------------------------------------------------------------
int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors)
{
// Свой профиль в памяти, не с диска: прогон не имеет права трогать чужой
// схрон, а без профиля не проверить ни эвакуацию, ни чекпоинты.
Profile profile;
profile.ResetLoadouts();
Game game;
game.combatMode = true;
game.rooms = &rooms;
game.catalog = &catalog;
game.profile = &profile;
game.levelSeed = seed;
game.Init();
std::printf("%-6s %-7s %7s %6s %5s %6s %s\n", "DEPTH", "THEME", "MONSTER", "DANGER", "LOOT",
"EXIT", "SQUAD");
int bad = 0;
for (int i = 0; i < floors; ++i)
{
const GeneratedLevel& l = game.level;
if (!game.levelGenerated)
{
std::printf("[FAIL] этаж %d не собрался\n", game.depth);
return 1;
}
// Сумки считаются каждый этаж: именно они переезжают вниз, и именно
// их пропажа была бы тихой. Вместе с ними — НАГРУЗКА: без неё не видно,
// связывает вес хоть что-то или он остался числом в каталоге.
const int bags = game.squad.bag.Count();
float maxLoad = 0.0f;
int overloaded = 0;
for (int a = 0; a < NUM_AGENTS; ++a)
{
const float load = game.squad.agents[a].load;
if (load > maxLoad) maxLoad = load;
if (load > CARRY_LIMIT) ++overloaded;
}
// Патроны — главный ресурс вылазки, и в прогоне он обязан стоять
// столбцом: «дошли до восьмого» само по себе не говорит, было ли чем
// стрелять на восьмом.
int ammoLeft = 0;
for (int a = 0; a < NUM_AGENTS; ++a)
ammoLeft += std::max(0, game.squad.agents[a].ammoReserve);
std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f"
" (перегруз %d), патронов %d\n",
l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced,
double(l.dangerSpent), int(l.loot.size()), l.exitDistance,
game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft);
if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; }
if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0)
{
std::printf(" [FAIL] срезано %d, недостижимо %d\n", l.spawnsDropped,
l.spawnsUnreachable);
++bad;
}
// Кладём каждому в сумку по предмету перед спуском: если перенос
// сломан, на следующем этаже сумки окажутся пустыми, и это видно
// в столбце прямо здесь.
// Кладём в ОБЩУЮ сумку по предмету на бойца перед спуском: если
// перенос сломан, на следующем этаже сумка окажется пустой.
for (int a = 0; a < NUM_AGENTS; ++a)
game.squad.bag.Take(ItemId::BANDAGE, game.squad.Carriers(), 0.0f);
if (i + 1 < floors) game.Descend();
}
// Сколько довезли до низа — считается ДО опытов ниже: они меняют содержимое
// сумки, и посчитав после, проверка «доехало ли нажитое» сравнивала бы
// сумку с собственноручно разложенной сумкой, то есть проходила бы всегда.
const int bagsBefore = game.squad.bag.Count();
// -------------------------------------------------------------------------
// Расходники. Проверяется правило, а не побочные цифры прогона: спуск ни с
// кем не воюет, поэтому патроны сами по себе не кончатся, и «столбец не
// изменился» не значит ничего.
//
// Условия ставятся руками: у бойца пусто в стволе и в запасе, в сумке пачка
// патронов и бинт, здоровье срезано, живых тварей рядом нет. Дальше — шаг
// симуляции, и оба расходника обязаны сработать сами.
// -------------------------------------------------------------------------
{
Agent& a = game.squad.agents[0];
Inventory* inv = game.world.Get<Inventory>(a.self);
SquadBag& bag = game.squad.bag;
// Опыт разбирает сумку под себя, а после него проверяется эвакуация —
// то есть ровно то, что он разобрал. Инструмент обязан вернуть мир
// как было, иначе следующая проверка меряет его следы.
const SquadBag savedBag = bag;
const float savedHp = a.hp;
if (!inv)
{
std::printf("\n [FAIL] у бойца нет инвентаря — расходники не проверить\n");
++bad;
}
else
{
// Лечиться можно только вне схватки, поэтому поле зачищается.
for (Target& t : game.targets) t.alive = false;
bag.Clear();
bag.slots[0] = ItemId::AMMO;
bag.slots[1] = ItemId::BANDAGE;
// Ствол в сумке обязателен: патроны нужны тому, у кого есть из чего
// стрелять, а в руках у этого бойца клинок. Без него проверка
// мерила бы не правило, а свою же расчищенную сумку.
bag.slots[2] = ItemId::RIFLE;
a.alive = true;
a.hp = a.maxHp * 0.3f;
a.ammoReserve = 0;
a.gun.ammo = 0;
a.gun.reloadTimer = 0.0f;
const float hpBefore = a.hp;
for (int k = 0; k < 8; ++k) game.Step(FIXED_DT);
const bool loaded = a.ammoReserve >= AMMO_BOX_ROUNDS;
const bool healed = a.hp > hpBefore;
const bool spent = bag.slots[0] == ItemId::NONE && bag.slots[1] == ItemId::NONE;
std::printf("\nрасходники: патроны %s, лечение %s (%.0f -> %.0f), из сумки ушли %s\n",
loaded ? "набиты" : "НЕ набиты", healed ? "сработало" : "НЕ сработало",
double(hpBefore), double(a.hp), spent ? "обе" : "не все");
if (!loaded)
{
std::printf(" [FAIL] пачка патронов не пошла в запас — стрелять нечем\n");
++bad;
}
if (!healed)
{
std::printf(" [FAIL] бинт не применился: расходники снова мёртвый груз\n");
++bad;
}
if (!spent)
{
std::printf(" [FAIL] потраченное осталось в сумке\n");
++bad;
}
// И главное правило: перезарядка тянет ИЗ ЗАПАСА. Прогон спуска ни
// с кем не воюет, поэтому столбец «патронов» в таблице выше стоит
// на месте — и без этой проверки бесконечный боезапас выглядел бы
// ровно так же.
EquipWeapon(game.world, a, ItemId::RIFLE);
game.Step(FIXED_DT); // SyncLoadout переносит ствол на бойца
const int reserveBefore = a.ammoReserve;
a.gun.ammo = 0;
for (int k = 0; k < 200 && a.ammoReserve == reserveBefore; ++k) game.Step(FIXED_DT);
const int spentRounds = reserveBefore - a.ammoReserve;
std::printf("перезарядка: запас %d -> %d (магазин %d)\n", reserveBefore,
a.ammoReserve, a.gun.def.magazine);
if (spentRounds <= 0)
{
std::printf(" [FAIL] магазин набился ниоткуда — боезапас бесконечен\n");
++bad;
}
// Мир возвращается как был: дальше проверяется эвакуация, то есть
// ровно то содержимое сумки, которое опыт только что разобрал.
bag = savedBag;
a.hp = savedHp;
}
}
// -------------------------------------------------------------------------
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
// и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой
// цифре — верный способ выставить пороги, которые ничего не ограничивают.
// -------------------------------------------------------------------------
{
// Один боец несёт ствол и общую сумку целиком: доля на одного — это
// худший случай, тот самый, в котором отряд остался вдвоём или один.
const float gun = ItemDefOf(ItemId::RIFLE).weight;
SquadBag probe;
// Три находки среднего веса — отряд, который взял нужное и ушёл.
probe.Take(ItemId::SABER, 1, gun);
probe.Take(ItemId::LANTERN, 1, gun);
probe.Take(ItemId::VEST, 1, gun);
const float light = gun + probe.Load();
// Добираем до отказа: ячейки кончатся или кончится переноска.
int added = 0;
while (probe.Take(ItemId::PLATE, 1, gun)) ++added;
const float heavy = gun + probe.Load();
const bool freeAtLight = CarryMul(light) > 0.999f;
const bool slowAtHeavy = CarryMul(heavy) < 0.95f;
const bool refused = probe.FreeSlot() >= 0; // отказ по ВЕСУ, а не по ячейкам
std::printf("\nпереноска: налегке %.1f (скорость %.0f%%), с грузом %.1f "
"(скорость %.0f%%), плит влезло %d\n",
double(light), double(CarryMul(light) * 100.0f), double(heavy),
double(CarryMul(heavy) * 100.0f), added);
if (!freeAtLight)
{
std::printf(" [FAIL] обычное снаряжение уже тормозит отряд\n");
++bad;
}
if (!slowAtHeavy)
{
std::printf(" [FAIL] полная сумка не стоит ничего — вес не связывает\n");
++bad;
}
if (!refused)
{
std::printf(" [FAIL] предел переноски не сработал: остановили ячейки, не вес\n");
++bad;
}
}
// Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не
// значит: вся суть экстракшена в том, что происходит на выходе.
game.Extract();
const RunReport& r = game.report;
std::printf("\nэвакуация: вынесено %d, потеряно %d, выживших %d, чекпоинт D%d\n",
r.itemsBrought, r.itemsLost, r.survivors, r.checkpoint);
if (r.outcome != RunOutcome::EXTRACTED)
{
std::printf(" [FAIL] исход не эвакуация\n");
++bad;
}
// Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому
// проверяем неравенством: меньше содержимого сумок быть не может.
if (r.itemsBrought < bagsBefore)
{
std::printf(" [FAIL] в сумках было %d, до профиля доехало %d\n", bagsBefore,
r.itemsBrought);
++bad;
}
if (profile.maxDepth < game.depth)
{
std::printf(" [FAIL] глубина %d не записалась в профиль (%d)\n", game.depth,
profile.maxDepth);
++bad;
}
// Круговой прогон профиля через ЗАПИСЬ. Здесь он не для полноты: запись
// профиля — единственный путь, которым игра может стереть всё нажитое, и
// ошибка в нём выглядит как «схрон куда-то делся», а не как падение.
{
const std::string tmp =
(std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string();
std::string why;
Profile back;
if (!profile.Save(tmp, &why))
{
std::printf(" [FAIL] профиль не пишется: %s\n", why.c_str());
++bad;
}
else if (!back.Load(tmp, &why))
{
std::printf(" [FAIL] профиль не читается обратно: %s\n", why.c_str());
++bad;
}
else
{
int diff = 0;
if (back.maxDepth != profile.maxDepth) ++diff;
if (back.StashCount() != profile.StashCount()) ++diff;
for (int a = 0; a < NUM_AGENTS; ++a)
{
const Inventory& x = profile.loadout[a];
const Inventory& y = back.loadout[a];
if (x.weapon != y.weapon || x.armor != y.armor || x.trinket != y.trinket) ++diff;
}
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
else std::printf("профиль: запись и чтение сходятся\n");
}
std::error_code ec;
std::filesystem::remove(tmp, ec);
}
// -------------------------------------------------------------------------
// Итог забега снимается ДО проверки улучшений: та начинает новую вылазку и
// обнуляет счётчик этажей. Печатать его после значило бы отчитаться
// цифрой, которую испортил измеряющий.
const int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp;
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
//
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
// сломана, а потому что её сломал измеряющий.
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
// влияет, выглядит точно так же, как работающее улучшение.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const float hpBefore = game.squad.agents[0].maxHp;
const int ammoBefore = int(g_tune.startAmmo);
pr.bankedXp = 10000.0f;
const bool boughtAmmo = pr.Buy(Upgrade::AMMO);
const bool boughtVigor = pr.Buy(Upgrade::VIGOR);
const float leftXp = pr.bankedXp;
// Улучшения раздаются вместе со снаряжением, то есть на новой вылазке.
game.NewRun(1);
game.Step(FIXED_DT);
const float hpAfter = game.squad.agents[0].maxHp;
const int ammoAfter = game.squad.agents[0].ammoReserve;
std::printf("\nулучшения: здоровье %.0f -> %.0f, патроны %d -> %d, "
"опыта осталось %.0f\n",
double(hpBefore), double(hpAfter), ammoBefore, ammoAfter, double(leftXp));
if (!boughtAmmo || !boughtVigor)
{
std::printf(" [FAIL] покупка не прошла при полном кошельке\n");
++bad;
}
if (hpAfter <= hpBefore)
{
std::printf(" [FAIL] живучесть куплена, но здоровье не выросло\n");
++bad;
}
if (ammoAfter <= ammoBefore)
{
std::printf(" [FAIL] запас куплен, но патронов не прибавилось\n");
++bad;
}
if (leftXp >= 10000.0f)
{
std::printf(" [FAIL] опыт не списался: покупка бесплатная\n");
++bad;
}
}
std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad);
return bad == 0 ? 0 : 1;
}

View File

@ -8,6 +8,14 @@
// -----------------------------------------------------------------------------
#include "tuning.h"
// --descend N: пройти N этажей подряд без окна и напечатать, во что
// превращается забег. Проверяет ровно то, что нельзя проверить скриншотом:
// цепочку Descend -> пересборка мира -> перенос отряда. Симуляция при этом
// не шагает — здесь важна сборка, а не бой.
#include "engine/level_gen.h"
int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors);
struct HarnessOptions
{
float seconds = 60.0f;

View File

@ -46,6 +46,9 @@ struct Metrics
// баг «бьёт на 360».
float meleeMaxHitExcessDeg = 0.0f;
int reloads = 0; // сколько раз стрелки уходили в перезарядку
// Сколько расходников потрачено за прогон. Ноль при раненом отряде и полных
// сумках означает, что расходники снова стали мёртвым грузом.
int itemsUsed = 0;
FriendlyHitEvent hitLog[HIT_LOG_MAX];
int hitLogCount = 0;

View File

@ -8,21 +8,80 @@
namespace
{
// Мир -> координаты окна (кадр уже растянут в WINDOW_SCALE раз).
inline Vector2 ToWindow(const View& v, Vec2 world)
// -----------------------------------------------------------------------------
// Сетка оверлея.
//
// Кадр 480x270 растянут целым множителем и центрирован в окне, поэтому все
// размеры ниже заданы в пикселях КАДРА и переводятся в окно через Layout.
// Считать напрямую в пикселях окна нельзя: разметка сразу разъезжается
// с картинкой, потому что множитель зависит от монитора.
// -----------------------------------------------------------------------------
struct Layout
{
const Vec2 s = iso::WorldToScreen(world);
return Vector2{(s.x + float(v.scene.ox)) * float(WINDOW_SCALE),
(s.y + float(v.scene.oy)) * float(WINDOW_SCALE)};
float x = 0.0f, y = 0.0f; // левый верхний угол кадра в окне
float u = 1.0f; // размер пикселя кадра в пикселях окна
int fw = 0, fh = 0; // размер кадра в его собственных пикселях
Vector2 At(float fx, float fy) const { return Vector2{x + fx * u, y + fy * u}; }
float Px(float frame) const { return frame * u; }
};
Layout Of(const ScreenRect& fit)
{
const int s = (fit.scale > 0) ? fit.scale : 1;
Layout l;
l.x = float(fit.x);
l.y = float(fit.y);
l.u = float(s);
l.fw = fit.w / s;
l.fh = fit.h / s;
return l;
}
void Ring(const View& g, Vec2 center, float radiusWorld, Color c, int segments = 48)
// Радиусы и толщины — в пикселях кадра. На апскейле x3 они дают ту же картинку,
// что была у прежних «магических» значений в пикселях окна.
constexpr float R_ANCHOR = 1.5f; // точка якоря
constexpr float R_SLOT = 2.0f; // слот строя
constexpr float R_POST = 1.5f; // выбранная огневая позиция
constexpr float R_CAND = 1.0f; // кандидат решателя
constexpr float R_CAND_ALL = 0.7f; // отбракованный кандидат
constexpr float R_BEST = 3.0f; // выбранный кандидат
constexpr float R_TARGET = 4.0f; // назначенная цель
constexpr float LINE_THIN = 0.35f;
constexpr float LINE_BOLD = 0.7f;
constexpr int LABEL_SIZE = 4; // подпись над бойцом
constexpr float LABEL_DX = -6.0f;
constexpr float LABEL_DY = -21.0f;
constexpr float PANEL_X = 3.0f; // текстовая панель в левом верхнем углу
constexpr float PANEL_Y = 3.0f;
constexpr float PANEL_STEP = 7.0f;
constexpr int PANEL_SIZE = 6;
constexpr int HINT_SIZE = 5; // строка подсказки
constexpr float HINT_BOTTOM = 8.0f; // отступ от нижней кромки КАДРА
void Text(const Layout& l, const char* s, float fx, float fy, int frameSize, Color c)
{
DrawText(s, int(l.x + fx * l.u), int(l.y + fy * l.u), int(float(frameSize) * l.u), c);
}
// Мир -> окно: изометрия даёт пиксели кадра, Layout доводит их до окна.
inline Vector2 ToWindow(const Layout& l, const View& v, Vec2 world)
{
const Vec2 s = v2d::WorldToScreen(world);
return l.At(s.x + float(v.scene.ox), s.y + float(v.scene.oy));
}
void Ring(const Layout& l, const View& g, Vec2 center, float radiusWorld, Color c,
int segments = 48)
{
Vector2 prev{};
for (int i = 0; i <= segments; ++i)
{
const float a = float(i) * (TWO_PI_F / float(segments));
const Vector2 p = ToWindow(g, center + FromAngle(a) * radiusWorld);
const Vector2 p = ToWindow(l, g, center + FromAngle(a) * radiusWorld);
if (i > 0) DrawLineV(prev, p, c);
prev = p;
}
@ -70,30 +129,31 @@ void Overlay::HandleKeys()
value = Clampf(value + p.step, p.minV, p.maxV);
}
void Overlay::Draw(const Game& game, const View& view) const
void Overlay::Draw(const Game& game, const View& view, const ScreenRect& fit) const
{
if (!show) return;
const Layout l = Of(fit);
const Squad& squad = game.squad;
// --- якорь, круги когезии, слоты строя ---
Ring(view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160});
Ring(l, view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160});
// Свой, больший круг бойцов ближнего боя — их отпускают дальше.
if (squad.meleeCount > 0)
Ring(view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110});
Ring(l, view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110});
// Поводок курсора, обе границы упругой модели: сплошной круг — мягкая
// граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел.
// По зазору между ними видно натяжение, то есть скоро ли отряд побежит.
Ring(view, squad.Centroid(), g_tune.anchorLeash,
Ring(l, view, squad.Centroid(), g_tune.anchorLeash,
squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110});
Ring(view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax),
Ring(l, view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax),
Color{90, 70, 110, 70});
DrawCircleV(ToWindow(view, squad.anchor), 4.0f,
DrawCircleV(ToWindow(l, view, squad.anchor), l.Px(R_ANCHOR),
squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220});
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Vector2 p = ToWindow(view, squad.SlotWorld(i));
DrawCircleLinesV(p, 5.0f, Color{80, 120, 160, 140});
const Vector2 p = ToWindow(l, view, squad.SlotWorld(i));
DrawCircleLinesV(p, l.Px(R_SLOT), Color{80, 120, 160, 140});
}
// --- сектор ближнего боя ---
@ -116,19 +176,19 @@ void Overlay::Draw(const Game& game, const View& view) const
break;
}
Ring(view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24);
Ring(l, view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24);
const float axis = m.Committed() ? m.dir : a.facing;
const float half = m.HalfArc();
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = a.pos + FromAngle(axis + half * float(s)) * m.reach;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), c);
DrawLineV(ToWindow(l, view, a.pos), ToWindow(l, view, e), c);
}
if (m.state == MeleeState::STRIKE)
{
const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach;
DrawLineEx(ToWindow(view, a.pos), ToWindow(view, blade), 2.0f, c);
DrawLineEx(ToWindow(l, view, a.pos), ToWindow(l, view, blade), l.Px(LINE_BOLD), c);
}
}
@ -146,7 +206,8 @@ void Overlay::Draw(const Game& game, const View& view) const
else c = Color{240, 210, 60, 200}; // перекрыта своим
const Vec2 end = a.laneEnd;
DrawLineEx(ToWindow(view, a.MuzzleTowards(end)), ToWindow(view, end), 2.0f, c);
DrawLineEx(ToWindow(l, view, a.MuzzleTowards(end)), ToWindow(l, view, end),
l.Px(LINE_BOLD), c);
}
}
@ -169,18 +230,18 @@ void Overlay::Draw(const Game& game, const View& view) const
for (int k = 0; k < r.count; ++k)
{
const Vector2 p = ToWindow(view, r.candidates[k]);
const Vector2 p = ToWindow(l, view, r.candidates[k]);
if (!r.valid[k])
{
DrawCircleV(p, 2.0f, Color{80, 40, 40, 140}); // отбракован
DrawCircleV(p, l.Px(R_CAND_ALL), Color{80, 40, 40, 140}); // отбракован
continue;
}
const float t = (r.scores[k] - lo) / span; // синий -> белый
const unsigned char v = (unsigned char)(60 + 195 * t);
DrawCircleV(p, 3.0f, Color{v, v, 255, 200});
DrawCircleV(p, l.Px(R_CAND), Color{v, v, 255, 200});
}
if (r.bestIndex > 0)
DrawCircleLinesV(ToWindow(view, r.candidates[r.bestIndex]), 8.0f,
DrawCircleLinesV(ToWindow(l, view, r.candidates[r.bestIndex]), l.Px(R_BEST),
Color{255, 255, 255, 230});
}
}
@ -191,15 +252,16 @@ void Overlay::Draw(const Game& game, const View& view) const
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
Ring(view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70});
Ring(view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110});
Ring(l, view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70});
Ring(l, view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110});
// Границы конуса взгляда.
const float half = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
for (int sgn = -1; sgn <= 1; sgn += 2)
{
const Vec2 e = a.pos + FromAngle(a.facing + half * float(sgn)) * g_tune.sightRadius;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), Color{90, 200, 140, 110});
DrawLineV(ToWindow(l, view, a.pos), ToWindow(l, view, e),
Color{90, 200, 140, 110});
}
}
}
@ -208,11 +270,14 @@ void Overlay::Draw(const Game& game, const View& view) const
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Vector2 p = ToWindow(view, a.pos);
const Vec2 s = v2d::WorldToScreen(a.pos);
const float fx = s.x + float(view.scene.ox) + LABEL_DX;
const float fy = s.y + float(view.scene.oy) + LABEL_DY;
if (a.IsMelee())
DrawText(TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H',
Text(l, TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, MeleePhase(a.melee.state)),
int(p.x) - 18, int(p.y) - 62, 12, Color{255, 210, 170, 210});
fx, fy, LABEL_SIZE, Color{255, 210, 170, 210});
else
{
// Патроны и перезарядка — то, чего не видно по силуэту, но от чего
@ -221,20 +286,21 @@ void Overlay::Draw(const Game& game, const View& view) const
const Color col = a.gun.Reloading() ? Color{255, 170, 90, 230}
: Color{220, 220, 220, 210};
if (a.gun.Reloading())
DrawText(TextFormat("%d%s %c t%d RELOAD %.1f", i, tag,
Text(l, TextFormat("%d%s %c t%d RELOAD %.1f", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, double(a.gun.reloadTimer)),
int(p.x) - 18, int(p.y) - 62, 12, col);
fx, fy, LABEL_SIZE, col);
else
DrawText(TextFormat("%d%s %c t%d %d/%d", i, tag,
Text(l, TextFormat("%d%s %c t%d %d/%d", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, a.gun.ammo, a.gun.def.magazine),
int(p.x) - 18, int(p.y) - 62, 12, col);
fx, fy, LABEL_SIZE, col);
}
// выбранная огневая позиция
if (a.state == AgentState::HOLDING || a.IsMelee())
DrawCircleLinesV(ToWindow(view, a.postPos), 4.0f, Color{160, 160, 255, 140});
DrawCircleLinesV(ToWindow(l, view, a.postPos), l.Px(R_POST),
Color{160, 160, 255, 140});
}
// --- назначенная цель / вектор прицела (6.8) ---
@ -242,24 +308,26 @@ void Overlay::Draw(const Game& game, const View& view) const
{
const Vec2 from = squad.anchor;
const Vec2 to = from + FromAngle(game.aim.dir) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, to), 1.0f, Color{255, 255, 255, 90});
DrawLineEx(ToWindow(l, view, from), ToWindow(l, view, to), l.Px(LINE_THIN),
Color{255, 255, 255, 90});
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = from + FromAngle(game.aim.dir + cone * float(s)) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, e), 1.0f, Color{255, 255, 255, 50});
DrawLineEx(ToWindow(l, view, from), ToWindow(l, view, e), l.Px(LINE_THIN),
Color{255, 255, 255, 50});
}
if (game.aim.assignedTargetId >= 0)
DrawCircleLinesV(ToWindow(view, game.targets[game.aim.assignedTargetId].pos), 12.0f,
Color{255, 120, 120, 230});
DrawCircleLinesV(ToWindow(l, view, game.targets[game.aim.assignedTargetId].pos),
l.Px(R_TARGET), Color{255, 120, 120, 230});
}
// --- текстовая панель ---
int y = 8;
float ty = PANEL_Y;
auto line = [&](const char* text, Color c = RAYWHITE) {
DrawText(text, 8, y, 18, c);
y += 20;
Text(l, text, PANEL_X, ty, PANEL_SIZE, c);
ty += PANEL_STEP;
};
line(TextFormat("%s %s aim:%s fps %d", StateName(squad.state),
@ -302,6 +370,6 @@ void Overlay::Draw(const Game& game, const View& view) const
double(g_tune.*(p.member))),
Color{255, 210, 120, 255});
DrawText("F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", 8,
WINDOW_H - 24, 16, Color{160, 160, 160, 200});
Text(l, "F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc",
PANEL_X, float(l.fh) - HINT_BOTTOM, HINT_SIZE, Color{160, 160, 160, 200});
}

View File

@ -3,7 +3,14 @@
// Debug-оверлей (спек 7). Рисуется примитивами raylib ПОВЕРХ уже
// отмасштабированного кадра — это единственное исключение из правила
// «вся сцена софтверная», и оно dev-only (F1 выключает).
//
// Кадр лежит в окне со сдвигом и целым масштабом (ScreenFitRect), поэтому
// оверлей обязан получать ScreenRect и считать ВСЕ координаты через него.
// Рисовать по координатам окна нельзя: на любом мониторе, где кадр не занимает
// окно целиком, разметка уезжает относительно картинки.
// -----------------------------------------------------------------------------
#include "engine/framebuffer.h"
struct Game;
struct View;
@ -16,5 +23,5 @@ struct Overlay
int tuneIndex = 0; // [ / ]
void HandleKeys();
void Draw(const Game& game, const View& view) const;
void Draw(const Game& game, const View& view, const ScreenRect& fit) const;
};

View File

@ -2,7 +2,9 @@
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "render/iso.h"
#include "engine/view2d.h"
#include "sim/enemy.h"
#include "sim/spawn_catalog.h"
namespace
{
@ -19,7 +21,8 @@ void ClearBullets(ecs::World& world)
Game::Game()
: targets(world.Store<Target>().data),
bullets(world.Store<Bullet>().data)
bullets(world.Store<Bullet>().data),
floorItems(world.Store<FloorItem>().data)
{
}
@ -30,11 +33,296 @@ void Game::Init()
}
void Game::Reset()
{
BuildFloor(nullptr);
xp = 0.0f;
floorsCleared = 0;
lootTaken = 0;
}
void Game::NewRun(int startDepth)
{
// Новый забег — новый сид. Сид крутится тем же линейным конгруэнтным шагом,
// что и кнопка SEED в редакторе: «следующий» обязано быть одним и тем же
// понятием в обоих приложениях, иначе сид, подобранный в редакторе, в игре
// не повторить.
levelSeed = levelSeed * 1664525u + 1013904223u;
// Начать глубже открытого чекпоинта нельзя: иначе достигнутая глубина
// перестаёт что-либо значить, а вместе с ней и весь риск.
depth = std::max(1, startDepth);
if (profile) depth = std::min(depth, profile->DeepestCheckpoint());
report = RunReport{};
if (profile) ++profile->runs;
Reset();
ApplyProfileLoadout();
}
void Game::Extract()
{
// Унести можно только то, что на ВЫЖИВШИХ. Павший забирает своё с собой —
// в этом вся ставка: не «доживи», а «доживи, имея что вынести».
HarvestToProfile(true);
FinishRun(RunOutcome::EXTRACTED);
}
void Game::WipeRun()
{
// Ничего не уносится. Снаряжение сбрасывается к стартовому, но схрон и
// чекпоинты остаются: потерять вылазку — не то же самое, что потерять
// всё нажитое, и второе прекращало бы игру, а не наказывало.
if (profile) profile->ResetLoadouts();
FinishRun(RunOutcome::WIPED);
}
void Game::FinishRun(RunOutcome outcome)
{
report.outcome = outcome;
report.depth = depth;
report.deepest = std::max(report.deepest, depth);
report.survivors = AliveAgents();
report.xp = xp;
if (!profile) { report.checkpoint = 1; return; }
if (outcome == RunOutcome::EXTRACTED)
{
++profile->extractions;
profile->bankedXp += xp;
}
else
{
++profile->wipes;
}
// Рекорд глубины засчитывается ЛЮБЫМ исходом. Дошёл — значит дошёл;
// требовать за чекпоинт ещё и удачную эвакуацию значило бы наказывать
// дважды за одну смерть.
report.newRecord = report.deepest > profile->maxDepth;
profile->maxDepth = std::max(profile->maxDepth, report.deepest);
report.checkpoint = profile->DeepestCheckpoint();
}
void Game::HarvestToProfile(bool onlySurvivors)
{
if (!profile) return;
report.itemsBrought = 0;
report.itemsLost = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
auto countCarried = [&](const Inventory& v) {
int n = 0;
if (v.weapon != ItemId::NONE) ++n;
if (v.armor != ItemId::NONE) ++n;
if (v.trinket != ItemId::NONE) ++n;
return n;
};
if (onlySurvivors && !a.alive)
{
report.itemsLost += countCarried(*inv);
// Слот павшего получает стартовый набор: следующая вылазка идёт
// впятером, и пустые руки одного из них — это не сложность,
// а неиграбельность.
profile->loadout[i] = Inventory{};
continue;
}
profile->loadout[i] = *inv;
report.itemsBrought += countCarried(*inv);
}
// Общая сумка уходит в схрон целиком, если из забега вообще кто-то вышел.
//
// Правило «унести можно только то, что на выживших» этим не ослаблено, оно
// стало точнее: НАДЕТОЕ павшего теряется по-прежнему, а общая ноша на нём
// не висела — её тащил отряд. Вайп по-прежнему не выносит ничего: тогда
// выживших нет вовсе, и сумку некому донести.
const bool anySurvivor = AliveAgents() > 0;
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{
const ItemId id = squad.bag.slots[s];
if (id == ItemId::NONE) continue;
if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought;
else ++report.itemsLost;
}
squad.bag.Clear();
// Пустой слот павшего добивается стартовым комплектом. Делается через
// ResetLoadouts только для тех, у кого ничего нет: иначе выжившие
// потеряли бы найденное.
Profile fresh;
fresh.ResetLoadouts();
for (int i = 0; i < NUM_AGENTS; ++i)
if (profile->loadout[i].weapon == ItemId::NONE) profile->loadout[i] = fresh.loadout[i];
}
void Game::ApplyProfileLoadout()
{
if (!profile) return;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
*inv = profile->loadout[i];
// Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого
// боец выходит с карабином по умолчанию, что бы ни лежало в профиле.
EquipWeapon(world, a, inv->weapon);
// Запас здоровья зависит от надетого (броня, оберег), и посчитан он
// будет только на первом шаге. Без явной синхронизации боец выходит
// на этаж с прежними ста единицами при полутора сотнях максимума —
// то есть уже раненым, ничего не сделав.
SyncLoadout(world, a);
a.hp = a.maxHp;
}
}
void Game::Descend()
{
// Отряд переезжает КАК ЕСТЬ: здоровье, оружие и сумки. В этом весь забег —
// спуск обязан ощущаться как продолжение, а не как новый заход.
SquadCarry carry;
SnapshotSquad(carry);
++depth;
++floorsCleared;
report.deepest = std::max(report.deepest, depth);
BuildFloor(&carry);
}
// -----------------------------------------------------------------------------
// Что переезжает между этажами. Отдельная структура, а не «не трогать мир»:
// Reset пересоздаёт весь ecs::World, и по-другому сохранить бойцов нельзя без
// того, чтобы половина систем начала различать «новый мир» и «тот же мир».
// -----------------------------------------------------------------------------
void Game::SnapshotSquad(SquadCarry& out) const
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
out.alive[i] = a.alive;
out.hp[i] = a.hp;
out.ammo[i] = a.ammoReserve;
if (const Inventory* inv = world.Get<Inventory>(a.self)) out.inv[i] = *inv;
}
out.valid = true;
}
void Game::RestoreSquad(const SquadCarry& carry)
{
if (!carry.valid) return;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (Inventory* inv = world.Get<Inventory>(a.self))
{
*inv = carry.inv[i];
// Ствол в руках — часть инвентаря, но живёт ещё и отдельной
// сущностью-оружием. Без этого боец приезжает на новый этаж с
// карабином по умолчанию, что бы он ни нашёл этажом выше.
EquipWeapon(world, a, inv->weapon);
}
// Павший остаётся павшим: воскрешение на лестнице обесценило бы
// единственную настоящую потерю в забеге.
a.alive = carry.alive[i];
a.hp = a.alive ? std::max(1.0f, carry.hp[i]) : 0.0f;
a.ammoReserve = carry.ammo[i];
}
}
void Game::BuildFloor(const SquadCarry* carry)
{
// Этаж из комнат — только если набор подключён снаружи. Не собрался
// (пустой набор, битые комнаты) — откатываемся на захардкоженную карту:
// прототип обязан запускаться на голом репозитории.
levelGenerated = false;
levelUnknownSpawns = 0;
atExit = false;
Vec2 start{SQUAD_START_X, SQUAD_START_Y};
const FloorSpec spec = DescribeFloor(depth);
if (rooms && catalog && rooms->Count() > 0 &&
GenerateLevel(*rooms, *catalog, spec, levelSeed, map, level))
{
levelGenerated = true;
start = level.squadStart;
}
else
{
map.Generate();
level = GeneratedLevel{};
level.floor = spec;
}
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
squad.Init(world, {SQUAD_START_X, SQUAD_START_Y});
SpawnTargets(world);
squad.Init(world, start);
if (carry) RestoreSquad(*carry);
// Собранный этаж населяет СЕБЯ САМ: захардкоженная расстановка мишеней
// и тварей относится к другой карте, и мешать их — верный способ получить
// тварь в стене на пустом месте.
if (levelGenerated)
{
int unknownLoot = 0;
SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns);
SpawnLootFromLevel(world, level.loot, &unknownLoot);
levelUnknownSpawns += unknownLoot;
}
else
{
SpawnTargets(world, combatMode, &map);
}
// Снаряжение раздаётся только тем, кто пришёл ни с чем: переезжающий отряд
// уже одет тем, что нашёл, и выдавать ему стартовый комплект заново
// означало бы стирать весь смысл находок.
//
// Есть профиль — одеваемся из него: это и есть «в чём вернулись с прошлой
// вылазки». Нет профиля (приёмка, dev-прогоны) — прежний фиксированный
// комплект, чтобы замеры не поехали.
if (!carry)
{
if (profile) ApplyProfileLoadout();
else if (combatMode) EquipKits();
// Улучшения профиля — сюда же: они часть того, с чем отряд выходит на
// вылазку. Нет профиля (приёмка, dev-прогоны) — нет и прибавок.
squad.carryBonus = 0.0f;
squad.sightBonus = 0.0f;
squad.hpBonus = 0.0f;
float ammoBonus = 0.0f;
if (profile)
{
squad.carryBonus = UPGRADE_CARRY_STEP * float(profile->UpgradeLevel(Upgrade::CARRY));
squad.sightBonus = UPGRADE_SIGHT_STEP * float(profile->UpgradeLevel(Upgrade::SIGHT));
squad.hpBonus = UPGRADE_VIGOR_STEP * float(profile->UpgradeLevel(Upgrade::VIGOR));
ammoBonus = UPGRADE_AMMO_STEP * float(profile->UpgradeLevel(Upgrade::AMMO));
}
// Запас патронов выдаётся вместе со снаряжением и только в БОЮ.
// В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют
// правила стрельбы, а не то, на сколько минут хватило патронов.
for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
}
lanes.Clear();
metrics.Reset();
events.Clear();
@ -88,6 +376,15 @@ void Game::PlaceSquad(Vec2 at, bool clumped)
a.gun.cooldown = 0.0f;
a.gun.recoilDeg = 0.0f;
a.gun.shotSeq = 0;
// Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него
// трупом, испортил бы и замеры, и игру.
a.alive = true;
a.hp = a.maxHp;
a.hurtTimer = 0.0f;
// Захват — тоже состояние сценария: боец, переехавший в новый сценарий
// в лапах хвата, просто не сдвинулся бы с места.
a.grabbedTimer = 0.0f;
}
ClearBullets(world);
@ -110,6 +407,68 @@ SolverContext Game::MakeContext() const
return ctx;
}
// -----------------------------------------------------------------------------
// Расходники. Одно место на все: бинт, стим и пачка патронов — это «предмет,
// который тратится, когда стало нужно», и разводить их по разным системам
// значило бы трижды написать одно правило.
//
// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон,
// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно:
// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой.
// -----------------------------------------------------------------------------
void Game::UseConsumables(float dt)
{
(void)dt;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
const Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
// Насколько близко ближайшая живая тварь: лечиться при ней нельзя.
float nearest = 1e9f;
for (const Target& t : targets)
{
if (!t.alive || t.IsProp()) continue;
nearest = std::min(nearest, Distance(t.pos, a.pos));
}
const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac;
const bool safe = nearest > g_tune.healSafeDist;
// Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто
// держит его прямо сейчас. Боец с клинком в руках и запасным карабином
// в сумке иначе таскал бы найденную пачку до конца забега мёртвым
// грузом, а это ровно то, от чего расходники и лечились.
bool ownsGun = !a.IsMelee();
for (int s = 0; s < SQUAD_BAG_SLOTS && !ownsGun; ++s)
if (ItemDefOf(squad.bag.slots[s]).kind == ItemKind::FIREARM) ownsGun = true;
const bool dry = (a.ammoReserve == 0) && ownsGun;
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{
const ItemDef d = ItemDefOf(squad.bag.slots[s]);
if (d.kind != ItemKind::CONSUMABLE) continue;
const bool healsNow = (d.heal > 0.0f) && hurt && safe;
const bool loadsNow = (d.rounds > 0) && dry;
if (!healsNow && !loadsNow) continue;
if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal);
if (loadsNow) a.ammoReserve += d.rounds;
squad.bag.slots[s] = ItemId::NONE;
events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{});
++metrics.itemsUsed;
break; // не больше одного расходника за шаг: тратить пачку разом незачем
}
}
}
void Game::Step(float dt)
{
if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT))
@ -118,13 +477,32 @@ void Game::Step(float dt)
// --- 0. статы снаряжения: с сущностей-оружия на бойцов ---------------------
// Одна система в начале шага. Всё остальное дальше читает уже готовые числа
// и не лазает в ECS: ai/ про мир сущностей не знает вообще.
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i]);
// Общая ноша делится поровну на живых. Потерять бойца — значит взвалить
// его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл
// сюда, и это не побочный эффект, а то, ради чего сумка общая.
const float bagShare = squad.bag.Load() / float(squad.Carriers());
SquadBuffs buffs;
buffs.sight = squad.sightBonus;
buffs.hp = squad.hpBonus;
buffs.carry = squad.carryBonus;
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs);
for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt);
// --- 0.5 расходники ---------------------------------------------------------
// Ручных действий в игре нет (спек 5), поэтому расходники боец применяет
// САМ. Иначе бинт остаётся числом в каталоге и мёртвым грузом в сумке —
// ровно тем, чем он и был до этой вехи.
UseConsumables(dt);
UpdateTargets(targets, dt);
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
// на то, что видит сейчас, а не на прошлый кадр.
UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events);
// --- 1. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
const Vec2 moveWorld = iso::ScreenDirToWorld(in.moveScreen);
const Vec2 moveWorld = v2d::ScreenDirToWorld(in.moveScreen);
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
@ -138,6 +516,7 @@ void Game::Step(float dt)
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
if (a.IsMelee()) EvaluateMeleeTarget(a, i, ctx);
else EvaluateAgentLane(a, i, ctx);
}
@ -147,6 +526,7 @@ void Game::Step(float dt)
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.alive) continue; // мёртвый ничего не резервирует
if (a.IsMelee())
{
@ -179,6 +559,7 @@ void Game::Step(float dt)
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) { lastSolveValid[i] = false; continue; }
a.commitTimer = std::max(0.0f, a.commitTimer - dt);
if (a.IsMelee())
@ -211,6 +592,7 @@ void Game::Step(float dt)
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
// На замахе и ударе ось зафиксирована: доворачивать её задним числом —
// это стрельба с самонаведением, только клинком.
if (a.IsMelee() && a.melee.Committed())
@ -229,12 +611,71 @@ void Game::Step(float dt)
// --- 9. пули ---------------------------------------------------------------
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events);
// --- 10. метрики -----------------------------------------------------------
// --- 10. находки -----------------------------------------------------------
// После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге,
// обязан быть подбираемым сразу, а не через кадр.
lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag,
squad.Carriers(), squad.carryBonus, &events, dt);
// --- 11. опыт за убитых ----------------------------------------------------
AwardKillXp();
// --- 12. спуск -------------------------------------------------------------
// Симуляция только ОТМЕЧАЕТ, что отряд дошёл. Сам этаж она не меняет: смена
// мира посреди шага, между движением и стрельбой, оставила бы половину
// систем со ссылками на снесённые сущности.
UpdateExit();
// --- 13. метрики -----------------------------------------------------------
metrics.Sample(squad, dt);
simTime += dt;
}
// -----------------------------------------------------------------------------
// Опыт. Цена убитого — его danger из каталога движка, помноженная на множитель
// этажа: и то и другое живёт в данных, а не здесь.
//
// Обстановка опыта не даёт по определению: ящик — это обстановка, и платить за
// него значило бы сделать разбивание хлама самым выгодным занятием в игре.
// -----------------------------------------------------------------------------
void Game::AwardKillXp()
{
const float scale = g_tune.xpPerDanger * level.floor.xpScale;
for (Target& t : targets)
{
if (t.alive) { t.countedDead = false; continue; }
if (t.countedDead) continue;
t.countedDead = true;
if (t.IsProp() || t.IsDummy()) continue;
float danger = 1.0f;
if (catalog)
if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger;
xp += danger * scale;
}
}
void Game::UpdateExit()
{
if (!levelGenerated) { atExit = false; atExtract = false; return; }
const float r = g_tune.descendRadius;
const bool nearExit = Distance(squad.anchor, level.exitPos) <= r;
const bool nearOut = Distance(squad.anchor, level.extractPos) <= r;
// События пишутся на ФРОНТЕ: иначе журнал забивался бы одним и тем же
// сообщением каждый кадр, пока отряд стоит на месте.
if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{});
if (nearOut && !atExtract) events.Push(CombatEventKind::EXTRACT, -1, level.extractPos, Vec2{});
atExit = nearExit;
atExtract = nearOut;
}
void Game::UpdateAim(float dt)
{
if (in.toggleAimMode)
@ -264,9 +705,9 @@ void Game::UpdateAim(float dt)
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
// Собираем кандидатов в конусе, отсортированных по дальности от якоря.
int inCone[NUM_TARGETS];
int inCone[MAX_TARGETS];
int coneCount = 0;
for (int i = 0; i < int(targets.size()); ++i)
for (int i = 0; i < int(targets.size()) && coneCount < MAX_TARGETS; ++i)
{
if (!targets[i].alive) continue;
const Vec2 d = targets[i].pos - squad.anchor;
@ -319,7 +760,7 @@ void Game::FireWeapons(const SolverContext& ctx, float dt)
{
Agent& a = squad.agents[i];
a.fired = false;
if (a.IsMelee()) continue;
if (!a.alive || a.IsMelee()) continue;
Firearm& g = a.gun;
g.cooldown = std::max(0.0f, g.cooldown - dt);
@ -333,13 +774,29 @@ void Game::FireWeapons(const SolverContext& ctx, float dt)
if (g.reloadTimer <= 0.0f)
{
g.reloadTimer = 0.0f;
// Магазин набивается ИЗ ЗАПАСА. Запас отрицательный — песочница
// приёмки, там патроны бесконечны (sim/agent.h).
if (a.ammoReserve < 0)
{
g.ammo = g.def.magazine;
}
else
{
const int take = std::min(g.def.magazine, a.ammoReserve);
g.ammo = take;
a.ammoReserve -= take;
}
events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing));
}
continue;
}
if (g.Empty())
{
// Нечем набивать — перезарядка даже не начинается. Боец с пустым
// стволом не изображает работу: он молчит, и это видно.
if (a.ammoReserve == 0) continue;
g.reloadTimer = g.def.reloadTime;
events.Push(CombatEventKind::RELOAD_START, i, a.pos, FromAngle(a.facing));
++metrics.reloads;
@ -368,11 +825,22 @@ void Game::FireWeapons(const SolverContext& ctx, float dt)
if (AllyInterceptsShot(muzzle, tp, i, ctx)) continue;
// Отклонение внутри уже проверенного конуса.
//
// Картечь ничего не ослабляет: ВСЕ дробины уходят в тот же конус
// SpreadRad, который только что проверили на своих. Поэтому у дробовика
// конус широкий, и выстрел отсекается чаще, — это его цена, а не
// послабление правила 6.3.
const float spread = a.SpreadRad();
const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing)));
const Vec2 dir = FromAngle(axis + g.ShotOffset(i, spread));
const float travel = Distance(muzzle, tp);
const int pellets = std::max(1, g.def.pellets);
SpawnBullet(world, g.def, muzzle, dir, Distance(muzzle, tp), i);
Vec2 dir{};
for (int p = 0; p < pellets; ++p)
{
dir = FromAngle(axis + g.ShotOffset(i, spread, p));
SpawnBullet(world, g.def, muzzle, dir, travel, i);
}
events.Push(CombatEventKind::SHOT, i, muzzle, dir);
g.cooldown = g.def.fireInterval;
@ -404,7 +872,7 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.IsMelee()) continue;
if (!a.alive || !a.IsMelee()) continue;
MeleeWeapon& m = a.melee;
m.cooldown = std::max(0.0f, m.cooldown - dt);
@ -492,25 +960,39 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
}
// 2. мишени, по одной за взмах
for (int t = 0; t < int(targets.size()); ++t)
// MAX_TARGETS — это ширина hitMask, а не «столько влезет»:
// за пределами маски удар прошёл бы по одной цели дважды.
for (int t = 0; t < int(targets.size()) && t < MAX_TARGETS; ++t)
{
Target& tg = targets[t];
if (!tg.alive) continue;
if (m.hitMask & (1u << t)) continue;
if (m.hitMask & (uint64_t(1) << t)) continue;
if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach, tg.pos, tg.radius))
continue;
if (map.RaycastBlocked(a.pos, tg.pos)) continue; // сквозь стену не рубим
m.hitMask |= (1u << t);
m.hitMask |= (uint64_t(1) << t);
a.fired = true;
const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir));
const Vec2 contact = tg.pos - dir * tg.radius;
const bool prop = tg.IsProp();
tg.hp -= m.curDamage;
tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock);
// Щепки вместо крови и мимо счётчика ударов по врагу:
// разрубленный ящик не должен красить пол и накручивать
// MELEE HITS в метриках.
if (prop)
{
events.Push(CombatEventKind::HIT_PROP, i, contact, dir);
}
else
{
events.Push(CombatEventKind::MELEE_HIT, i, contact, dir);
++metrics.meleeHits;
}
// Инвариант сектора: попадание обязано лежать внутри дуги
// ЭТОГО взмаха. Замеряется превышение — оно должно быть ~0.
@ -523,9 +1005,11 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
if (tg.hp <= 0.0f)
{
tg.alive = false;
tg.respawnTimer = g_tune.targetRespawn;
++metrics.targetsKilled;
events.Push(CombatEventKind::HIT_KILL, i, tg.pos, dir);
tg.respawnTimer = tg.RespawnDelay();
if (!prop) ++metrics.targetsKilled;
events.Push(prop ? CombatEventKind::PROP_BREAK
: CombatEventKind::HIT_KILL,
i, tg.pos, dir);
}
}
@ -555,3 +1039,66 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
}
}
}
// -----------------------------------------------------------------------------
// Комплекты снаряжения.
//
// Раздаются ТОЛЬКО в боевом режиме и только здесь. Причина жёсткая: броня несёт
// множитель скорости, а на скорости движения меряются критерии 11.4 и 11.5.
// Отряд приёмки обязан оставаться голым — тогда цифры сравнимы с прежними.
// -----------------------------------------------------------------------------
void Game::EquipKits()
{
struct Kit
{
ItemId blade; // если слот держит клинок
ItemId gun; // если слот держит ствол
ItemId armor;
ItemId trinket;
ItemId bag[3]; // запасное оружие и расходники
};
// Комплект на СЛОТ СТРОЯ, а не на «персонажа»: остриё клина берёт плиту и
// тесак, хвост — лёгкую броню и дальний ствол. Состав читается по строю.
static const Kit KITS[NUM_AGENTS] = {
{ItemId::CLEAVER, ItemId::SHOTGUN, ItemId::PLATE, ItemId::CHARM,
{ItemId::BANDAGE, ItemId::NONE, ItemId::NONE}},
{ItemId::KNIFE, ItemId::SMG, ItemId::SHROUD, ItemId::SPURS,
{ItemId::PISTOL, ItemId::BANDAGE, ItemId::NONE}},
{ItemId::SABER, ItemId::RIFLE, ItemId::VEST, ItemId::LANTERN,
{ItemId::SHOTGUN, ItemId::NONE, ItemId::NONE}},
{ItemId::SABER, ItemId::SHOTGUN, ItemId::VEST, ItemId::CHARM,
{ItemId::RIFLE, ItemId::STIMPACK, ItemId::NONE}},
{ItemId::KNIFE, ItemId::MAGNUM, ItemId::JACKET, ItemId::SPURS,
{ItemId::SMG, ItemId::BANDAGE, ItemId::NONE}},
};
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
const Kit& kit = KITS[i];
const bool melee = (i < squad.meleeCount);
*inv = Inventory{};
inv->weapon = melee ? kit.blade : kit.gun;
inv->armor = kit.armor;
inv->trinket = kit.trinket;
// Запасное уходит в ОБЩУЮ сумку: она одна на отряд.
for (int s = 0; s < 3; ++s)
if (kit.bag[s] != ItemId::NONE) squad.bag.Take(kit.bag[s], NUM_AGENTS, 0.0f);
EquipWeapon(world, a, inv->weapon);
a.hp = a.maxHp;
}
}
int Game::AliveAgents() const
{
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (squad.agents[i].alive) ++n;
return n;
}

View File

@ -7,7 +7,7 @@
// Мишени, пули, бойцы и оружие — сущности одного мира ecs::World. targets и
// bullets ниже — это ССЫЛКИ на плотные массивы соответствующих пулов, а не
// отдельные контейнеры: правила ходят по ним как по обычным векторам и про ECS
// ничего не знают (docs/12-ecs.md).
// ничего не знают (см. заголовок src/ecs/ecs.h).
// -----------------------------------------------------------------------------
#include <vector>
@ -15,15 +15,26 @@
#include "ai/lane_registry.h"
#include "ai/melee.h"
#include "core/input.h"
#include "core/math.h"
#include "engine/math.h"
#include "debug/metrics.h"
#include "ecs/ecs.h"
#include "engine/level_gen.h"
#include "engine/room.h"
#include "render/sprites.h"
#include "sim/events.h"
#include "sim/bullet.h"
#include "sim/loot.h"
#include "sim/profile.h"
#include "sim/squad.h"
#include "sim/target.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
// Движок и игра считают населённость этажа своими числами и не обязаны знать
// про чужое: у движка это MAX_LEVEL_SPAWNS, у игры — ширина маски удара
// клинком MAX_TARGETS. Здесь видны оба — значит, здесь и проверяется, что этаж
// целиком влезает в мир. Иначе он молча приезжал бы обкусанным.
static_assert(MAX_LEVEL_SPAWNS <= MAX_TARGETS,
"движок кладёт на этаж больше сущностей, чем игра способна держать");
// Ручное прицеливание (спек 6.8).
struct AimState
@ -33,6 +44,45 @@ struct AimState
int assignedTargetId = -1;
};
// Что отряд увозит с собой на следующий этаж. Отдельная структура, а не
// «не трогать мир»: сборка этажа пересоздаёт ecs::World целиком, и по-другому
// сохранить бойцов нельзя без того, чтобы половина систем начала различать
// «новый мир» и «тот же мир».
struct SquadCarry
{
bool valid = false;
bool alive[NUM_AGENTS] = {};
float hp[NUM_AGENTS] = {};
Inventory inv[NUM_AGENTS];
// Патроны едут вниз вместе со всем остальным. Иначе спуск чинил бы главный
// ресурс вылазки сам собой, и «идти ли ещё глубже» перестало бы быть
// вопросом.
int ammo[NUM_AGENTS] = {};
};
// Чем кончился забег. Отдельная структура, потому что читать её будут ПОСЛЕ
// того, как мир уже пересобран: экран итога показывается на новом главном
// экране, а не поверх старого этажа.
enum class RunOutcome : uint8_t
{
NONE = 0, // забег идёт
EXTRACTED, // отряд вышел наверх и унёс добычу
WIPED, // отряд полёг, унесено ничего
};
struct RunReport
{
RunOutcome outcome = RunOutcome::NONE;
int depth = 1; // на каком этаже кончилось
int deepest = 1; // до какого дошли за забег
int survivors = 0;
float xp = 0.0f;
int itemsBrought = 0; // сколько предметов легло в схрон
int itemsLost = 0; // сколько осталось на павших и внизу
bool newRecord = false; // побит рекорд глубины
int checkpoint = 1; // самый глубокий открытый чекпоинт ПОСЛЕ забега
};
struct Game
{
Tilemap map;
@ -44,6 +94,7 @@ struct Game
LaneRegistry lanes;
std::vector<Target>& targets; // пул ECS
std::vector<Bullet>& bullets; // пул ECS
std::vector<FloorItem>& floorItems;
Metrics metrics;
AimState aim;
// Единственный канал в сторону эффектов. Правила его не читают.
@ -57,13 +108,80 @@ struct Game
bool enableSolver = true;
bool enableLaneAvoid = true;
// Игровой режим: вместо мишеней — твари, на бойцах снаряжение.
// Прогоны приёмки и --headless его НЕ включают: там прежние мишени и голый
// отряд, поэтому все критерии меряются на том же, на чём мерялись.
bool combatMode = false;
// --- забег: этаж, собранный из комнат (engine/level_gen.h) ----------------
// nullptr — прежняя захардкоженная планировка Tilemap::Generate(). Приёмка
// и --headless набор комнат не подключают НИКОГДА: критерии меряются на той
// же карте, на которой мерялись, иначе цифры не с чем сравнивать.
const RoomLibrary* rooms = nullptr;
const EntityCatalog* catalog = nullptr;
uint32_t levelSeed = 1;
// Глубина. Первичное понятие всей игры: от неё зависят твари, лут, отделка
// и свет. Что именно она означает — engine/progression.h, здесь только
// номер текущего этажа.
int depth = 1;
// Что вышло из последней сборки: комнаты, коридоры, население, спуск.
// Читают оверлей, HUD и dev-вывод; правила боя про это не знают.
GeneratedLevel level;
bool levelGenerated = false;
int levelUnknownSpawns = 0; // сколько id этажа игра не опознала
// --- счёт забега ----------------------------------------------------------
// Опыт копится за убитых с учётом их опасности и множителя этажа. Тратить
// его пока не на что, и это честная заглушка, а не забытый кусок: тратить
// опыт — значит менять характеристики бойцов, а на них меряются критерии
// приёмки, и такой шаг обязан идти отдельной вехой со своими проверками.
float xp = 0.0f;
int floorsCleared = 0;
int lootTaken = 0;
// Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение
// принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает,
// иначе смена мира случалась бы посреди шага, между движением и стрельбой.
bool atExit = false;
bool atExtract = false;
// --- экстракшен -----------------------------------------------------------
// Профиль — состояние ИГРОКА между забегами: снаряжение, схрон, чекпоинты.
// nullptr — забеги не сохраняются (dev-прогоны, приёмка).
Profile* profile = nullptr;
// Чем кончился последний забег. Читает экран итога; правила боя — нет.
RunReport report;
InputState in{};
float simTime = 0.0f;
Game();
void Init(); // одноразовая инициализация (спрайты + Reset)
void Reset(); // R: перегенерировать уровень и расставить отряд
// R: пересобрать ТОТ ЖЕ уровень и расставить отряд. Сид не меняется —
// «переиграть с начала» обязано давать ту же карту, иначе на ней нечему
// научиться, а замеры не с чем сравнить.
void Reset();
// Новая вылазка с указанного чекпоинта (1 — сверху). Счёт с нуля, другой
// сид, снаряжение берётся из профиля.
void NewRun(int startDepth = 1);
// Спуск на следующий этаж. Отряд переезжает КАК ЕСТЬсо здоровьем,
// оружием и сумками: в этом весь забег. Павшие не воскресают.
void Descend();
// --- конец забега ---------------------------------------------------------
// Эвакуация: ВЫЖИВШИЕ уносят своё, павшие не уносят ничего. Это и есть
// ставка экстракшена — не «доживи», а «доживи, имея что вынести».
void Extract();
// Отряд полёг. Снаряжение сбрасывается к стартовому, схрон и чекпоинты
// остаются: потерять вылазку — не то же самое, что потерять всё нажитое.
void WipeRun();
// Забег кончился сам собой? Проверяется снаружи (main), а не в Step:
// симуляция не должна решать, показывать ли экран.
bool RunOver() const { return report.outcome != RunOutcome::NONE; }
// Поставить отряд в заданную точку. clumped = все пятеро в одной куче —
// это исходная позиция сценария из критерия приёмки 11.1.
@ -73,7 +191,32 @@ struct Game
SolverContext MakeContext() const;
// Раздать комплекты снаряжения по слотам строя. Только для combatMode:
// броня меняет скорость, а на скорости меряются критерии приёмки.
void EquipKits();
int AliveAgents() const;
bool SquadWiped() const { return AliveAgents() <= 0; }
private:
// Собрать текущий этаж. carry != nullptr — отряд приехал сверху и его
// надо восстановить после пересоздания мира.
void BuildFloor(const SquadCarry* carry);
void SnapshotSquad(SquadCarry& out) const;
void RestoreSquad(const SquadCarry& carry);
void AwardKillXp(); // опыт за впервые упавших целей
void UpdateExit(); // дошёл ли отряд до спуска или до эвакуации
// Собрать снаряжение бойцов в профиль. onlySurvivors — то, что унесли;
// иначе всё разом (нужно между этажами, там ничего не теряется).
void HarvestToProfile(bool onlySurvivors);
// Раздать бойцам снаряжение из профиля. Пустой профиль — стартовый набор.
void ApplyProfileLoadout();
// Расходники применяются сами: ручных действий в игре нет.
void UseConsumables(float dt);
void FinishRun(RunOutcome outcome);
void UpdateAim(float dt);
// Перезарядка, увод ствола, спуск. Разброс выстрела считается здесь же и
// ровно тот, который проверил LaneClear.

View File

@ -12,6 +12,16 @@
// --melee N сколько слотов держат клинок (0 = все стрелки)
// --pistol N сколько ПОСЛЕДНИХ слотов носят пистолет
// --set имя=значение подкрутить параметр из TUNABLES
// --sf-tiles тайлы из библиотеки SpriteForge + hot reload
// --sprites отчёт по библиотеке SpriteForge и выход
// --windowed не разворачивать окно на весь экран
// --sandbox прежняя песочница: мишени вместо тварей
// --rooms [каталог] бесконечный спуск: этажи из комнат (assets/rooms)
// --seed N сид сборки этажа; без него 1
// --depth N начать спуск не с первого этажа (для проверок)
// --descend N прогон забега на N этажей без окна
// --profile <файл> где лежит профиль (схрон, чекпоинты)
// --no-profile не читать и не писать профиль
// -----------------------------------------------------------------------------
#include <raylib.h>
@ -19,17 +29,27 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include "engine/app.h"
#include "debug/harness.h"
#include "debug/overlay.h"
#include "engine/project.h"
#include "engine/room.h"
#include "game.h"
#include "render/framebuffer.h"
#include "engine/framebuffer.h"
#include "render/hud.h"
#include "engine/sfa_library.h"
#include "render/view.h"
#include "tuning.h"
#include "ui/menu.h"
namespace
{
// Свой проект. Игра знает, как её содержимое называется; движок — нет.
constexpr const char* DEFAULT_PROJECT = "descent";
InputState SampleInput()
{
InputState in;
@ -68,6 +88,31 @@ void ApplyTuningOverride(const char* arg)
std::printf("неизвестный параметр: %s\n", arg);
}
// --sprites: что загрузчик реально видит в build/sprites. Проверка стороны C++;
// содержимое самих ассетов смотрят через `sf list` / `sf ascii`, а не отсюда.
int RunSpriteReport()
{
const std::string dir = FindSpriteLibraryDir();
SpriteLibrary lib;
if (dir.empty() || !lib.Open(dir))
{
std::printf("библиотека SpriteForge не найдена; собрать: sf build "
"assets/manifests/environment.yaml --output build/sprites\n");
return 1;
}
std::printf("%s: %d ассет(ов)\n", lib.Dir().c_str(), lib.AssetCount());
std::printf("%-18s %8s %6s %10s %8s\n", "ID", "HASH", "FRAMES", "SIZE", "PIVOT");
for (const uint32_t id : lib.Ids())
{
Sprite s;
if (!lib.Read(id, 0, s)) continue;
std::printf("%-18s %08X %6d %5dx%-4d %4d,%-4d\n", lib.NameOf(id), id, lib.FrameCount(id),
s.w, s.h, s.ax, s.ay);
}
return 0;
}
} // namespace
int main(int argc, char** argv)
@ -76,6 +121,29 @@ int main(int argc, char** argv)
const char* shotFile = "shot.png";
bool accept = false;
bool clean = false; // --clean: стартовать без оверлея (для скриншотов)
bool sfTiles = false; // --sf-tiles: пол и стены из библиотеки SpriteForge
bool sfEnemies = false; // --sf-enemies: анимированные твари оттуда же
bool sfAgents = false; // --sf-agents: modular agent paper-dolls
// --sf включает оба разом
bool sfReport = false; // --sprites: отчёт по библиотеке и выход
bool windowed = false; // --windowed: окно вместо безрамочного на весь экран
bool sandbox = false; // --sandbox: мишени вместо тварей, как было раньше
// --ui-shot <экран>: снять кадр не с боя, а с меню/отряда/поражения.
// Нужен ровно для картинок в документации: экраны вне боя иначе не снять,
// потому что --shot никого не нажимает.
const char* uiShot = nullptr;
// --rooms: планировка собирается из комнат вместо захардкоженной карты.
// Приёмки и headless это не касается — они до сюда не доходят.
bool useRooms = false;
// Какой проект открывать. Своё имя игра знает, движок — нет: для него это
// просто строка, которую ему передали.
const char* projectArg = nullptr;
const char* viewOverride = nullptr;
uint32_t levelSeed = 1;
int startDepth = 1; // --depth N: начать спуск не с первого этажа
int descendFloors = 0; // --descend N: прогнать N этажей без окна и выйти
const char* profilePath = nullptr;
bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны)
HarnessOptions harness;
harness.seconds = 0.0f;
@ -95,87 +163,321 @@ int main(int argc, char** argv)
else if (std::strcmp(argv[i], "--pistol") == 0 && i + 1 < argc)
harness.pistolCount = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--clean") == 0) clean = true;
else if (std::strcmp(argv[i], "--sf-tiles") == 0) sfTiles = true;
else if (std::strcmp(argv[i], "--sf-enemies") == 0) sfEnemies = true;
else if (std::strcmp(argv[i], "--sf-agents") == 0) sfAgents = true;
else if (std::strcmp(argv[i], "--sf") == 0) sfTiles = sfEnemies = sfAgents = true;
else if (std::strcmp(argv[i], "--sprites") == 0) sfReport = true;
else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true;
else if (std::strcmp(argv[i], "--sandbox") == 0) sandbox = true;
else if (std::strcmp(argv[i], "--ui-shot") == 0 && i + 1 < argc) uiShot = argv[++i];
else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc)
startDepth = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc)
profilePath = argv[++i];
else if (std::strcmp(argv[i], "--no-profile") == 0) useProfile = false;
else if (std::strcmp(argv[i], "--descend") == 0 && i + 1 < argc)
{
descendFloors = TextToInteger(argv[++i]);
useRooms = true; // забег без набора комнат бессмыслен
}
else if (std::strcmp(argv[i], "--seed") == 0 && i + 1 < argc)
levelSeed = uint32_t(std::strtoul(argv[++i], nullptr, 10));
else if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc)
{
projectArg = argv[++i];
useRooms = true; // проект открывают, чтобы в него играть
}
else if (std::strcmp(argv[i], "--view") == 0 && i + 1 < argc)
viewOverride = argv[++i];
else if (std::strcmp(argv[i], "--rooms") == 0)
{
useRooms = true;
// Имя проекта необязательно: без него открывается свой, DESCENT.
if (i + 1 < argc && argv[i + 1][0] != '-') projectArg = argv[++i];
}
else if (std::strcmp(argv[i], "--accept") == 0) accept = true;
else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true;
else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false;
else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false;
}
// Проект поднимается ДО окна: прогон забега (--descend) окна не открывает
// вообще, а игре содержимое нужно до первой сборки этажа.
//
// Порядок именно такой: сперва проект — он задаёт вид отображения и кривую
// глубины, — потом по его путям читаются каталог и комнаты. Наоборот
// нельзя: тайлсет рисуется под форму плиты, которую называет проект.
static Project project;
static RoomLibrary rooms;
static EntityCatalog catalog;
bool contentOk = false;
if (useRooms)
{
const std::string dir = FindProjectDir(projectArg ? projectArg : DEFAULT_PROJECT);
std::string why;
if (dir.empty())
{
std::printf("проект не найден (искали %s)\n",
projectArg ? projectArg : DEFAULT_PROJECT);
const std::vector<std::string> avail = AvailableProjects();
if (!avail.empty())
{
std::printf(" доступны:");
for (const std::string& n : avail) std::printf(" %s", n.c_str());
std::printf("\n");
}
}
else if (!project.Load(dir, &why))
{
std::printf("проект не читается: %s\n", why.c_str());
}
else
{
// Ключ --view перебивает проект: это отладочная ручка, чтобы
// посмотреть чужой проект в другом виде, не правя его манифест.
if (viewOverride) ParseViewMode(viewOverride, project.view);
project.Apply();
const bool catOk = catalog.Load(project.catalogPath, &why);
if (!catOk) std::printf("каталог сущностей недоступен (%s)\n", why.c_str());
contentOk = catOk && rooms.Load(project.roomsDir, &why);
if (contentOk)
{
std::printf("проект %s (%s, вид %s): комнат %d, каталог %d записей, "
"сид %u, первый этаж %d\n",
project.name.c_str(), project.dir.c_str(),
ViewModeName(project.view), rooms.Count(), catalog.Count(),
unsigned(levelSeed), startDepth);
for (const std::string& f : rooms.failures)
std::printf(" не читается %s\n", f.c_str());
}
else
{
std::printf("набор комнат недоступен (%s), карта захардкоженная\n", why.c_str());
}
}
}
// Профиль — состояние игрока между вылазками. Отсутствие файла не ошибка:
// первый запуск начинается со стартовым снаряжением и пустым схроном.
static Profile profile;
const std::string profFile = profilePath ? std::string(profilePath) : DefaultProfilePath();
if (useProfile)
{
std::string why;
if (profile.Load(profFile, &why))
std::printf("профиль: %s, глубина %d, схрон %d, вылазок %d\n", profFile.c_str(),
profile.maxDepth, profile.StashCount(), profile.runs);
else
{
profile.ResetLoadouts();
std::printf("профиль: новый (%s)\n", why.c_str());
}
}
if (sfReport) return RunSpriteReport();
if (accept) return RunAccept();
if (harness.seconds > 0.0f) return RunHeadless(harness);
if (descendFloors > 0)
{
if (!contentOk) { std::printf("прогон забега невозможен без набора комнат\n"); return 1; }
return RunDescend(rooms, catalog, levelSeed, descendFloors);
}
SetConfigFlags(FLAG_VSYNC_HINT);
InitWindow(WINDOW_W, WINDOW_H, "Squad prototype");
SetExitKey(KEY_ESCAPE);
Framebuffer fb;
fb.Init(INTERNAL_W, INTERNAL_H);
ScreenBlitter screen;
screen.Init(INTERNAL_W, INTERNAL_H);
// Окно, кадр и ход времени — движковые (engine/app.h). Esc здесь не
// закрывает игру: он открывает паузу, а выход идёт через меню — закрывать
// игру случайным нажатием посреди вылазки нельзя.
App app;
AppConfig cfg;
cfg.title = "Descent — Tile2D";
cfg.borderless = !windowed;
if (!app.Init(cfg)) return 1;
Game game;
game.combatMode = !sandbox; // прежняя песочница с мишенями — под --sandbox
game.enableSolver = harness.solver;
game.enableLaneAvoid = harness.laneAvoid;
game.squad.meleeCount = harness.meleeCount; // до Init: состав раздаётся там
game.squad.pistolCount = harness.pistolCount;
// Набор подключается ДО Init — первая сборка этажа случается там.
// Профиль тоже: снаряжение бойцов раздаётся при сборке.
if (useProfile) game.profile = &profile;
if (contentOk)
{
game.rooms = &rooms;
game.catalog = &catalog;
game.levelSeed = levelSeed;
game.depth = startDepth;
}
game.Init();
if (game.levelGenerated)
{
const GeneratedLevel& l = game.level;
std::printf("этаж %d (%s): комнат %d, коридоров %d, тварей %d (долив %d), лута %d\n",
l.floor.depth, ThemeName(l.floor.theme), l.roomsPlaced, l.corridors,
l.monstersPlaced, l.monstersFilled, int(l.loot.size()));
std::printf(" опасность %.1f из %.1f, достижимо тайлов %d, до спуска %d шагов\n",
double(l.dangerSpent), double(l.floor.dangerBudget), l.reachableTiles,
l.exitDistance);
// Всё, из-за чего этаж населён не так, как задумано, печатается
// отдельными строками: молчаливая усушка населения — это не «баланс».
if (l.spawnsDropped > 0)
std::printf(" срезано потолком %d: %d\n", MAX_TARGETS, l.spawnsDropped);
if (l.spawnsUnreachable > 0)
std::printf(" недостижимо от старта: %d\n", l.spawnsUnreachable);
if (game.levelUnknownSpawns > 0)
std::printf(" id нет в игре: %d\n", game.levelUnknownSpawns);
}
// Библиотека SpriteForge живёт весь запуск: без неё нечего перечитывать
// по сигналу `sf watch`. Не открыта — набор остаётся процедурным.
//
// Открывается она ОДИН раз на оба режима: тайлы и твари берут ассеты из
// одной библиотеки с одной палитрой, и открывать её дважды значило бы
// держать в памяти две копии всех кадров.
static SpriteLibrary forge;
bool forgeOpen = false;
if (sfTiles || sfEnemies || sfAgents)
{
const std::string dir = FindSpriteLibraryDir();
forgeOpen = !dir.empty() && forge.Open(dir);
if (!forgeOpen) std::printf("библиотека SpriteForge недоступна, спрайты процедурные\n");
}
if (sfTiles && forgeOpen)
std::printf(game.sprites.LoadTiles(forge) ? "тайлы из SpriteForge: %s\n"
: "тайлы SpriteForge не загрузились (%s)\n",
forge.Dir().c_str());
// View большой (поля света на всю карту + виньетка на кадр) — на куче.
static View view;
view.Init();
view.Reset(game);
// Анимация тварей: спрайты берёт View, потому что кадр зависит от ВРЕМЕНИ,
// а время — состояние картинки, а не симуляции.
if (sfEnemies && forgeOpen)
{
if (game.sprites.LoadEnemies(forge))
{
view.forge = &forge;
std::printf("анимированные твари из SpriteForge: %s\n", forge.Dir().c_str());
}
else
{
std::printf("анимации тварей не загрузились, спрайты процедурные\n");
}
}
if (sfAgents && forgeOpen)
{
view.forge = &forge;
std::printf("agent paper-dolls from SpriteForge: %s\n", forge.Dir().c_str());
}
Overlay overlay;
if (clean) { overlay.show = false; overlay.showLanes = false; }
double accumulator = 0.0;
while (!WindowShouldClose())
Ui ui;
// Снимок снимается с БОЯ, а не с меню: --shot нужен для картинок
// в документации, и заставка на них ни при чём.
if (shotFrame >= 0) { ui.screen = Screen::GAME; ui.started = true; }
if (uiShot)
{
if (std::strcmp(uiShot, "menu") == 0) { ui.screen = Screen::MENU; ui.started = false; }
else if (std::strcmp(uiShot, "squad") == 0) ui.screen = Screen::SQUAD;
else if (std::strcmp(uiShot, "defeat") == 0) ui.screen = Screen::DEFEAT;
else if (std::strcmp(uiShot, "depth") == 0) ui.screen = Screen::DEPTH;
else if (std::strcmp(uiShot, "upgrade") == 0) ui.screen = Screen::UPGRADE;
else if (std::strcmp(uiShot, "summary") == 0) ui.screen = Screen::SUMMARY;
else if (std::strcmp(uiShot, "pause") == 0) ui.screen = Screen::PAUSE;
}
while (app.NextFrame())
{
const float frameDt = app.frameDt;
// Меню съедает ввод целиком: пока оно открыто, отряд не шевелится.
// Съёмка экрана отряда: курсор ставится на слот брони, иначе подсказку
// под курсором не снять — она появляется только по наведению. Ровно та
// же причина, по которой у редактора есть --select.
if (uiShot && std::strcmp(uiShot, "squad") == 0 && shotFrame > 0)
SetMousePosition(app.fit.x + 60 * app.fit.scale, app.fit.y + 122 * app.fit.scale);
const bool halted = ui.Update(game, app.fit, frameDt);
if (ui.worldReset)
{
ui.worldReset = false;
view.Reset(game); // кровь и туман прошлой вылазки в новую не едут
}
if (!halted)
{
overlay.HandleKeys();
// Спуск — РЕШЕНИЕ игрока, а не то, что случается от захода на клетку.
// Отряд может стоять на спуске сколько угодно: этаж не зачищен,
// лут не собран, и уходить рано — это нормальный выбор.
if (game.atExit && IsKeyPressed(KEY_F))
{
game.Descend();
view.Reset(game); // кровь и туман прошлого этажа вниз не едут
}
// Эвакуация заканчивает забег. Мир при этом не пересобирается:
// экран итога показывается поверх последнего кадра, а следующая
// вылазка соберёт свой этаж сама.
if (game.atExtract && IsKeyPressed(KEY_E) && !game.RunOver())
{
game.Extract();
if (useProfile) profile.Save(profFile);
}
if (IsKeyPressed(KEY_R)) { game.Reset(); view.Reset(game); }
if (IsKeyPressed(KEY_F5)) view.horrorEnabled = !view.horrorEnabled;
if (IsKeyPressed(KEY_F6)) view.showSight = !view.showSight;
}
game.in = SampleInput();
// `sf watch` пересобрал библиотеку — забрать тайлы не перезапуская игру.
// Кровь при этом сбрасывается по той же причине, что и при смене уровня:
// она запечена в спрайты пола, а пол под ней уже другой.
if (forge.PollReload() && game.sprites.LoadTiles(forge)) view.blood.Reset();
const float frameDt = GetFrameTime();
accumulator += double(frameDt);
game.in = halted ? InputState{} : SampleInput();
// --- fixed timestep 60 Гц, рендер с интерполяцией ---------------------
int steps = 0;
// Сколько шагов прошло, считает движок; ЧТО такое шаг — по-прежнему
// дело игры. Пауза время не копит: иначе после меню симуляция догоняла
// бы пропущенные секунды одним рывком.
const auto t0 = std::chrono::steady_clock::now();
while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME)
{
game.Step(FIXED_DT);
accumulator -= FIXED_DT;
++steps;
}
const int steps = app.Advance(halted);
for (int i = 0; i < steps; ++i) game.Step(FIXED_DT);
game.metrics.simMs = float(
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count());
if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0;
const float alpha = float(accumulator / FIXED_DT);
const float alpha = app.Alpha();
// Мир рисуется ВСЕГДА, даже под меню: чёрный экран за паузой убивает
// всю атмосферу, ради которой этот кадр и собирается.
const auto tr0 = std::chrono::steady_clock::now();
RenderGame(game, view, fb, alpha, frameDt);
RenderGame(game, view, app.fb, alpha, halted ? 0.0f : frameDt);
if (ui.screen == Screen::GAME) DrawHud(app.fb, game);
game.metrics.renderMs = float(
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - tr0).count());
BeginDrawing();
ClearBackground(BLACK);
screen.Present(fb, WINDOW_W, WINDOW_H);
overlay.Draw(game, view);
EndDrawing();
app.Present();
if (ui.screen == Screen::GAME) overlay.Draw(game, view, app.fit);
ui.Draw(game, app.fit);
app.Finish();
if (shotFrame >= 0 && --shotFrame <= 0)
{
TakeScreenshot(shotFile);
app.Screenshot(shotFile);
break;
}
if (ui.quit) app.quit = true; // «выход» в меню
}
screen.Shutdown();
CloseWindow();
app.Shutdown();
return 0;
}

94
src/render/anim.h Normal file
View File

@ -0,0 +1,94 @@
#pragma once
// -----------------------------------------------------------------------------
// Проигрыватель анимаций: состояние на ОДНУ сущность и больше ничего.
//
// Здесь нет ни кадров, ни спрайтов, ни ассетов — только «какой клип идёт и
// сколько он уже идёт». Пиксели по этому состоянию достаёт SpriteLibrary::Frame,
// поэтому кадры анимации лежат в памяти в одном экземпляре на ассет, а не по
// копии на каждого бойца.
//
// Заголовок сознательно header-only и ни от чего не зависит: тварям, бойцам и
// эффектам нужен один и тот же счётчик времени.
// -----------------------------------------------------------------------------
#include <cmath>
#include <cstddef>
#include <cstdint>
// Клипы, которые игра умеет спрашивать. Имена в манифестах SpriteForge —
// строчными: idle, walk, attack, hurt, die.
enum class AnimKind : uint8_t { IDLE = 0, WALK, ATTACK, HURT, DIE, COUNT };
// FNV-1a, 32 бита, по байтам UTF-8 — ровно то, что считает SpriteForge
// (fnv1a32 в format.py) и кладёт в SF_ANIM_* через codegen. Совпадение обязано
// быть побитовым: разойдись хоть один бит — анимация не найдётся, и сущность
// молча замрёт в первом кадре, без единой ошибки в логе.
constexpr uint32_t AnimFnv1a(const char* text)
{
uint32_t hash = 0x811C9DC5u;
for (const char* p = text; *p != '\0'; ++p)
hash = (hash ^ uint32_t(uint8_t(*p))) * 0x01000193u;
return hash;
}
// Порядок строк — порядок AnimKind, иначе хеши поедут молча.
inline constexpr const char* ANIM_NAMES[] = {"idle", "walk", "attack", "hurt", "die"};
static_assert(sizeof(ANIM_NAMES) / sizeof(ANIM_NAMES[0]) == size_t(AnimKind::COUNT),
"ANIM_NAMES обязан покрывать все значения AnimKind");
// Хеш имени клипа. Константа времени компиляции: в кадре игры не считается.
constexpr uint32_t AnimNameHash(AnimKind kind)
{
return AnimFnv1a(ANIM_NAMES[(kind < AnimKind::COUNT) ? int(kind) : 0]);
}
struct Animator
{
AnimKind kind = AnimKind::IDLE;
float time = 0.0f; // секунд от начала клипа
bool loop = true;
bool finished = false; // только для незацикленных: клип дошёл до конца
// Повторный Play той же анимации НЕ сбрасывает время. Логика поведения
// просит анимацию каждый кадр («иду» — значит walk), и сброс намертво
// приморозил бы её к первому кадру.
void Play(AnimKind k, bool looping)
{
if (k == kind)
{
loop = looping;
return;
}
kind = k;
time = 0.0f;
loop = looping;
finished = false;
}
// Начать текущий клип заново — для повтора того же действия подряд
// (второй удар, второй выстрел): Play такое не отличит.
void Restart()
{
time = 0.0f;
finished = false;
}
// length — длина клипа в секундах, из SpriteLibrary::AnimationLength.
// 0 значит «клипа нет»: время не двигаем, иначе оно бесконечно растёт
// на ассете без анимаций и первое же появление клипа даст рывок.
void Advance(float dt, float length)
{
if (!(length > 0.0f)) return;
time += dt;
if (loop)
{
time = std::fmod(time, length);
if (time < 0.0f) time += length; // отмотка назад — тоже по кругу
}
else if (time >= length)
{
time = length; // незацикленный клип замирает на последнем кадре
finished = true;
}
}
};

View File

@ -1,6 +1,6 @@
#include "render/atmos.h"
#include "render/iso.h"
#include "engine/view2d.h"
#include "render/noise.h"
namespace
@ -64,7 +64,7 @@ void Atmosphere::DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lig
const float bob = std::sin(t * TWO_PI_F * 1.31f + ph) * 2.5f;
const int lift = int(g_tune.ashLiftPx * liftFrac + bob);
const Vec2 s = iso::WorldToScreen(p);
const Vec2 s = v2d::WorldToScreen(p);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - lift;
if (unsigned(sx) >= unsigned(fb.w) || unsigned(sy) >= unsigned(fb.h)) continue;

View File

@ -15,8 +15,8 @@
// «частиц» нет как объектов, аллокаций ноль, и число пылинок можно менять
// ручкой в оверлее прямо на ходу.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "engine/math.h"
#include "engine/framebuffer.h"
#include "render/lighting.h"
struct Atmosphere

View File

@ -1,6 +1,6 @@
#include "render/blood.h"
#include "render/iso.h"
#include "engine/view2d.h"
#include "render/noise.h"
namespace
@ -168,7 +168,7 @@ void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world,
// запачканные продолжают собирать кровь.
if (int(stained.size()) >= BLOOD_TILE_BUDGET) continue;
slot = int32_t(stained.size());
stained.push_back(sprites.FloorTile(tx, ty));
stained.push_back(sprites.FloorTile(tx, ty, map.ThemeAt(tx, ty)));
slotOf[key] = slot;
}
@ -184,7 +184,7 @@ void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world,
// Пиксель ромба -> точка мира. Проекция линейна, поэтому
// обратный переход точен и швов между тайлами не будет.
const Vec2 p = center + iso::ScreenToWorld(
const Vec2 p = center + v2d::ScreenToWorld(
Vec2{float(px - s.ax), float(py - s.ay)});
float cover = 0.0f;

View File

@ -23,11 +23,11 @@
#include <cstdint>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
#include "engine/math.h"
#include "engine/framebuffer.h"
#include "render/sprites.h"
#include "sim/events.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
struct BloodField
{

View File

@ -21,10 +21,14 @@ constexpr uint32_t COL_BLADE_TAIL = FXC(120, 165, 235);
constexpr uint32_t COL_SPARK_MELEE = FXC(255, 165, 95);
// Оборванный взмах — тусклая серая вспышка: свой влез под лезвие.
constexpr uint32_t COL_SPARK_ABORT = FXC(150, 150, 170);
// Обстановка: пыльная щепа, а не кровь. Тусклее любого попадания по телу —
// игрок обязан по одному цвету искры понимать, во что он стреляет.
constexpr uint32_t COL_SPARK_SPLINTER = FXC(170, 130, 80);
constexpr uint32_t COL_SPARK_DEBRIS = FXC(200, 160, 105);
inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx)
{
const Vec2 s = iso::WorldToScreen(world);
const Vec2 s = v2d::WorldToScreen(world);
return Vec2{s.x + float(ox), s.y + float(oy) - float(liftPx)};
}
@ -110,6 +114,9 @@ void FxSystem::Ingest(const CombatEventLog& log, const Agent* agents, int agentC
// Удар клинком — самое громкое событие в кадре: он всегда в упор.
case CombatEventKind::MELEE_HIT: col = COL_SPARK_MELEE; power = 2.2f; break;
case CombatEventKind::MELEE_ABORT: col = COL_SPARK_ABORT; power = 0.7f; break;
// Обстановка: щепки на попадание, облако трухи на развал.
case CombatEventKind::HIT_PROP: col = COL_SPARK_SPLINTER; power = 0.6f; break;
case CombatEventKind::PROP_BREAK: col = COL_SPARK_DEBRIS; power = 1.6f; break;
default: continue;
}
if (sparkCount < FX_SPARK_MAX)

View File

@ -10,9 +10,9 @@
// * короткая вспышка у дула в момент выстрела;
// * искра в точке попадания, разная для стены и для цели.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "render/iso.h"
#include "engine/math.h"
#include "engine/framebuffer.h"
#include "engine/view2d.h"
#include "sim/agent.h"
#include "sim/bullet.h"
#include "sim/events.h"

225
src/render/hud.cpp Normal file
View File

@ -0,0 +1,225 @@
#include "render/hud.h"
#include <cstdio>
#include "engine/microfont.h"
#include "game.h"
#include "render/sprites.h"
namespace
{
constexpr int CARD_W = 92;
constexpr int CARD_H = 22;
constexpr int CARD_GAP = 2;
constexpr int BAR_W = 52;
const uint32_t COL_FRAME = RGBA(24, 26, 32);
const uint32_t COL_EMPTY = RGBA(44, 48, 56);
const uint32_t COL_DEAD = RGBA(70, 26, 26);
// Рамка карточки: тёмная подложка, чтобы HUD читался поверх любого пола.
void Panel(Framebuffer& fb, int x, int y, int w, int h, uint32_t fill)
{
fb.FillRect(x, y, w, h, fill);
fb.Line(x, y, x + w - 1, y, COL_FRAME);
fb.Line(x, y + h - 1, x + w - 1, y + h - 1, COL_FRAME);
fb.Line(x, y, x, y + h - 1, COL_FRAME);
fb.Line(x + w - 1, y, x + w - 1, y + h - 1, COL_FRAME);
}
// Патроны: по квадратику на выстрел, пока их не больше BAR_W/2. Дальше —
// обычная полоса: сорок квадратиков автомата разглядывать всё равно никто
// не будет, а «сколько осталось» видно и так.
// Запас патронов СВЕРХ магазина — тонкая полоса под патронами.
//
// Это главный ресурс вылазки, и он обязан таять на глазах: игрок решает «ещё
// этаж или наверх» именно по нему. Пустой запас — красная риска на месте
// полосы: не «полоса нулевой длины», которую не отличить от её отсутствия.
void DrawReserve(Framebuffer& fb, int x, int y, const Agent& a)
{
if (a.ammoReserve < 0) return; // песочница: патроны бесконечны
const float k = Clampf(float(a.ammoReserve) / std::max(1.0f, g_tune.startAmmo), 0.0f, 1.0f);
fb.FillRect(x, y, BAR_W, 1, COL_EMPTY);
if (a.ammoReserve > 0)
fb.FillRect(x, y, std::max(1, int(float(BAR_W) * k)), 1,
(k < 0.2f) ? RGBA(220, 120, 60) : RGBA(150, 140, 110));
else
fb.FillRect(x + BAR_W / 2 - 2, y, 4, 1, RGBA(220, 70, 60));
}
void DrawAmmo(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col)
{
const int mag = a.gun.def.magazine;
if (a.gun.Reloading())
{
// Перезарядка: полоса набирается слева направо. Это ЕДИНСТВЕННЫЙ момент,
// когда стрелок беспомощен, и его надо видеть без чтения цифр.
const float k = 1.0f - Clampf(a.gun.reloadTimer / std::max(0.01f, a.gun.def.reloadTime),
0.0f, 1.0f);
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * k), 3, RGBA(210, 150, 60));
return;
}
if (mag > 0 && mag <= BAR_W / 2)
{
const int step = BAR_W / mag;
for (int i = 0; i < mag; ++i)
fb.FillRect(x + i * step, y, std::max(1, step - 1), 3,
(i < a.gun.ammo) ? col : COL_EMPTY);
return;
}
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * a.gun.AmmoFrac()), 3, col);
}
// Клинок: фаза удара. Замах, удар и восстановление — три разных цвета, потому
// что это три разных состояния бойца, а не одна «атака».
void DrawMeleeState(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col)
{
uint32_t c = COL_EMPTY;
float k = 1.0f;
switch (a.melee.state)
{
case MeleeState::WINDUP: c = RGBA(220, 180, 70); k = a.melee.Progress(); break;
case MeleeState::STRIKE: c = RGBA(250, 240, 220); k = 1.0f; break;
case MeleeState::RECOVER: c = RGBA(150, 70, 70); k = 1.0f - a.melee.Progress(); break;
default:
// Готов бить — полоса цвета бойца; иначе видно, сколько ждать.
c = (a.melee.cooldown <= 0.0f) ? col : COL_EMPTY;
k = (a.melee.cooldown <= 0.0f)
? 1.0f
: 1.0f - Clampf(a.melee.cooldown / std::max(0.01f, a.melee.interval),
0.0f, 1.0f);
break;
}
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * Clampf(k, 0.0f, 1.0f)), 3, c);
}
// -----------------------------------------------------------------------------
// Строка забега: этаж, отделка, опыт. Единственные БУКВЫ в HUD, и появились
// они по одной причине: номер этажа — это число, и показать его формой нельзя
// (engine/microfont.h). Всё остальное в HUD по-прежнему без единой буквы.
// -----------------------------------------------------------------------------
void DrawRunBar(Framebuffer& fb, const Game& game)
{
if (!game.levelGenerated) return; // на захардкоженной карте этажа нет
const uint32_t COL_TEXT = RGBA(206, 200, 190);
const uint32_t COL_SHADOW = RGBA(6, 6, 8);
const uint32_t COL_READY = RGBA(120, 226, 150); // вниз, глубже
const uint32_t COL_OUT = RGBA(130, 200, 240); // наверх, с добычей
char buf[48];
FloorLabel(game.level.floor, buf, sizeof(buf));
MicroTextShadow(fb, 5, 5, buf, COL_TEXT, COL_SHADOW);
// Добыча — вторая по важности цифра забега после этажа: именно она стоит
// на кону, и знать её нужно ДО того, как решаешь идти глубже.
// Сумка одна на отряд, поэтому и цифра одна: пересчитывать по бойцам
// больше нечего.
const int carried = game.squad.bag.Count();
std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried);
MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW);
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
// «нажми F» висела бы на экране весь забег и перестала бы читаться.
//
// Разные цвета — те же, что у колец на полу: подсказка обязана читаться
// как подпись к тому, на чём стоишь, а не как отдельное сообщение.
const char* hint = nullptr;
uint32_t hintCol = COL_READY;
if (game.atExtract) { hint = "EXTRACT: E"; hintCol = COL_OUT; }
else if (game.atExit) { hint = "DESCEND: F"; hintCol = COL_READY; }
if (!hint) return;
MicroTextShadow(fb, (fb.w - MicroTextWidth(hint)) / 2, 14, hint, hintCol, COL_SHADOW);
}
} // namespace
void DrawHud(Framebuffer& fb, const Game& game)
{
DrawRunBar(fb, game);
const int total = NUM_AGENTS * CARD_W + (NUM_AGENTS - 1) * CARD_GAP;
const int x0 = (fb.w - total) / 2;
const int y0 = fb.h - CARD_H - 4;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
const int x = x0 + i * (CARD_W + CARD_GAP);
const uint32_t col = SlotColor(i);
// Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда,
// а красная карточка в углу глаза видна.
uint32_t back = RGBA(12, 13, 16);
if (!a.alive) back = COL_DEAD;
else if (a.hurtTimer > 0.0f)
back = ShadeColor(RGBA(120, 30, 30), 0.4f + a.hurtTimer);
Panel(fb, x, y0, CARD_W, CARD_H, back);
// Метка слота: тот же цвет, что и у тела на карте.
fb.FillRect(x + 3, y0 + 3, 4, CARD_H - 6, a.alive ? col : ShadeColor(col, 0.35f));
if (!a.alive)
{
// Перечёркнутая карточка: потерю отряда нельзя пропустить.
fb.Line(x + 10, y0 + 4, x + CARD_W - 6, y0 + CARD_H - 5, RGBA(180, 60, 60));
fb.Line(x + 10, y0 + CARD_H - 5, x + CARD_W - 6, y0 + 4, RGBA(180, 60, 60));
continue;
}
// Здоровье.
const float hpK = Clampf(a.hp / std::max(1.0f, a.maxHp), 0.0f, 1.0f);
const uint32_t hpCol = (hpK > 0.5f) ? RGBA(150, 200, 130)
: (hpK > 0.25f) ? RGBA(220, 190, 90)
: RGBA(220, 80, 70);
fb.FillRect(x + 10, y0 + 4, BAR_W, 5, COL_EMPTY);
fb.FillRect(x + 10, y0 + 4, int(float(BAR_W) * hpK), 5, hpCol);
// Броня: тонкая полоска под здоровьем. Её нет — нет и полоски.
if (a.armor > 0.0f)
fb.FillRect(x + 10, y0 + 10, int(float(BAR_W) * Clampf(a.armor / ARMOR_CAP, 0.0f, 1.0f)),
2, RGBA(120, 150, 190));
// Нагрузка. Полоска появляется ТОЛЬКО за нормой переноски: пока боец
// идёт как шёл, показывать нечего, а лишняя линия в каждой карточке
// отняла бы внимание у здоровья и патронов.
//
// Она растёт от нормы к пределу и там же кончается — это и есть шкала
// «насколько ты уже обоз».
if (a.load > CARRY_LIMIT)
{
const float over = Clampf((a.load - CARRY_LIMIT) / (CARRY_MAX - CARRY_LIMIT),
0.0f, 1.0f);
fb.FillRect(x + 10, y0 + 12, BAR_W, 2, COL_EMPTY);
fb.FillRect(x + 10, y0 + 12, int(float(BAR_W) * over), 2,
(over > 0.85f) ? RGBA(220, 90, 60) : RGBA(200, 160, 70));
}
// Оружие: патроны или фаза клинка.
if (a.IsMelee()) DrawMeleeState(fb, x + 10, y0 + 14, a, col);
else
{
DrawAmmo(fb, x + 10, y0 + 14, a, col);
DrawReserve(fb, x + 10, y0 + 19, a);
}
// Иконка того, что в руках.
fb.Blit(game.sprites.Icon(a.held), x + CARD_W - 12, y0 + CARD_H / 2);
// Есть цель — уголок над карточкой. Отряд, который никого не видит,
// должен отличаться от отряда, который работает.
const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane;
if (engaged) fb.FillRect(x + CARD_W / 2 - 3, y0 - 3, 6, 2, RGBA(230, 220, 200));
}
}

21
src/render/hud.h Normal file
View File

@ -0,0 +1,21 @@
#pragma once
// -----------------------------------------------------------------------------
// HUD отряда. Рисуется В ТОТ ЖЕ софтверный фреймбуфер, что и мир, обычными
// примитивами — значит, попадает под ту же сетку пикселей и не выбивается из
// картинки. Текста здесь нет ни одной буквы: на 480x270 шрифт либо нечитаем,
// либо занимает четверть экрана. Всё, что нужно знать в бою, показано формой
// и цветом:
//
// цвет полосы = номер бойца (тот же, что у его тела);
// длина полосы = здоровье;
// квадратики = патроны в магазине, гаснут по одному;
// полоса-заливка вместо квадратиков = откат клинка;
// иконка справа = что в руках.
//
// Слова живут в меню и на экране отряда (src/ui/menu.cpp) — там есть место.
// -----------------------------------------------------------------------------
#include "engine/framebuffer.h"
struct Game;
void DrawHud(Framebuffer& fb, const Game& game);

View File

@ -1,4 +1,5 @@
#include "render/lighting.h"
#include "engine/projection.h"
namespace
{
@ -88,15 +89,25 @@ void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float
for (int i = 0; i < MAP_W * MAP_H; ++i) visRaw[i] = 0.0f;
// --- 1. видимость пола от каждого агента ---------------------------------
const float rFull = g_tune.sightFullRadius;
const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius);
const float coneHalf = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
const float coneSoft = g_tune.sightConeSoftDeg * DEG2RAD_F;
const float personal = g_tune.sightPersonalRadius;
for (int a = 0; a < count; ++a)
{
const Agent& ag = agents[a];
if (!ag.alive) continue; // павший больше не смотрит по сторонам
// Радиус обзора СВОЙ у каждого бойца: фонарь в кармане светит дальше,
// саван — ближе (sim/items.h). Без этого экран отряда показывал бы
// «SIGHT 135%», за которым ничего не стоит.
// Глубина сжимает обзор ВСЕМ одинаково, поверх личного множителя:
// фонарь на двадцатом этаже по-прежнему лучше савана, просто оба
// светят хуже, чем на первом.
const float mul = ag.sightMul * sightScale;
const float rFull = g_tune.sightFullRadius * mul;
const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius * mul);
const float personal = g_tune.sightPersonalRadius * mul;
const int x0 = std::max(0, int(std::floor(ag.pos.x - rMax)));
const int x1 = std::min(MAP_W - 1, int(std::floor(ag.pos.x + rMax)));
const int y0 = std::max(0, int(std::floor(ag.pos.y - rMax)));
@ -271,12 +282,32 @@ ShadeQuad Lighting::QuadFor(int tx, int ty, int clampRow) const
const float Bv = c01 - c00;
const float K = c00 - c10 - c01 + c11;
const float tw = float(g_proj.tileW);
const float th = float(g_proj.tileH);
ShadeQuad q;
q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f;
q.b = (Bu - Bv + K) / float(TILE_W);
q.c = (Bu + Bv) / float(TILE_H);
q.e = -K / float(TILE_W * TILE_W);
q.f = K / float(TILE_H * TILE_H);
q.clampRow = clampRow;
if (g_proj.mode == ViewMode::TOP_DOWN)
{
// Плита — квадрат, углы лежат по сторонам света: c00 в (0,0), c10 в
// (tw,0), c01 в (0,th). Косой член K тут остался бы честным слагаемым
// u*v, а ShadeQuad такого не умеет — у неё только px, py, px^2, py^2.
// Поэтому он отбрасывается: на тайле в 32 пикселя это доли ступени
// рампы, а размен на лишний член в горячем блите того не стоит.
q.a = c00;
q.b = Bu / tw;
q.c = Bv / th;
return q;
}
// Изометрия: те же четыре угла попадают в вершины ромба, и билинейка,
// записанная в координатах спрайта, честно раскладывается по этим членам —
// поэтому на общем ребре соседних тайлов значения совпадают и швов нет.
q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f;
q.b = (Bu - Bv + K) / tw;
q.c = (Bu + Bv) / th;
q.e = -K / (tw * tw);
q.f = K / (th * th);
return q;
}

View File

@ -6,7 +6,7 @@
// Почему свет НЕ делается пост-проходом по пикселям:
// спрайт стены имеет привязку ay = TILE_HALF_H + TILE_H = 24, то есть верхняя
// грань поднята на 16 px над ромбом своего тайла. Экранный пиксель крыши,
// прогнанный обратно через iso::ScreenToWorld, даёт ScreenToWorld(0,-16) =
// прогнанный обратно через v2d::ScreenToWorld, даёт ScreenToWorld(0,-16) =
// ровно (-1,-1) тайла, то есть СОСЕДНИЙ тайл на северо-запад. Пост-проход
// осветил бы крыши чужой видимостью: чёрные крыши на освещённых стенах.
// Поэтому свет вплавляется в блит спрайта: спрайт целиком светится от СВОЕГО
@ -18,12 +18,12 @@
// видно сейчас -> нормальная яркость;
// вспышка выстрела -> верх рампы, тёплый пересвет.
// -----------------------------------------------------------------------------
#include "core/math.h"
#include "render/framebuffer.h"
#include "engine/math.h"
#include "engine/framebuffer.h"
#include "sim/agent.h"
#include "sim/events.h"
#include "tuning.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
// Свет протекает вглубь блока стен: разделитель y=13..15 толщиной 3 тайла иначе
// остаётся чёрным изнутри, и его южная грань висит в пустоте.
@ -52,6 +52,16 @@ struct Lighting
bool enabled = true; // F5 — аварийно выключить туман целиком
// Общее сжатие обзора на этом этаже. Чем глубже, тем меньше видно — и это
// главное, чем спуск становится страшнее: не новые спрайты, а сужающийся
// круг вокруг отряда. Значение приходит из кривой глубины
// (engine/progression.h) через View; сама Lighting про глубину не знает.
//
// Живёт здесь, а не в g_tune: tuning общий на весь запуск, и запись туда
// означала бы, что «этаж 12» подкрутил ручку, с которой потом работает
// и первый этаж, и песочница приёмки.
float sightScale = 1.0f;
void Reset();
// Забрать вспышки из журнала боя (то, что накопилось за кадр).

View File

@ -10,4 +10,4 @@
// Состояния нет ни у кого: значение зависит только от аргументов (координата,
// номер кадра), общего генератора нет и синхронизировать нечего.
// -----------------------------------------------------------------------------
#include "core/hash.h"
#include "engine/hash.h"

View File

@ -30,6 +30,9 @@ void Dread::Update(const DreadInput& in, float frameDt)
float target = Clampf(0.45f * near_ + 0.75f * unseen + 0.25f * crowd + 0.30f * blind,
0.0f, 1.0f);
if (in.friendlyDelta > 0) target = 1.0f; // попали по своему — максимум
// Бойца грызут прямо сейчас — не время для «спокойной» виньетки.
if (in.squadHurtDelta > 0.0f) target = std::max(target, 0.85f);
if (in.squadDownDelta > 0) target = 1.0f;
// Асимметрия: страх набегает быстро, отпускает медленно.
const float rate = (target > level) ? g_tune.dreadAttack : g_tune.dreadRelease;
@ -61,6 +64,20 @@ void Dread::Update(const DreadInput& in, float frameDt)
pulse = std::min(1.0f, pulse + 0.35f * float(in.meleeHitsDelta));
}
// Удар ПО отряду трясёт сильнее всего: это единственное событие, после
// которого игрок обязан посмотреть на HUD.
if (in.squadHurtDelta > 0.0f)
{
const float k = Clampf(in.squadHurtDelta / 25.0f, 0.15f, 1.0f);
shake = std::min(g_tune.shakeMax, shake + g_tune.meleeShake * 1.4f * k);
pulse = std::min(1.0f, pulse + 0.5f * k);
}
if (in.squadDownDelta > 0)
{
shake = g_tune.shakeMax;
pulse = 1.0f;
}
shake *= std::exp(-g_tune.shakeDecay * frameDt);
pulse *= std::exp(-6.0f * frameDt);
}

View File

@ -9,8 +9,8 @@
#include <cstdint>
#include <vector>
#include "core/math.h"
#include "render/framebuffer.h"
#include "engine/math.h"
#include "engine/framebuffer.h"
// Что кадр знает о происходящем — собирается из const Game& в RenderGame.
struct DreadInput
@ -24,6 +24,10 @@ struct DreadInput
int killsDelta = 0;
int friendlyDelta = 0;
int meleeHitsDelta = 0; // ударов клинком за кадр
// Досталось СВОИМ. Отдельно от friendlyDelta: то — нарушение правила 6.9,
// это — обычный ход боя, но кадр должен дёрнуться и в том, и в другом.
float squadHurtDelta = 0.0f; // сколько здоровья отряд потерял за кадр
int squadDownDelta = 0; // сколько бойцов упало за кадр
};
// Напряжение: медленно отпускает, быстро нарастает. Кормит виньетку, зерно,

View File

@ -3,9 +3,12 @@
namespace
{
// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь.
constexpr int CULL_MARGIN_X = TILE_W;
constexpr int CULL_MARGIN_Y = WALL_H + TILE_H;
// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь. Считается
// по САМОМУ БОЛЬШОМУ тайлу из возможных, а не по текущей проекции: это верхняя
// оценка, и запас, взятый с избытком, стоит нескольких лишних блитов по краю —
// заниженный стоит мигающих спрайтов на кромке кадра.
constexpr int CULL_MARGIN_X = TILE_MAX_W;
constexpr int CULL_MARGIN_Y = WALL_H + TILE_MAX_H;
inline bool OffScreen(int sx, int sy)
{
@ -15,7 +18,7 @@ inline bool OffScreen(int sx, int sy)
} // namespace
void Scene::Begin(const iso::Camera& cam)
void Scene::Begin(const v2d::Camera& cam)
{
items.clear();
cam.ScreenOffset(ox, oy);
@ -23,13 +26,13 @@ void Scene::Begin(const iso::Camera& cam)
void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias)
{
const Vec2 s = iso::WorldToScreen(world);
const Vec2 s = v2d::WorldToScreen(world);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - pixelLift;
if (OffScreen(sx, sy)) return;
iso::DrawItem it;
it.depth = iso::Depth(world) + depthBias;
v2d::DrawItem it;
it.depth = v2d::Depth(world) + depthBias;
it.spr = spr;
it.x = sx;
it.y = sy;
@ -39,13 +42,13 @@ void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias)
void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift,
float depthBias)
{
const Vec2 s = iso::WorldToScreen(world);
const Vec2 s = v2d::WorldToScreen(world);
const int sx = int(std::lround(s.x)) + ox;
const int sy = int(std::lround(s.y)) + oy - pixelLift;
if (OffScreen(sx, sy)) return;
iso::DrawItem it;
it.depth = iso::Depth(world) + depthBias;
v2d::DrawItem it;
it.depth = v2d::Depth(world) + depthBias;
it.spr = spr;
it.x = sx;
it.y = sy;
@ -56,8 +59,8 @@ void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int
void Scene::Flush(Framebuffer& fb)
{
iso::SortByDepth(items);
iso::FlushDrawList(fb, items);
v2d::SortByDepth(items);
v2d::FlushDrawList(fb, items);
}
// Ниже этого уровня рампа даёт ступень 0, то есть чистый чёрный.
@ -73,14 +76,14 @@ void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, in
if (map.IsWall(tx, ty)) continue; // под стеной пол не виден
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
const Vec2 c = iso::WorldToScreen({tx + 0.5f, ty + 0.5f});
const Vec2 c = v2d::WorldToScreen({tx + 0.5f, ty + 0.5f});
const int sx = int(std::lround(c.x)) + ox;
const int sy = int(std::lround(c.y)) + oy;
if (OffScreen(sx, sy)) continue;
const Sprite* stained = blood ? blood->At(tx, ty) : nullptr;
const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty);
fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, TILE_H - 1));
const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty, map.ThemeAt(tx, ty));
fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, g_proj.tileH - 1));
}
}
}
@ -95,7 +98,7 @@ void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map,
if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue;
// clampRow = TILE_H-1: вертикальные грани светятся как нижняя кромка
// крыши, а не как экстраполяция патча на 32 строки вниз.
scene.AddShaded(&sprites.wall, {tx + 0.5f, ty + 0.5f},
light.QuadFor(tx, ty, TILE_H - 1));
scene.AddShaded(&sprites.WallTile(tx, ty, map.ThemeAt(tx, ty)), {tx + 0.5f, ty + 0.5f},
light.QuadFor(tx, ty, g_proj.tileH - 1));
}
}

View File

@ -6,19 +6,19 @@
#include <vector>
#include "render/blood.h"
#include "render/framebuffer.h"
#include "render/iso.h"
#include "engine/framebuffer.h"
#include "engine/view2d.h"
#include "render/sprites.h"
#include "render/lighting.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
struct Scene
{
std::vector<iso::DrawItem> items;
std::vector<v2d::DrawItem> items;
int ox = 0; // смещение камеры в пикселях (целое)
int oy = 0;
void Begin(const iso::Camera& cam);
void Begin(const v2d::Camera& cam);
// world — мировая позиция «подошвы» сущности;
// pixelLift — подъём спрайта по экрану (пули летят не по земле);

View File

@ -1,97 +1,18 @@
#include "render/sprites.h"
#include "core/math.h"
#include <utility>
#include "engine/hash.h"
#include "engine/math.h"
#include "engine/sfa_library.h"
#include "monster_sprites.h"
namespace
{
// Занятая часть строки y для ромба 32x16.
void DiamondRow(int y, int& x0, int& x1)
{
const int k = (y < TILE_HALF_H) ? y : (TILE_H - 1 - y);
const int width = 4 * (k + 1);
x0 = TILE_HALF_W - width / 2;
x1 = x0 + width;
}
// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный
// 4-сосед (или край спрайта), перекрашивается в outline.
void OutlineSilhouette(Sprite& s, uint32_t outline)
{
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 border =
x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 ||
AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 ||
AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0;
if (border) s.Set(x, y, outline);
}
}
}
Sprite MakeFloor(uint32_t base)
{
Sprite s;
s.Init(TILE_W, TILE_H, 0);
s.ax = TILE_HALF_W;
s.ay = TILE_HALF_H;
const uint32_t edge = ShadeColor(base, 0.78f);
const uint32_t fleck = ShadeColor(base, 1.18f);
for (int y = 0; y < TILE_H; ++y)
{
int x0 = 0, x1 = 0;
DiamondRow(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;
}
Sprite MakeWall(uint32_t top)
{
Sprite s;
s.Init(TILE_W, WALL_H, 0);
s.ax = TILE_HALF_W;
s.ay = TILE_HALF_H + TILE_H; // низ блока садится на ромб пола
const uint32_t left = ShadeColor(top, 0.62f);
const uint32_t right = ShadeColor(top, 0.80f);
// верхняя грань
for (int y = 0; y < TILE_H; ++y)
{
int x0 = 0, x1 = 0;
DiamondRow(y, x0, x1);
for (int x = x0; x < x1; ++x) s.Set(x, y, top);
}
// вертикальные грани: от нижней кромки ромба вниз на высоту тайла
for (int x = 0; x < TILE_W; ++x)
{
int bottom = -1;
for (int y = 0; y < TILE_H; ++y)
if (AlphaOf(s.Get(x, y)) != 0) bottom = y;
if (bottom < 0) continue;
const uint32_t side = (x < TILE_HALF_W) ? left : right;
for (int y = bottom + 1; y <= bottom + TILE_H && y < WALL_H; ++y)
s.Set(x, y, side);
}
OutlineSilhouette(s, ShadeColor(top, 0.35f));
return s;
}
// DiamondRowN / DiamondRow / OutlineSilhouette / ShadeColor переехали в
// engine/pixel.h: ими рисует и движок (тайлы пола и стен), и игра. Две копии
// наклона ромба означали бы обстановку в одной изометрии, а пол — в другой.
// Капсула с обводкой и бликом.
Sprite MakeCapsule(int w, int h, float radius, uint32_t body)
@ -126,77 +47,732 @@ Sprite MakeCapsule(int w, int h, float radius, uint32_t body)
return s;
}
// Боец ближнего боя: та же капсула, но шире в плечах и с клинком у бедра.
// На 480x270 класс обязан читаться силуэтом — цвет уже занят номером агента.
Sprite MakeMeleeCapsule(int w, int h, float radius, uint32_t body)
{
Sprite s = MakeCapsule(w, h, radius, body);
// --- оружие в руках ----------------------------------------------------------
//
// Ствол и клинок дорисовываются ПОВЕРХ капсулы по правому краю силуэта: на
// 14 пикселях иначе не видно ничего. Разница между длинным и тяжёлым стволом —
// два пикселя толщины и один длины, и этого достаточно: игрок различает их
// не разглядывая, а по общему пятну.
const uint32_t steel = RGBA(196, 206, 222);
const uint32_t edge = RGBA(240, 246, 255);
const uint32_t kSteel = RGBA(168, 176, 190);
const uint32_t kSteelDark = RGBA(74, 78, 88);
const uint32_t kEdge = RGBA(238, 244, 255);
// Клинок: короткая вертикаль вдоль правого края силуэта, с бликом.
const int bx = w - 2;
for (int y = 2; y < h - 4; ++y)
void DrawBarrel(Sprite& s, int len, int thick, bool heavy)
{
s.Set(bx, y, steel);
if (y < h / 2) s.Set(bx, y, edge);
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 градусов
}
// Гарда, чтобы клинок не читался как случайная полоска.
s.Set(bx - 1, h - 5, steel);
s.Set(bx, h - 5, steel);
return s;
}
// Стрелок с пистолетом: та же капсула, но уже в плечах и с коротким стволом
// у пояса. Класс оружия обязан читаться силуэтом — цвет уже занят номером.
Sprite MakePistolCapsule(int w, int h, float radius, uint32_t body)
// --- твари -------------------------------------------------------------------
//
// Не капсулы. Тварь на 480x270 занимает 14-24 пикселя, и опознаётся она ПЯТНОМ:
// сначала силуэт, потом цвет, детали — никогда. Поэтому каждый вид строится как
// объединение эллипсов (голова, корпус, конечности), а объём даёт не «блик
// слева», а свет по КРАЮ силуэта: подсвечена верхне-левая кромка, затенена
// нижне-правая. Тот же приём, что у стены, — и твари садятся в один свет с ней.
// Эллипс силуэта. Отрицательный ry допустим только по недосмотру, поэтому он
// зажат: вырожденный эллипс молча съел бы часть тела.
struct Blob
{
Sprite s = MakeCapsule(w, h, radius, body);
float cx, cy, rx, ry;
};
const uint32_t steel = RGBA(150, 156, 168);
const uint32_t dark = RGBA(70, 74, 84);
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;
}
// Коротышка-ствол: две пиксельных полоски у бедра, с рукоятью вниз.
const int bx = w - 2;
const int by = h - 7;
s.Set(bx, by, steel);
s.Set(bx - 1, by, steel);
s.Set(bx, by + 1, dark);
s.Set(bx, by + 2, dark);
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 ShadeColor(uint32_t c, float k)
uint32_t SlotColor(int slot)
{
const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f);
const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f);
const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f);
return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c));
}
void SpriteSet::Generate()
{
floorA = MakeFloor(RGBA(50, 54, 62));
floorB = MakeFloor(RGBA(42, 46, 53));
wall = MakeWall(RGBA(78, 84, 96));
// 5 различимых цветов — по одному на агента
static const uint32_t agentCols[NUM_AGENTS] = {
// 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),
};
for (int i = 0; i < NUM_AGENTS; ++i)
{
agent[i] = MakeCapsule(10, 14, 4.0f, agentCols[i]);
melee[i] = MakeMeleeCapsule(12, 15, 4.6f, agentCols[i]);
pistol[i] = MakePistolCapsule(9, 13, 3.6f, agentCols[i]);
return cols[(slot >= 0 && slot < NUM_AGENTS) ? slot : 0];
}
target = MakeCapsule(12, 16, 5.0f, RGBA(120, 44, 44));
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;
}

View File

@ -1,31 +1,117 @@
#pragma once
// -----------------------------------------------------------------------------
// Процедурная генерация всех спрайтов при старте. Файлов с ассетами нет.
// Процедурная генерация всех спрайтов при старте: базовый набор не зависит ни
// от одного файла на диске.
//
// Тайлы можно подменить на собранные SpriteForge (LoadTiles, dev-режим
// --sf-tiles). Бойцы, твари и иконки остаются процедурными сознательно: на
// 480x270 боец занимает 14 пикселей, и цвет там занят НОМЕРОМ слота, а силуэт —
// КЛАССОМ оружия. И то, и другое живёт в коде, а не в манифесте
// (см. docs/12-assets.md).
//
// Правило чтения картинки, из которого всё вытекает:
// * номер бойца -> цвет тела;
// * класс оружия -> силуэт (ствол/клинок, длинный/короткий/тяжёлый);
// * вид твари -> и силуэт, и цвет; их всего пять, и путать их нельзя.
// -----------------------------------------------------------------------------
#include "render/framebuffer.h"
#include "engine/hash.h"
#include "engine/pixel.h"
#include "engine/tileset.h"
#include "engine/framebuffer.h"
#include "sim/items.h"
#include "sim/target.h"
#include "tuning.h"
class SpriteLibrary;
// Силуэт бойца. Не «какое именно оружие», а «как оно читается»: карабин и
// автомат на 14 пикселях — один и тот же длинный ствол, и притворяться, что
// игрок их различит, бессмысленно.
enum class BodyLook : uint8_t
{
GUN_LONG = 0, // карабин, автомат
GUN_SHORT, // пистолет
GUN_HEAVY, // дробовик, магнум: толстый ствол, широкие плечи
BLADE_LONG, // сабля, тесак
BLADE_SHORT, // нож
COUNT,
};
BodyLook LookOf(ItemId weapon);
struct SpriteSet
{
Sprite floorA; // шахматка пола, вариант 1
Sprite floorB; // вариант 2
Sprite wall; // блок 32x32
Sprite agent[NUM_AGENTS]; // стрелок: по цвету на агента
Sprite melee[NUM_AGENTS]; // тот же цвет, но силуэт с клинком
Sprite target;
// Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp).
// Пол и стены живут в движке: ими рисует и игра, и редактор комнат
// (engine/tileset.h). Здесь остаётся только то, что знает про правила, —
// бойцы, твари и иконки предметов.
TileSet tiles;
Sprite pistol[NUM_AGENTS]; // тот же цвет, но силуэт с коротким стволом
// Тело бойца: силуэт по оружию x цвет по слоту.
Sprite body[int(BodyLook::COUNT)][NUM_AGENTS];
// Павший: тот же цвет, но лежит. Отряд обязан видеть, кого потерял.
Sprite corpse[NUM_AGENTS];
// Твари. DUMMY — прежняя мишень приёмки, её вид не менялся.
Sprite enemy[int(EnemyKind::COUNT)];
Sprite target; // синоним enemy[DUMMY], чтобы не ломать вызовы
// Анимированный вариант твари: ID ассета SpriteForge, 0 — «нет, рисуй
// процедурный». Кадры здесь НЕ хранятся: они лежат в SpriteLibrary в одном
// экземпляре на ассет, и рендер достаёт нужный по времени клипа.
//
// Пока клип один — цикл шага. Появятся idle/attack — это будут соседние
// массивы и соседние ассеты <kind>_idle, а не вторая анимация внутри
// этого: бэкенд import имён клипов не читает (assets/manifests/enemies.yaml).
uint32_t enemyWalk[int(EnemyKind::COUNT)] = {};
// Иконки предметов для инвентаря и HUD.
Sprite icon[int(ItemId::COUNT)];
// Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp).
void Generate();
// Какой ромб пола лежит на тайле. Одно место на весь рендер: запечённая
// кровь копирует ИМЕННО его, иначе шахматка поедет (render/blood.h).
const Sprite& FloorTile(int tx, int ty) const
// Заменить пол и стену на тайлы из библиотеки SpriteForge. Всё или ничего:
// при любой осечке набор остаётся тем, что дал Generate(), и возвращается
// false. Вызывать только после Generate().
bool LoadTiles(const SpriteLibrary& lib);
// Включить анимированных тварей из библиотеки. Тоже всё или ничего: одна
// анимированная тварь среди пяти статичных выглядит хуже пяти статичных.
// Мишень приёмки (DUMMY) и обстановка не анимируются никогда.
bool LoadEnemies(const SpriteLibrary& lib);
const Sprite& AgentBody(int slot, ItemId weapon) const
{
return ((tx + ty) & 1) ? floorA : floorB;
const int s = (slot >= 0 && slot < NUM_AGENTS) ? slot : 0;
return body[int(LookOf(weapon))][s];
}
const Sprite& EnemyBody(EnemyKind kind) const
{
const int k = (int(kind) >= 0 && int(kind) < int(EnemyKind::COUNT)) ? int(kind) : 0;
return enemy[k];
}
const Sprite& Icon(ItemId id) const
{
const int i = (int(id) >= 0 && int(id) < int(ItemId::COUNT)) ? int(id) : 0;
return icon[i];
}
// Какой ромб пола и какой блок стены лежат на тайле. Одно место на весь
// рендер: запечённая кровь копирует ИМЕННО этот ромб, иначе шахматка
// поедет (render/blood.h). Тему тайл несёт сам (Tilemap::ThemeAt).
const Sprite& FloorTile(int tx, int ty, uint8_t theme) const
{
return tiles.Floor(tx, ty, theme);
}
const Sprite& WallTile(int tx, int ty, uint8_t theme) const
{
return tiles.Wall(tx, ty, theme);
}
};
// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется).
uint32_t ShadeColor(uint32_t c, float k);
// Цвет слота отряда: номер бойца читается по цвету и больше ничем.
uint32_t SlotColor(int slot);

View File

@ -2,12 +2,69 @@
#include "game.h"
#include "render/noise.h"
#include "engine/projection.h"
#include "engine/sfa_library.h"
#include "agent_paperdoll.h"
#include "monster_sprites.h"
namespace
{
constexpr uint32_t COL_BG = RGBA(0, 0, 0); // ступень 0 рампы == цвет очистки
// Насколько тварь должна сдвинуться за кадр, чтобы считаться идущей. Ниже —
// дрожание от расталкивания толпы (CrowdPush), под которое шаг проигрывать
// незачем: стоящая в куче тварь перебирала бы ногами на месте.
constexpr float ENEMY_WALK_EPS = 0.004f;
// На сколько приподнята иконка лежащего предмета. Ноль клал бы её ровно на
// линию пола, где она сливается с рантом тайла и с кровью.
constexpr int ITEM_LIFT_PX = 4;
// Какой спрайт рисовать для твари: кадр анимации, если игра запущена с
// собранной библиотекой, иначе процедурный.
//
// Проигрыватель двигается ТОЛЬКО когда тварь реально идёт. Иначе цикл шага
// крутился бы у стоящего плевка, который по замыслу держит дистанцию и никуда
// не идёт, — и вся анимация читалась бы как ошибка.
const Sprite* EnemyFrame(View& view, const SpriteSet& sprites, const Target& t, int index,
float frameDt)
{
const Sprite* fallback = &sprites.EnemyBody(t.kind);
if (!view.forge || index < 0 || index >= MAX_TARGETS) return fallback;
if (t.IsProp() || t.kind == EnemyKind::DUMMY) return fallback;
AnimKind wanted = AnimKind::WALK;
if (!t.alive) wanted = AnimKind::DIE;
else if (t.hurtTimer > 0.0f) wanted = AnimKind::HURT;
else if (t.Telegraphing() || t.Busy()) wanted = AnimKind::ATTACK;
const uint32_t asset = MonsterAsset(t.kind, wanted);
if (asset == 0) return fallback;
const float length = view.forge->AnimationLength(asset, uint32_t(SF_ANIM_DEFAULT));
if (!(length > 0.0f)) return fallback;
Animator& anim = view.enemyAnim[index];
const bool looping = wanted == AnimKind::WALK;
anim.Play(wanted, looping);
const bool moved = view.enemyPosKnown[index] &&
DistanceSq(view.enemyPrevPos[index], t.pos) >
ENEMY_WALK_EPS * ENEMY_WALK_EPS;
view.enemyPrevPos[index] = t.pos;
view.enemyPosKnown[index] = true;
if (wanted != AnimKind::WALK || moved) anim.Advance(frameDt, length);
const int direction = view.forge->DirectionOf(asset, t.facing);
const Sprite* frame = view.forge->Frame(asset, uint32_t(SF_ANIM_DEFAULT), direction,
anim.time, looping);
return frame ? frame : fallback;
}
} // namespace
void View::Init()
{
post.Init(INTERNAL_W, INTERNAL_H);
@ -35,6 +92,17 @@ void View::Reset(const Game& game)
prevKills = game.metrics.targetsKilled;
prevFriendly = game.metrics.friendlyHits;
prevMeleeHits = game.metrics.meleeHits;
prevSquadHp = -1.0f; // после сброса дельта здоровья считается заново
prevSquadAlive = NUM_AGENTS;
// Твари в новом сценарии стоят по своим местам: фаза шага, доехавшая из
// прошлого прогона, дала бы рывок в первом же кадре.
for (int i = 0; i < MAX_TARGETS; ++i)
{
enemyAnim[i] = Animator{};
enemyPosKnown[i] = false;
}
for (int i = 0; i < NUM_AGENTS; ++i) agentAnim[i] = Animator{};
}
void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt)
@ -68,6 +136,21 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
view.prevFriendly = game.metrics.friendlyHits;
view.prevMeleeHits = game.metrics.meleeHits;
float squadHp = 0.0f;
int squadAlive = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
squadHp += squad.agents[i].hp;
if (squad.agents[i].alive) ++squadAlive;
}
if (view.prevSquadHp >= 0.0f)
{
di.squadHurtDelta = std::max(0.0f, view.prevSquadHp - squadHp);
di.squadDownDelta = std::max(0, view.prevSquadAlive - squadAlive);
}
view.prevSquadHp = squadHp;
view.prevSquadAlive = squadAlive;
// «Отряд не слеп» — это и линия огня, и клинок в зоне удара: боец,
// достающий врага, беспомощным не выглядит и напряжение снимает так же.
for (int i = 0; i < NUM_AGENTS; ++i)
@ -78,7 +161,7 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
for (const Target& t : game.targets)
{
if (!t.alive) continue;
if (!t.alive || t.IsProp()) continue; // ящик в темноте никого не пугает
const float d = Distance(t.pos, squad.anchor);
di.nearestTargetDist = std::min(di.nearestTargetDist, d);
if (d < g_tune.dreadRadius) ++di.targetsNear;
@ -90,6 +173,10 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
// --- свет ------------------------------------------------------------------
view.lighting.enabled = view.horrorEnabled;
// Глубина сжимает обзор. Ставится каждый кадр, а не при смене этажа:
// View переживает Reset, и забытое значение уехало бы вниз вместе с
// отрядом и осталось бы там навсегда.
view.lighting.sightScale = game.level.floor.sightScale;
view.lighting.Update(squad.agents.data(), NUM_AGENTS, game.map, frameDt);
// --- сцена -----------------------------------------------------------------
@ -110,35 +197,160 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
&view.blood);
CollectWalls(view.scene, game.sprites, game.map, view.lighting);
// Спуск и эвакуация. Рисуются ДО всего остального и всегда, даже в полной
// темноте: обе цели этажа обязаны быть видны с порога, иначе игра
// превращается в обшаривание стен. Пульсация — чтобы их не путали с
// очередным пятном на полу, разный цвет — чтобы не путали между собой.
//
// Зелёное — вниз, глубже. Голубое — наверх, к выходу с добычей. Спутать их
// означает уйти не туда, и цена ошибки здесь — весь забег.
if (game.levelGenerated)
{
const float pulse = 0.55f + 0.45f * std::sin(game.simTime * 3.0f);
auto ring = [&](Vec2 world, uint8_t rr, uint8_t gg, uint8_t bb) {
const Vec2 s = v2d::WorldToScreen(world);
const int sx = int(std::lround(s.x)) + view.scene.ox;
const int sy = int(std::lround(s.y)) + view.scene.oy;
// Кольцо лежит В ПЛОСКОСТИ ПОЛА, поэтому и форму берёт у плиты:
// в изометрии это ромб, при виде сверху — квадрат. Своя формула
// контура здесь означала бы ромб поверх квадратной плиты — метку,
// висящую в другой проекции, чем пол под ней.
const int insetX = 3;
const int insetY = std::max(1, insetX * g_proj.tileH / g_proj.tileW);
for (int r = 0; r < 3; ++r)
{
const int rw = g_proj.tileW / 2 - r * insetX;
const int rh = g_proj.tileH / 2 - r * insetY;
if (rw <= 1 || rh <= 0) break;
const float k = pulse * (1.0f - float(r) * 0.25f);
const uint32_t c = FXC(uint8_t(float(rr) * k), uint8_t(float(gg) * k),
uint8_t(float(bb) * k));
// Контур сжатой копии плиты: слева и справа от середины строки,
// а на верхней и нижней строке — вся строка целиком. Без этого
// у квадрата не было бы горизонтальных сторон: у ромба они
// получаются сами собой из ступенек, у квадрата — нет.
for (int y = 0; y < g_proj.tileH; ++y)
{
int x0 = 0, x1 = 0;
g_proj.TileRow(y, x0, x1);
const int halfRow = (x1 - x0) / 2;
const int i = (halfRow * rw) / std::max(1, g_proj.tileW / 2);
const int dy = ((y - g_proj.tileH / 2) * rh) / std::max(1, g_proj.tileH / 2);
if (i <= 0) continue;
if (y == 0 || y == g_proj.tileH - 1)
for (int x = -i; x <= i; ++x) fb.AddPx(sx + x, sy + dy, c);
else
{
fb.AddPx(sx - i, sy + dy, c);
fb.AddPx(sx + i, sy + dy, c);
}
}
}
};
ring(game.level.exitPos, 30, 90, 45); // вниз
ring(game.level.extractPos, 40, 80, 110); // наверх
}
// Лежащие предметы. Свет читается как у тварей, но с меньшим порогом:
// находка в темноте — это тусклый отблеск, который замечаешь, проходя мимо,
// а не то, что видно через полкарты.
for (const FloorItem& it : game.floorItems)
{
const float lit = view.lighting.LightAt(it.pos);
if (lit < g_tune.fogEntityReveal * 0.5f) continue;
view.scene.AddShaded(&game.sprites.Icon(it.id), it.pos,
ShadeQuad::Flat(std::max(lit, 0.22f)), ITEM_LIFT_PX);
}
// Мишени: показываются только там, где светло, либо если по ним уже работает
// кто-то из отряда (иначе стрельба в темноту выглядит абсурдом).
for (int i = 0; i < int(game.targets.size()); ++i)
{
const Target& t = game.targets[i];
if (!t.alive) continue;
if (!t.alive && (!view.forge || t.IsProp())) continue;
const float lit = view.lighting.LightAt(t.pos);
bool engaged = false;
for (int a = 0; a < NUM_AGENTS; ++a)
if (squad.agents[a].currentTargetId == i) { engaged = true; break; }
// Тело — по реальному свету: в темноте мишени физически не видно.
if (lit >= g_tune.fogEntityReveal || engaged)
view.scene.AddShaded(&game.sprites.target, t.pos,
ShadeQuad::Flat(std::max(lit, engaged ? 0.30f : 0.0f)));
// Тело — по реальному свету: в темноте твари физически не видно.
if (lit < g_tune.fogEntityReveal && !engaged) continue;
float level = std::max(lit, engaged ? 0.30f : 0.0f);
// Замах — единственный телеграф удара, и он обязан быть виден ДАЖЕ
// в темноте: иначе смерть бойца выглядит случайной.
if (t.Telegraphing()) level = std::max(level, 0.85f);
else if (t.hurtTimer > 0.0f) level = std::max(level, 0.55f);
view.scene.AddShaded(EnemyFrame(view, game.sprites, t, i, frameDt), t.pos,
ShadeQuad::Flat(level));
}
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
if (!a.alive)
{
// Павший лежит там, где упал, и светится как обычная геометрия:
// подсвечивать труп «чтобы было видно» — врать игроку.
view.scene.AddShaded(&game.sprites.corpse[i], p,
ShadeQuad::Flat(view.lighting.LightAt(p)));
continue;
}
// Свои всегда читаемы: игрок обязан различать агентов даже в темноте.
const float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight);
float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight);
if (a.hurtTimer > 0.0f) lit = std::max(lit, 0.55f + 0.45f * a.hurtTimer);
// Класс и ствол читаются силуэтом, номер — цветом.
const Sprite& spr = a.IsMelee() ? game.sprites.melee[i]
: (a.gunId == FirearmId::PISTOL) ? game.sprites.pistol[i]
: game.sprites.agent[i];
view.scene.AddShaded(&spr, p, ShadeQuad::Flat(lit));
const ShadeQuad shade = ShadeQuad::Flat(lit);
const Sprite* body = nullptr;
if (view.forge)
{
AnimKind wanted = AnimKind::IDLE;
if (a.fired || (a.IsMelee() && a.melee.state != MeleeState::IDLE))
wanted = AnimKind::ATTACK;
else if (a.Speed() > 0.04f)
wanted = AnimKind::WALK;
Animator& animator = view.agentAnim[i];
animator.Play(wanted, wanted != AnimKind::ATTACK);
uint32_t bodyId = AgentBodyAsset(wanted);
if (view.forge->FrameCount(bodyId) == 0) bodyId = AGENT_BODY_ASSET;
const uint32_t clipAnim = AnimNameHash(wanted);
const float length = view.forge->AnimationLength(bodyId, clipAnim);
animator.Advance(frameDt, length);
const int dir = view.forge->DirectionOf(bodyId, a.facing);
body = view.forge->Frame(bodyId, clipAnim, dir, animator.time, animator.loop);
if (body)
{
const Inventory* inv = game.world.Get<Inventory>(a.self);
uint32_t armorId = inv ? AgentArmorAsset(inv->armor, wanted) : 0;
uint32_t weaponId = inv ? AgentWeaponAsset(inv->weapon, wanted) : 0;
if (armorId && view.forge->FrameCount(armorId) == 0)
armorId = AgentArmorAsset(inv->armor);
if (weaponId && view.forge->FrameCount(weaponId) == 0)
weaponId = AgentWeaponAsset(inv->weapon);
const Sprite* armor = armorId ? view.forge->Frame(armorId, clipAnim, dir, animator.time, animator.loop) : nullptr;
const Sprite* weapon = weaponId ? view.forge->Frame(weaponId, clipAnim, dir, animator.time, animator.loop) : nullptr;
// Rear-facing weapons pass behind the body; front-facing ones pass in front.
const bool rear = dir >= 3 && dir <= 5;
if (rear && weapon) view.scene.AddShaded(weapon, p, shade, 0, -0.0002f);
view.scene.AddShaded(body, p, shade);
if (armor) view.scene.AddShaded(armor, p, shade, 0, 0.0001f);
if (!rear && weapon) view.scene.AddShaded(weapon, p, shade, 0, 0.0002f);
}
}
if (!body)
view.scene.AddShaded(&game.sprites.AgentBody(i, a.held), p, shade);
}
view.scene.Flush(fb);
@ -148,17 +360,20 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
// игрок сначала видит огонёк, и только подойдя — того, кому он принадлежит.
for (const Target& t : game.targets)
{
if (!t.alive) continue;
if (!t.alive || t.IsProp()) continue; // у обстановки глаз нет
if (view.lighting.LightAt(t.pos) < g_tune.eyeRevealLight) continue;
// Фаза пульсации своя у каждой мишени и учащается при низком HP.
// Фаза пульсации своя у каждой твари и учащается при низком HP.
const uint32_t seed = Hash2(uint32_t(int(t.pos.x * 4.0f)), uint32_t(int(t.pos.y * 4.0f)));
const float hpFrac = Clampf(t.hp / std::max(1.0f, g_tune.targetHP), 0.0f, 1.0f);
const float hpFrac = Clampf(t.hp / std::max(1.0f, t.maxHp), 0.0f, 1.0f);
const float rate = Lerpf(3.4f, 1.1f, hpFrac);
const float ph = Hash01(seed) + float(view.post.frame) * (rate / 60.0f);
const float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F);
float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F);
// На замахе глаза вспыхивают ровно: это тот же телеграф, что и тело,
// но его видно раньше — сначала загорается взгляд, потом виден удар.
if (t.Telegraphing()) pulse = 1.6f;
const Vec2 sp = iso::WorldToScreen(t.pos);
const Vec2 sp = v2d::WorldToScreen(t.pos);
const int ex = int(std::lround(sp.x)) + view.scene.ox;
const int ey = int(std::lround(sp.y)) + view.scene.oy - int(g_tune.eyeLiftPx);
fb.AddGlowDisc(ex, ey, int(g_tune.eyeRadiusPx) + 1,

View File

@ -8,20 +8,22 @@
// не могут изменить состояние симуляции. Headless-прогоны (--accept, --headless)
// View не создают вообще, поэтому цифры приёмки остаются цифрами симуляции.
// -----------------------------------------------------------------------------
#include "render/anim.h"
#include "render/atmos.h"
#include "render/blood.h"
#include "render/framebuffer.h"
#include "engine/framebuffer.h"
#include "render/fx.h"
#include "render/iso.h"
#include "engine/view2d.h"
#include "render/lighting.h"
#include "render/post.h"
#include "render/scene.h"
struct Game;
class SpriteLibrary;
struct View
{
iso::Camera cam;
v2d::Camera cam;
Scene scene;
Lighting lighting;
FxSystem fx;
@ -37,9 +39,30 @@ struct View
int prevFriendly = 0;
int prevMeleeHits = 0;
// Здоровье отряда прошлого кадра: урон приходит от тварей россыпью, флага
// «ударили» ни у кого нет, а дельта суммы честно ловит любой источник.
float prevSquadHp = -1.0f;
int prevSquadAlive = NUM_AGENTS;
bool horrorEnabled = true; // F5 — аварийный возврат к прежнему кадру
bool showSight = false; // F6 — показать диски обзора
// --- анимация тварей -------------------------------------------------------
// Библиотека спрайтов, если игру запустили с собранными ассетами. Пусто —
// рисуем процедурные спрайты, и это рабочий режим по умолчанию, а не отказ.
const SpriteLibrary* forge = nullptr;
// Состояние проигрывателя на КАЖДУЮ цель. Живёт в View, а не в Target,
// ровно по правилу этого файла: фаза шага — свойство картинки, и попав в
// симуляцию, она поехала бы в цифрах приёмки.
Animator enemyAnim[MAX_TARGETS];
Animator agentAnim[NUM_AGENTS];
// Где тварь была в прошлом кадре: шаг проигрывается, только когда она идёт.
// Скорости у Target нет (vel — это отдача от удара), поэтому движение
// определяется единственным честным способом — сравнением позиций.
Vec2 enemyPrevPos[MAX_TARGETS];
bool enemyPosKnown[MAX_TARGETS] = {};
// Гамма и усиление меняют САМУ таблицу рампы: без перепечатки ручки молчат.
float rampGamma = -1.0f;
float rampGain = -1.0f;

View File

@ -19,7 +19,7 @@ Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed
for (int i = 0; i < count; ++i)
{
const Agent& other = all[i];
if (&other == &a) continue;
if (&other == &a || !other.alive) continue;
const Vec2 delta = a.pos - other.pos;
const float d = Length(delta);
@ -133,28 +133,108 @@ void AimAlongMotion(Agent& a, float dt)
a.facing = RotateToward(a.facing, AngleOf(a.vel), g_tune.turnRateDeg * DEG2RAD_F * dt);
}
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId)
namespace
{
const ecs::Entity e = world.Create();
// Заполнить компонент оружия по предмету. Клинок и ствол лежат в одной записи:
// какой из них рабочий, решает cls.
WeaponItem MakeWeaponItem(ItemId id)
{
const ItemDef def = ItemDefOf(id);
WeaponItem item;
item.cls = cls;
item.gunId = gunId;
item.gun = FirearmDefOf(gunId);
item.blade = DefaultBladeDef();
world.Add<WeaponItem>(e, item);
item.cls = (def.kind == ItemKind::BLADE) ? WeaponClass::MELEE : WeaponClass::RANGED;
item.gunId = def.gun;
item.bladeId = def.blade;
item.gun = FirearmDefOf(def.gun);
item.blade = BladeDefOf(def.blade);
return item;
}
} // namespace
ecs::Entity SpawnWeapon(ecs::World& world, ItemId id)
{
const ecs::Entity e = world.Create();
world.Add<WeaponItem>(e, MakeWeaponItem(id));
return e;
}
void SyncLoadout(ecs::World& world, Agent& a)
bool EquipWeapon(ecs::World& world, Agent& a, ItemId id)
{
if (!IsWeaponItem(id)) return false;
WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
if (!item) return false;
*item = MakeWeaponItem(id);
// Смена оружия обрывает всё незавершённое: недорубленный взмах чужим
// клинком и патроны от другого магазина — это чужое состояние.
a.melee.state = MeleeState::IDLE;
a.melee.phase = 0.0f;
a.melee.cooldown = 0.0f;
a.melee.hitMask = 0;
a.melee.comboStep = 0;
a.melee.comboTimer = 0.0f;
a.gun.reloadTimer = 0.0f;
a.gun.cooldown = 0.0f;
a.gun.recoilDeg = 0.0f;
a.gun.ammo = item->gun.magazine;
SyncLoadout(world, a);
return true;
}
bool DamageAgent(Agent& a, float amount)
{
if (!a.alive || amount <= 0.0f) return false;
a.hp -= amount * (1.0f - Clampf(a.armor, 0.0f, ARMOR_CAP));
a.hurtTimer = 0.35f;
if (a.hp > 0.0f) return false;
a.hp = 0.0f;
a.alive = false;
a.vel = {};
a.currentTargetId = -1;
a.hasLane = false;
a.laneIntent = false;
a.melee.state = MeleeState::IDLE;
a.melee.targetId = -1;
return true;
}
void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& buffs)
{
a.bodyRadius = g_tune.bodyRadius;
// Снаряжение: четыре числа и ни одной ручки к правилам боя (sim/items.h).
const Inventory* inv = world.Get<Inventory>(a.self);
if (inv)
{
a.armor = inv->Armor();
// Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе
// скорости: движению всё равно, почему боец медленный. Разделять их
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
a.load = inv->Load() + bagShare;
a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry);
a.sightMul = inv->SightMul() + buffs.sight;
a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp;
a.held = inv->weapon;
if (a.hp > a.maxHp) a.hp = a.maxHp;
}
else
{
a.maxHp = g_tune.agentHP + buffs.hp;
a.load = bagShare;
}
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
if (!item) return; // оружия нет — бойцу нечем ни стрелять, ни бить
a.cls = item->cls;
a.gunId = item->gunId;
a.bladeId = item->bladeId;
// Ствол: статы обновляются целиком, состояние (патроны, откат, увод) — нет.
// Магазин мог смениться вместе с оружием, поэтому патроны подрезаем.

View File

@ -11,12 +11,13 @@
// -----------------------------------------------------------------------------
#include <vector>
#include "core/math.h"
#include "engine/math.h"
#include "ecs/ecs.h"
#include "sim/items.h"
#include "sim/loadout.h"
#include "sim/target.h"
#include "tuning.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
enum class AgentState : uint8_t
{
@ -44,9 +45,49 @@ struct Agent
WeaponClass cls = WeaponClass::RANGED;
FirearmId gunId = FirearmId::RIFLE;
BladeId bladeId = BladeId::SABER;
// Что именно в руках. Рендеру нужен силуэт, HUD — имя; выводить их из
// cls+gunId+bladeId в двух местах значило бы держать правило в двух местах.
ItemId held = ItemId::RIFLE;
Firearm gun; // работает только у RANGED
MeleeWeapon melee; // работает только у MELEE
// --- живучесть ------------------------------------------------------------
// Мёртвый боец остаётся в массиве: NUM_AGENTS — величина времени сборки,
// и по ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его
// из пула значило бы переиндексировать полмира на каждой смерти. Поэтому
// смерть — это флаг, а не удаление; все системы обязаны его проверять.
bool alive = true;
float hp = 0.0f;
float maxHp = 0.0f;
float hurtTimer = 0.0f; // недавно получил урон: для рендера и HUD
// Бойца держит тварь (EnemyKind::LURKER). Не флаг, а таймер, и держащий
// продлевает его каждый шаг: убили тварь — таймер гаснет сам, и никому,
// кроме sim/enemy.cpp, про захват знать не надо.
//
// Захват останавливает НОГИ и только их. Стрелять и рубить боец продолжает:
// правила огня (6.3) и сектор клинка захват не читают вообще — иначе
// единственным выходом из хватки был бы чужой выстрел.
float grabbedTimer = 0.0f;
// Итог снаряжения, пересчитывается в SyncLoadout. Правила боя эти числа
// не читают — только движение, урон и обзор.
float armor = 0.0f; // доля поглощённого урона, 0..ARMOR_CAP
float speedMul = 1.0f;
// Патроны СВЕРХ магазина. Перезарядка тянет отсюда, и это главный ресурс
// вылазки: чем глубже отряд, тем ближе тот момент, когда стрелять станет
// нечем, — давит не время, а исчерпание.
//
// -1 означает «бесконечно» и стоит в ПЕСОЧНИЦЕ приёмки: там на бойцах нет
// снаряжения вообще, а критерии меряют огневую работу за минуту, и запас
// патронов сделал бы их проверкой запаса, а не правил стрельбы.
int ammoReserve = -1;
// Сколько боец несёт. Считается из инвентаря каждый шаг (SyncLoadout) и
// лежит здесь, чтобы HUD и экран отряда не лезли в ECS за компонентом.
float load = 0.0f;
float sightMul = 1.0f;
// --- служебное (рендер/отладка/метрики), на правила не влияет ---
Vec2 prevPos{}; // для интерполяции кадра
bool hasLane = false; // есть валидная линия огня прямо сейчас
@ -59,6 +100,8 @@ struct Agent
float Speed() const { return Length(vel); }
bool Grabbed() const { return grabbedTimer > 0.0f; }
// Полураствор конуса выстрела прямо сейчас. Стоя он почти нулевой, на ходу
// растёт. Ровно этот конус проверяется на своих перед спуском.
float SpreadRad() const { return gun.SpreadRad(Speed()); }
@ -110,10 +153,31 @@ void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const T
void AimAt(Agent& a, Vec2 point, float dt);
void AimAlongMotion(Agent& a, float dt);
// Система: скопировать статы с сущности-оружия и общие размеры из tuning.
// Рабочее состояние (патроны, фаза удара, откат, увод ствола) НЕ трогается.
void SyncLoadout(ecs::World& world, Agent& a);
// Система: скопировать статы с сущности-оружия и снаряжения, общие размеры из
// tuning. Рабочее состояние (патроны, фаза удара, откат, увод) НЕ трогается.
// bagShare — доля общей сумки отряда, приходящаяся на этого бойца. Инвентарь
// про отряд не знает, поэтому долю считает вызывающий (Game::Step).
// Прибавки отряда (улучшения профиля) приходят снаружи по той же причине, что
// и доля сумки: агент про профиль не знает.
struct SquadBuffs
{
float sight = 0.0f; // прибавка к множителю обзора
float hp = 0.0f; // прибавка к запасу здоровья
float carry = 0.0f; // прибавка к норме переноски
};
// Создать сущность оружия нужного класса и вернуть её. Стволы и клинки живут
// в одном пуле WeaponItem: подменить одно другим — это одна запись компонента.
ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId);
void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f,
const SquadBuffs& buffs = SquadBuffs{});
// Создать сущность оружия и вернуть её. Стволы и клинки живут в одном пуле
// WeaponItem: подменить одно другим — это одна запись компонента.
ecs::Entity SpawnWeapon(ecs::World& world, ItemId item);
// Взять предмет в руки: меняет и сущность-оружие, и класс бойца. Не оружие —
// отказ. Возвращает false, если ничего не изменилось.
bool EquipWeapon(ecs::World& world, Agent& a, ItemId item);
// Урон бойцу с учётом брони. Возвращает true, если этот удар его добил.
// Единственное место, где агент теряет здоровье: броня применяется здесь,
// иначе половина источников урона про неё забудет.
bool DamageAgent(Agent& a, float amount);

View File

@ -29,6 +29,30 @@ void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir,
world.Add<Bullet>(b.self, b);
}
void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, float damage,
float range)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
if (int(bullets.size()) >= MAX_BULLETS) return;
Bullet b;
b.self = world.Create();
b.pos = from;
b.prevPos = from;
b.origin = from;
b.vel = Normalized(dir) * speed;
b.damage = damage;
b.knockback = 0.0f;
b.radius = 0.10f; // плевок крупнее пули: его должно быть видно
b.life = range / std::max(0.001f, speed);
b.owner = -1;
b.hostile = true;
b.tracerLength = 0.45f;
b.tracerR = 150; b.tracerG = 230; b.tracerB = 120;
world.Add<Bullet>(b.self, b);
}
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
@ -58,11 +82,12 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
float tWall = 0.0f;
if (map.RaycastBlocked(b.prevPos, b.pos, &tWall)) tBest = tWall;
if (g_tune.friendlyFire)
if (g_tune.friendlyFire || b.hostile)
{
for (int i = 0; i < agentCount; ++i)
{
if (i == b.owner) continue; // своя пуля стрелка не бьёт
if (!agents[i].alive) continue; // упавший больше не тело
if (!b.hostile && i == b.owner) continue; // своя пуля стрелка не бьёт
float t = 0.0f;
const Vec2 cp = ClosestPointOnSegment(agents[i].pos, b.prevPos, b.pos, &t);
if (Distance(cp, agents[i].pos) > agents[i].bodyRadius + br) continue;
@ -75,7 +100,9 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
}
}
for (int i = 0; i < int(targets.size()); ++i)
// Плевок твари по своим не бьёт: у противника нет своих, и мишени
// для него — не цели.
for (int i = 0; !b.hostile && i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
float t = 0.0f;
@ -95,25 +122,46 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
const Vec2 dir = Normalized(b.vel);
if (hitAgent >= 0)
{
if (b.hostile)
{
// Урон от твари — не нарушение 6.9 и в FRIENDLY_HITS не идёт.
const bool down = DamageAgent(agents[hitAgent], b.damage);
if (events)
{
events->Push(CombatEventKind::ENEMY_HIT, hitAgent, impact, dir);
if (down) events->Push(CombatEventKind::AGENT_DOWN, hitAgent,
agents[hitAgent].pos, dir);
}
}
else
{
metrics.LogFriendlyHit(b.owner, hitAgent, agents[hitAgent].pos,
agents[hitAgent].state == AgentState::ADVANCING);
if (events) events->Push(CombatEventKind::HIT_ALLY, b.owner, impact, dir);
}
dead[deadCount++] = b.self;
continue;
}
if (hitTarget >= 0)
{
Target& t = targets[hitTarget];
const bool prop = t.IsProp();
t.hp -= b.damage;
t.hurtTimer = g_tune.targetHurtTime;
KnockTarget(t, dir, b.knockback);
if (events) events->Push(CombatEventKind::HIT_TARGET, b.owner, impact, dir);
if (events)
events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET,
b.owner, impact, dir);
if (t.hp <= 0.0f)
{
t.alive = false;
t.respawnTimer = g_tune.targetRespawn;
++metrics.targetsKilled;
if (events) events->Push(CombatEventKind::HIT_KILL, b.owner, t.pos, dir);
t.respawnTimer = t.RespawnDelay();
// Разбитый ящик — не убитый враг: ни в KILLS, ни в напряжение.
if (!prop) ++metrics.targetsKilled;
if (events)
events->Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL,
b.owner, t.pos, dir);
}
dead[deadCount++] = b.self;
continue;

View File

@ -9,12 +9,12 @@
// -----------------------------------------------------------------------------
#include <vector>
#include "core/math.h"
#include "engine/math.h"
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/events.h"
#include "sim/target.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
struct Metrics;
@ -32,6 +32,11 @@ struct Bullet
float life = 0.0f; // сек до самоуничтожения (дальность / скорость)
int owner = -1;
// Чей снаряд. Пуля отряда ищет мишени (и своих — правило 6.9), плевок твари
// ищет ТОЛЬКО бойцов. Один флаг вместо двух списков: иначе пришлось бы
// дублировать весь проход по отрезку.
bool hostile = false;
// Визуал трассера едет вместе со снарядом: он у каждого ствола свой,
// а к моменту отрисовки стрелок мог уже сменить оружие или умереть.
float tracerLength = 1.3f;
@ -47,6 +52,11 @@ struct Bullet
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
int owner);
// Плевок твари. Летит до своей дальности и бьёт первого встречного бойца.
// Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих.
void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, float damage,
float range);
// events == nullptr в headless-прогонах: эффектов там нет, а правила журнал не читают.
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,

295
src/sim/enemy.cpp Normal file
View File

@ -0,0 +1,295 @@
#include "sim/enemy.h"
#include "sim/bullet.h"
namespace
{
// Ближайший ЖИВОЙ боец. Упавшего тварь не добивает: иначе отряд, потерявший
// одного, теряет всех подряд, пока игрок ничего не может сделать.
int NearestAgent(Vec2 from, const Agent* agents, int count, float maxDist, float* outDist)
{
int best = -1;
float bestD = maxDist;
for (int i = 0; i < count; ++i)
{
if (!agents[i].alive) continue;
const float d = Distance(from, agents[i].pos);
if (d < bestD)
{
bestD = d;
best = i;
}
}
if (outDist) *outDist = bestD;
return best;
}
// Расталкивание тварей между собой. Без него толпа сливается в одну точку и
// вместо десяти тел отряд видит одно — а бьют его при этом десятью ударами.
Vec2 CrowdPush(const Target& self, int selfIndex, const std::vector<Target>& all)
{
Vec2 push{};
for (int i = 0; i < int(all.size()); ++i)
{
if (i == selfIndex) continue;
const Target& o = all[i];
if (!o.alive || o.IsDummy() || o.IsProp()) continue;
const Vec2 d = self.pos - o.pos;
const float gap = self.radius + o.radius;
const float len = Length(d);
if (len >= gap || len < 1e-4f) continue;
push += (d / len) * ((gap - len) / gap);
}
return push;
}
// Зазор, с которым плевок вылетает из пасти, а не из центра тела: стартуй
// снаряд внутри собственной капсулы — и он бы упирался в неё же. Это геометрия
// спавна, а не баланс, поэтому число живёт здесь, а не в каталоге видов.
constexpr float kSpitMuzzleGap = 0.12f;
// Урон бойцу от твари — в одном месте на все пять видов атак: множитель
// сложности, событие попадания и падение. Разложенное по веткам, это правило
// первым же дополнением разъехалось бы на пять слегка разных версий.
void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events)
{
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
if (!events) return;
events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir);
if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir);
}
} // namespace
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events)
{
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
// хвата — продлевать стало некому, и через enemyGrabHold боец свободен.
// В песочнице приёмки этот цикл ничего не делает: там никого не хватают.
for (int i = 0; i < agentCount; ++i)
agents[i].grabbedTimer = std::max(0.0f, agents[i].grabbedTimer - dt);
for (int ti = 0; ti < int(targets.size()); ++ti)
{
Target& t = targets[ti];
// Мишень приёмки не шевелится по определению, обстановка — тем более:
// у ящика нет ни скорости, ни агро, ни удара. Отсекаем их здесь, а не
// полагаемся на нули в каталоге.
if (t.IsDummy() || t.IsProp() || !t.alive) continue;
t.attackTimer = std::max(0.0f, t.attackTimer - dt);
// --- захват уже держит (GRAB) -----------------------------------------
// Пока держит — не ходит, не выбирает цель заново и не замахивается.
// Всё, что у неё есть, уже вцепилось в бойца.
if (t.grabbed >= 0)
{
Agent& held = agents[t.grabbed];
t.grabTimer -= dt;
t.facing = AngleOf(Normalized(held.pos - t.pos, FromAngle(t.facing)));
// Время вышло или боец упал: упавшего твари не добивают, держать
// его тем более незачем.
if (!held.alive || t.grabTimer <= 0.0f)
{
if (events) events->Push(CombatEventKind::ENEMY_RELEASE, t.grabbed, held.pos,
FromAngle(t.facing));
t.grabbed = -1;
t.grabTimer = 0.0f;
t.attackTimer = t.interval;
continue;
}
held.grabbedTimer = std::max(held.grabbedTimer, g_tune.enemyGrabHold);
t.grabTickTimer -= dt;
if (t.grabTickTimer <= 0.0f)
{
t.grabTickTimer += std::max(0.05f, t.grabTick);
HurtAgent(held, t.grabbed, t.damage, FromAngle(t.facing), events);
}
continue;
}
// --- рывок в полёте (LUNGE) -------------------------------------------
// Именно движение по времени, а не телепорт: бросок обязан быть виден,
// иначе непонятно, чем именно снесло бойца.
if (t.lungeTimer > 0.0f)
{
t.lungeTimer -= dt;
const Vec2 from = t.pos;
const float speed = t.lungeDist / std::max(0.02f, t.lungeTime);
t.pos += t.lungeDir * (speed * dt);
map.ResolveCircle(t.pos, t.radius); // сквозь стены не рвётся никто
// Урон всем, кого зацепило ПО ПУТИ, а не одной жертве: рывок —
// это отрезок, и стоять на нём не должно быть безопасно никому.
// Маска считается по индексам бойцов, поэтому и ограничена 32.
for (int i = 0; i < agentCount && i < 32; ++i)
{
if (!agents[i].alive) continue;
if (t.lungeMask & (1u << i)) continue; // каждого — один раз за рывок
if (DistancePointToSegment(agents[i].pos, from, t.pos) >
t.radius + agents[i].bodyRadius)
continue;
t.lungeMask |= (1u << i);
HurtAgent(agents[i], i, t.damage, t.lungeDir, events);
}
if (t.lungeTimer <= 0.0f)
{
t.lungeTimer = 0.0f;
t.attackTimer = t.interval; // откат считается от КОНЦА рывка
}
continue;
}
// --- кого преследуем --------------------------------------------------
float dist = 0.0f;
const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius, &dist);
if (victim >= 0) t.aggro = true; // заметил — уже не забудет
t.victim = victim;
if (victim < 0)
{
// Никого в радиусе: замах в пустоту не доводится.
t.windupTimer = 0.0f;
continue;
}
const Vec2 toVictim = agents[victim].pos - t.pos;
t.facing = AngleOf(Normalized(toVictim, FromAngle(t.facing)));
// --- замах доведён: бьём ----------------------------------------------
// Ось удара НЕ доводится: замахнулся — бьёт туда, куда замахнулся.
// Ровно это даёт игроку возможность увести бойца из-под удара.
if (t.Telegraphing())
{
t.windupTimer -= dt;
if (t.windupTimer > 0.0f) continue; // на замахе тварь стоит
t.windupTimer = 0.0f;
const Vec2 axis = FromAngle(t.facing);
switch (t.attack)
{
// Плевок: единственная атака, которую перекрывает стена, —
// снаряд летит и во что-нибудь упирается.
case AttackKind::SPIT:
SpawnHostileBullet(world, t.pos + axis * (t.radius + kSpitMuzzleGap), axis,
t.projectileSpeed, t.damage * g_tune.enemyDamageScale,
std::max(t.spitRange, t.attackRange));
if (events) events->Push(CombatEventKind::ENEMY_SPIT, ti, t.pos, axis);
t.attackTimer = t.interval;
break;
// Рывок: с этого шага тварь едет по прямой и откат ей встанет
// только в конце броска.
case AttackKind::LUNGE:
t.lungeDir = axis;
t.lungeTimer = t.lungeTime;
t.lungeMask = 0;
if (events) events->Push(CombatEventKind::ENEMY_LUNGE, ti, t.pos, axis);
break;
// Удар оземь: КРУГ вокруг себя. Под него попадает и тот, кто
// просто стоял рядом с жертвой, — разбирать громилу вдвоём
// теперь стоит здоровья обоим.
case AttackKind::SLAM:
{
if (events) events->Push(CombatEventKind::ENEMY_SLAM, ti, t.pos, axis);
for (int i = 0; i < agentCount; ++i)
{
if (!agents[i].alive) continue;
const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.slamRadius + agents[i].bodyRadius) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events);
}
t.attackTimer = t.interval;
break;
}
// Захват: промахнуться им можно — ушёл из вытянутых лап за время
// замаха, и хват остался ни с чем на целый интервал.
case AttackKind::GRAB:
{
if (dist <= t.attackRange + agents[victim].bodyRadius)
{
t.grabbed = victim;
t.grabTimer = t.grabTime;
t.grabTickTimer = 0.0f; // первый тик сразу: хватку видно
agents[victim].grabbedTimer =
std::max(agents[victim].grabbedTimer, g_tune.enemyGrabHold);
if (events) events->Push(CombatEventKind::ENEMY_GRAB, victim,
agents[victim].pos, axis);
}
else
{
t.attackTimer = t.interval;
}
break;
}
// Взмах: сектор перед собой. Достаёт всех, кто в него влез, —
// от такого удара уходят вбок, а не назад.
case AttackKind::SWIPE:
default:
{
const float half = t.swipeArcDeg * 0.5f * DEG2RAD_F;
for (int i = 0; i < agentCount; ++i)
{
if (!agents[i].alive) continue;
const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.attackRange + agents[i].bodyRadius) continue;
if (std::fabs(AngleDiff(t.facing, AngleOf(d))) > half) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events);
}
t.attackTimer = t.interval;
break;
}
}
continue;
}
// --- начать замах -----------------------------------------------------
// Телеграф обязателен для КАЖДОЙ атаки, но дистанция начала у них разная:
// рывок затевается ИЗДАЛЕКА (он для того и нужен, чтобы закрыть разрыв),
// остальные — по своей досягаемости.
const float reach = t.attackRange + agents[victim].bodyRadius;
const float trigger = (t.attack == AttackKind::LUNGE) ? std::max(reach, t.lungeDist)
: reach;
if (dist <= trigger && t.attackTimer <= 0.0f)
{
t.windupTimer = t.windup * std::max(0.05f, g_tune.enemyWindupScale);
if (events) events->Push(CombatEventKind::ENEMY_WINDUP, ti, t.pos,
FromAngle(t.facing));
continue;
}
// --- ход --------------------------------------------------------------
// Стрелок держит дистанцию: подходить вплотную ему незачем, а отряду
// нужен противник, до которого ещё надо дойти.
Vec2 dir{};
if (t.ranged && dist < t.attackRange * 0.55f) dir = Normalized(-toVictim);
else if (dist > reach * 0.9f) dir = Normalized(toVictim);
Vec2 move = dir * t.speed;
move += CrowdPush(t, ti, targets) * t.speed;
t.pos += move * dt;
map.ResolveCircle(t.pos, t.radius);
}
}
int CountAggroEnemies(const std::vector<Target>& targets)
{
int n = 0;
for (const Target& t : targets)
if (t.alive && !t.IsDummy() && t.victim >= 0) ++n;
return n;
}

39
src/sim/enemy.h Normal file
View File

@ -0,0 +1,39 @@
#pragma once
// -----------------------------------------------------------------------------
// Поведение тварей. Отдельный файл, потому что здесь единственное место, где
// противник ЧИТАЕТ бойцов: sim/target.h про Agent не знает и знать не должен.
//
// Правила простые и намеренно читаемые с экрана:
// 1. заметил — идёт;
// 2. дошёл — замахивается, и замах ВИДНО (windupTimer);
// 3. довёл замах — бьёт по геометрии СВОЕЙ атаки (AttackKind).
// Между 2 и 3 у игрока есть время отойти или убить. Без телеграфа удар твари
// неотличим от случайной потери здоровья, и бой перестаёт быть боем.
//
// Пункт 3 — это пять разных задач для игрока, а не один урон:
// SWIPE — сектор перед тварью: уводи бойца вбок;
// LUNGE — рывок по прямой: освободи ОСЬ, а не точку;
// SPIT — снаряд: единственное, что перекрывается стеной;
// SLAM — круг вокруг твари: не толпитесь возле неё вдвоём;
// GRAB — захват: боец встал намертво, вытаскивать его придётся огнём.
// Числа этих форм — в EnemyDef (sim/target.cpp), общие множители — в tuning.h.
//
// ИИ не решает, кого атаковать, «по-умному»: ближайший живой боец в радиусе.
// Прототип проверяет отряд, а не противника, — и цена ошибки здесь должна быть
// в геометрии, а не в чужой хитрости.
// -----------------------------------------------------------------------------
#include <vector>
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/events.h"
#include "sim/target.h"
#include "engine/tilemap.h"
// Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще:
// --accept обязан считать ровно то же, что считал.
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events);
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
int CountAggroEnemies(const std::vector<Target>& targets);

View File

@ -12,7 +12,7 @@
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
#include "engine/math.h"
enum class CombatEventKind : uint8_t
{
@ -27,6 +27,38 @@ enum class CombatEventKind : uint8_t
MELEE_ABORT, // взмах оборван: в дугу влез свой
RELOAD_START, // магазин пуст, боец пошёл перезаряжаться
RELOAD_DONE, // магазин снаряжён
// --- разрушаемая обстановка -----------------------------------------------
// Отдельно от HIT_TARGET/HIT_KILL по одной причине: ящик не кровоточит.
// Слой крови (render/blood.cpp) знает только позицию события, поэтому
// отличить попадание в бочку от попадания в тварь можно ТОЛЬКО видом
// события. Заодно разбитый хлам не идёт в счётчик убитых и в напряжение.
HIT_PROP, // пуля/клинок в обстановку: щепки, не кровь
PROP_BREAK, // объект развалился
// --- события противника ---------------------------------------------------
// Отдельные виды, а не HIT_ALLY: попадание по своему — это нарушение
// критерия приёмки, и смешивать его с уроном от твари нельзя ни в метриках,
// ни в эффектах.
//
// ПОЛЕ owner здесь двузначно, и это осознанно: у события «тварь что-то
// делает» интересен индекс ТВАРИ (эффект рисуется на ней), у события
// «бойцу прилетело» — индекс БОЙЦА (эффект рисуется на нём). Ниже указано
// для каждого вида отдельно; путать их нельзя — индексы из разных массивов.
ENEMY_WINDUP, // замах начался. owner = ТВАРЬ, pos = тварь, dir = ось атаки
ENEMY_HIT, // тварь достала бойца. owner = БОЕЦ, pos = точка удара
ENEMY_SPIT, // плевок ушёл. owner = ТВАРЬ, pos = пасть, dir = направление
ENEMY_LUNGE, // рывок пошёл. owner = ТВАРЬ, pos = откуда, dir = ось рывка
ENEMY_SLAM, // удар оземь. owner = ТВАРЬ, pos = центр круга поражения
ENEMY_GRAB, // захват состоялся. owner = БОЕЦ, pos = боец, dir = от твари к нему
ENEMY_RELEASE, // захват кончился. owner = БОЕЦ, pos = боец
AGENT_DOWN, // боец упал. owner = БОЕЦ
// --- спуск ------------------------------------------------------------------
LOOT_TAKEN, // боец подобрал предмет. owner = БОЕЦ, pos = где лежало
ITEM_USED, // боец потратил расходник: бинт, стим, пачку патронов
DESCEND, // отряд дошёл до спуска. pos = точка спуска
EXTRACT, // отряд дошёл до эвакуации. pos = лестница наверх
};
struct CombatEvent

370
src/sim/items.h Normal file
View File

@ -0,0 +1,370 @@
#pragma once
// -----------------------------------------------------------------------------
// Предметы и снаряжение: ЧТО боец несёт на себе.
//
// Разделение то же, что у оружия (sim/weapon_defs.h):
// * ItemDef — неизменяемое описание предмета, одна запись каталога;
// * Inventory — компонент бойца: что в руках, что надето, что в сумке.
//
// Снаряжение НЕ трогает правила боя. Оно даёт бойцу четыре числа —
// броня, скорость, запас здоровья, дальность обзора, — и всё. Никакой предмет
// не может ослабить правило «не бить своих»: у него просто нет такой ручки.
// Это не самоограничение, а следствие устройства: FireWeapons и UpdateMelee
// про Inventory не знают вообще, они читают уже готовые статы агента.
//
// Оружие живёт двумя сущностями: ItemId в инвентаре — это ЧТО взято в руки,
// а сущность-оружие ECS хранит рабочие статы. SyncLoadout переносит одно
// в другое каждый шаг, поэтому «поменять ствол» — это одна запись ItemId.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "sim/weapon_defs.h"
// Сколько патронов в найденной пачке. Не «магазин»: магазины у стволов разные,
// а пачка — это просто запас, из которого магазин потом набивается.
constexpr int AMMO_BOX_ROUNDS = 40;
enum class ItemKind : uint8_t
{
NONE = 0,
FIREARM, // в руки, класс RANGED
BLADE, // в руки, класс MELEE
ARMOR, // на корпус
TRINKET, // в карман: свет, чутьё, живучесть
CONSUMABLE, // расходник
};
enum class ItemId : uint8_t
{
NONE = 0,
// --- стволы ---
RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW,
// --- клинки ---
SABER, KNIFE, CLEAVER,
// --- броня ---
JACKET, // куртка: почти ничего, зато не мешает
VEST, // жилет: ровный размен брони на скорость
PLATE, // плита: держит удар, но боец становится обозом
SHROUD, // саван: брони нет, зато тише и быстрее
// --- карман ---
LANTERN, // фонарь: видно дальше, и тебя тоже
CHARM, // оберег: запас здоровья
SPURS, // шпоры: скорость
// --- расходники ---
BANDAGE,
STIMPACK,
AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем
COUNT,
};
struct ItemDef
{
const char* name = "-";
ItemKind kind = ItemKind::NONE;
// Что взять в руки, если kind — оружие.
FirearmId gun = FirearmId::RIFLE;
BladeId blade = BladeId::SABER;
// Модификаторы бойца. Складываются по всем надетым предметам.
float armor = 0.0f; // доля поглощённого урона, суммарно жёстко режется
float speedMul = 1.0f; // множитель скорости хода
float hpBonus = 0.0f;
float sightMul = 1.0f; // множитель радиуса обзора (render-only)
float heal = 0.0f; // расходник: сколько здоровья возвращает
int rounds = 0; // расходник: сколько патронов добавляет в запас
// Сколько предмет весит. Единица условная, но соотношения — нет: плита
// тяжелее всего, что можно взять в руки, а карманное почти ничего не весит.
//
// Вес — это то, чем «унести всё» перестало быть бесплатным. До него решение
// на выходе сводилось к счёту свободных ячеек, а шесть бинтов и шесть плит
// стоили одинаково (docs/17-extraction.md).
//
// По умолчанию НОЛЬ, а не единица: ItemId::NONE — это пустая ячейка и
// «брони нет», и весить пустота не должна. С единицей боец без снаряжения
// таскал бы три килограмма ничего.
float weight = 0.0f;
// Цвет иконки в инвентаре. Нейтральные байты RGB — в sim/ типов рендера нет.
uint8_t r = 200, g = 200, b = 200;
};
// Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет.
constexpr float ARMOR_CAP = 0.72f;
// -----------------------------------------------------------------------------
// Переноска. Порогов ДВА, и это главное решение всей вехи.
//
// Один жёсткий предел означал бы «сумка кончилась» — то же самое, что счёт
// ячеек, только считать труднее. Настоящий выбор возникает там, где лишнее
// взять МОЖНО, но за это платят: до нормы отряд идёт как шёл, дальше — тем
// медленнее, чем больше нагрёб. А наверх идти через весь этаж, который уже
// растревожен.
//
// Верхний предел всё-таки есть: без него отряд уносил бы этаж целиком со
// скоростью ноль, и решение снова исчезло бы — только с другой стороны.
//
// Числа выставлены по каталогу лута, а не на глаз. Средняя находка первых
// тиров весит около 1.1, поздних — около 1.7, значит полная сумка из шести
// вещей тянет на 710, плюс ствол в руках.
//
// Отсюда пороги. Норма 7.5 — это ствол и три-четыре находки: отряд, который
// взял нужное и ушёл, идёт как шёл. Полная сумка (6.610 плюс ствол) неизбежно
// оказывается за нормой — так и задумано: «унести всё» обязано стоить скорости.
// Предел 14 оставляет место найденной плите (6), но с ней в сумке больше почти
// ничего не влезет — и это ровно тот выбор, ради которого вес заводился.
//
// Норму 6 пришлось поднять: прогон показал, что «карабин, сабля, фонарь и
// жилет» весят 6.9, то есть под неё не проходил даже обычный набор. Число
// противоречило собственному замыслу, и заметила это проверка, а не глаз.
//
// Сверяться с прогоном тут ОБЯЗАТЕЛЬНО, но не по столбцу «груз»: `--descend`
// кладёт в сумки бинты, чтобы проверить перенос между этажами, и по ним вес
// выглядит втрое меньше настоящего. Правило проверяется отдельно — там же,
// в прогоне, куском на выдуманном инвентаре.
// -----------------------------------------------------------------------------
constexpr float CARRY_LIMIT = 7.5f; // до этого веса скорость не страдает
constexpr float CARRY_MAX = 14.0f; // выше не поднимет: руки заняты
constexpr float CARRY_SLOW = 0.55f; // во сколько раз медленнее на пределе
// Множитель скорости от нагрузки. Между нормой и пределом падает линейно:
// нелинейная кривая тут была бы честнее физически и хуже играбельно — игрок
// должен чувствовать «каждая лишняя вещь стоит столько-то».
// bonus — прибавка к норме от улучшений профиля (sim/profile.h). Предел
// двигается вместе с нормой: улучшение обязано увеличивать И то, сколько можно
// нести свободно, И то, сколько вообще можно поднять, — иначе прокачка
// переноски упиралась бы в невидимую стену.
inline float CarryMul(float load, float bonus = 0.0f)
{
const float limit = CARRY_LIMIT + bonus;
const float top = CARRY_MAX + bonus;
if (load <= limit) return 1.0f;
if (load >= top) return CARRY_SLOW;
return 1.0f + (CARRY_SLOW - 1.0f) * (load - limit) / (top - limit);
}
inline ItemDef ItemDefOf(ItemId id)
{
ItemDef d;
switch (id)
{
// --- стволы ---
case ItemId::RIFLE:
d.name = "CARBINE"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::RIFLE;
d.weight = 2.2f;
d.r = 208; d.g = 196; d.b = 150; break;
case ItemId::PISTOL:
d.name = "PISTOL"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::PISTOL;
d.weight = 0.7f;
d.r = 190; d.g = 180; d.b = 168; break;
case ItemId::SHOTGUN:
d.name = "SHOTGUN"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SHOTGUN;
d.weight = 2.8f;
d.r = 214; d.g = 158; d.b = 104; break;
case ItemId::SMG:
d.name = "SMG"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SMG;
d.weight = 1.6f;
d.r = 176; d.g = 200; d.b = 220; break;
case ItemId::MAGNUM:
d.name = "MAGNUM"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::MAGNUM;
d.weight = 1.5f;
d.r = 226; d.g = 210; d.b = 176; break;
case ItemId::BOW:
d.name = "BOW"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::BOW;
d.weight = 1.4f;
d.r = 154; d.g = 190; d.b = 164; break;
// --- клинки ---
case ItemId::SABER:
d.name = "SABER"; d.kind = ItemKind::BLADE; d.blade = BladeId::SABER;
d.weight = 1.2f;
d.r = 200; d.g = 212; d.b = 228; break;
case ItemId::KNIFE:
d.name = "KNIFE"; d.kind = ItemKind::BLADE; d.blade = BladeId::KNIFE;
d.speedMul = 1.06f;
d.weight = 0.4f;
d.r = 214; d.g = 222; d.b = 232; break;
case ItemId::CLEAVER:
d.name = "CLEAVER"; d.kind = ItemKind::BLADE; d.blade = BladeId::CLEAVER;
d.speedMul = 0.94f;
d.weight = 2.2f;
d.r = 196; d.g = 174; d.b = 160; break;
// --- броня ---
case ItemId::JACKET:
d.name = "JACKET"; d.kind = ItemKind::ARMOR;
d.armor = 0.10f; d.speedMul = 1.00f;
d.weight = 1.2f;
d.r = 122; d.g = 112; d.b = 96; break;
case ItemId::VEST:
d.name = "VEST"; d.kind = ItemKind::ARMOR;
d.armor = 0.28f; d.speedMul = 0.95f; d.hpBonus = 10.0f;
d.weight = 3.0f;
d.r = 108; d.g = 118; d.b = 108; break;
case ItemId::PLATE:
d.name = "PLATE"; d.kind = ItemKind::ARMOR;
d.armor = 0.48f; d.speedMul = 0.82f; d.hpBonus = 25.0f;
d.weight = 6.0f;
d.r = 140; d.g = 146; d.b = 156; break;
case ItemId::SHROUD:
d.name = "SHROUD"; d.kind = ItemKind::ARMOR;
d.armor = 0.04f; d.speedMul = 1.12f; d.sightMul = 0.92f;
d.weight = 0.8f;
d.r = 88; d.g = 84; d.b = 100; break;
// --- карман ---
case ItemId::LANTERN:
d.name = "LANTERN"; d.kind = ItemKind::TRINKET;
d.sightMul = 1.35f;
d.weight = 0.5f;
d.r = 240; d.g = 206; d.b = 130; break;
case ItemId::CHARM:
d.name = "CHARM"; d.kind = ItemKind::TRINKET;
d.hpBonus = 30.0f;
d.weight = 0.3f;
d.r = 196; d.g = 130; d.b = 190; break;
case ItemId::SPURS:
d.name = "SPURS"; d.kind = ItemKind::TRINKET;
d.speedMul = 1.14f;
d.weight = 0.3f;
d.r = 198; d.g = 184; d.b = 120; break;
// --- расходники ---
case ItemId::BANDAGE:
d.name = "BANDAGE"; d.kind = ItemKind::CONSUMABLE; d.heal = 25.0f;
d.weight = 0.2f;
d.r = 216; d.g = 208; d.b = 196; break;
case ItemId::STIMPACK:
d.name = "STIM"; d.kind = ItemKind::CONSUMABLE; d.heal = 60.0f;
d.weight = 0.25f;
d.r = 130; d.g = 220; d.b = 170; break;
case ItemId::AMMO:
d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS;
d.weight = 1.0f;
d.r = 200; d.g = 176; d.b = 96; break;
default: break;
}
return d;
}
inline const char* ItemName(ItemId id) { return ItemDefOf(id).name; }
inline bool IsWeaponItem(ItemId id)
{
const ItemKind k = ItemDefOf(id).kind;
return k == ItemKind::FIREARM || k == ItemKind::BLADE;
}
// --- инвентарь ----------------------------------------------------------------
// -----------------------------------------------------------------------------
// Сумка — ОДНА НА ОТРЯД, а не шесть ячеек на каждого.
//
// Раньше каждый боец таскал своё, и это порождало ровно одну проблему, которую
// нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась
// только ему и лежала мёртвым грузом до конца забега. Перекладывать между
// бойцами игра не умела, и научить её значило бы завести целый обмен.
//
// Отряд ходит одной кучей и дерётся как одно целое — пусть и несёт как одно
// целое. Личным остаётся НАДЕТОЕ: руки, броня, карман. Их не разделить, они и
// есть «кто этот боец».
// -----------------------------------------------------------------------------
constexpr int SQUAD_BAG_SLOTS = 16;
struct SquadBag
{
ItemId slots[SQUAD_BAG_SLOTS] = {};
float Load() const
{
float w = 0.0f;
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) w += ItemDefOf(slots[i]).weight;
return w;
}
int Count() const
{
int n = 0;
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] != ItemId::NONE) ++n;
return n;
}
int FreeSlot() const
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] == ItemId::NONE) return i;
return -1;
}
int Find(ItemKind kind) const
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] != ItemId::NONE && ItemDefOf(slots[i]).kind == kind) return i;
return -1;
}
// Влезет ли ещё одна вещь. Ячейки — это «сколько разных вещей», вес —
// «сколько они тянут» на КАЖДОГО, и одно другое не заменяет.
//
// carriers — сколько живых бойцов делят ношу. Меньше живых — тяжелее всем,
// и это не побочный эффект: потерять бойца значит взвалить его долю на
// остальных, и обратно наверх они пойдут медленнее.
bool CanTake(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f) const
{
if (FreeSlot() < 0) return false;
const int n = (carriers > 0) ? carriers : 1;
return weaponLoad + (Load() + ItemDefOf(id).weight) / float(n) <= CARRY_MAX + bonus;
}
bool Take(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f)
{
if (!CanTake(id, carriers, weaponLoad, bonus)) return false;
const int slot = FreeSlot();
if (slot < 0) return false;
slots[slot] = id;
return true;
}
void Clear()
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemId::NONE;
}
};
// Компонент ECS на сущности бойца: только НАДЕТОЕ.
struct Inventory
{
ItemId weapon = ItemId::RIFLE; // в руках
ItemId armor = ItemId::NONE;
ItemId trinket = ItemId::NONE;
// Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак
// весит, нож — нет.
float Armor() const
{
const float a = ItemDefOf(armor).armor + ItemDefOf(trinket).armor;
return (a < ARMOR_CAP) ? a : ARMOR_CAP;
}
float SpeedMul() const
{
return ItemDefOf(armor).speedMul * ItemDefOf(trinket).speedMul *
ItemDefOf(weapon).speedMul;
}
float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; }
float SightMul() const { return ItemDefOf(armor).sightMul * ItemDefOf(trinket).sightMul; }
// Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи
// (SyncLoadout): инвентарь про отряд не знает.
//
// Надетое сюда НЕ входит, и это не поблажка. За броню уже заплачено
// множителем скорости (плита — 0.82), и считать её вес вторично значило бы
// наказывать за один выбор дважды. Зато плита, НАЙДЕННАЯ и лежащая в
// сумке, весит полной мерой: тащить её наверх — это и есть выбор.
float Load() const { return ItemDefOf(weapon).weight; }
};

View File

@ -16,8 +16,8 @@
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/hash.h"
#include "core/math.h"
#include "engine/hash.h"
#include "engine/math.h"
#include "sim/weapon_defs.h"
#include "tuning.h"
@ -58,9 +58,12 @@ struct Firearm
// Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш
// от (владелец, номер выстрела), поэтому прогон воспроизводим побитово.
float ShotOffset(int ownerIndex, float spreadRad) const
// pellet различает дробины одного залпа: без него вся картечь ушла бы
// в одну точку и была бы неотличима от одиночного выстрела.
float ShotOffset(int ownerIndex, float spreadRad, int pellet = 0) const
{
const uint32_t h = Hash2(uint32_t(ownerIndex) * 2654435761u, shotSeq);
const uint32_t h = Hash3(uint32_t(ownerIndex) * 2654435761u, shotSeq,
uint32_t(pellet) * 0x85EBCA6Bu);
// Треугольное распределение: центр конуса вероятнее краёв.
const float a = HashSigned(h);
const float b = HashSigned(HashU32(h ^ 0x9E3779B9u));
@ -102,7 +105,10 @@ struct MeleeWeapon
int targetId = -1;
bool inReach = false; // цель в зоне удара прямо сейчас
bool arcBlockedByAlly = false; // в секторе свой -> бить нельзя (аналог 6.3)
uint32_t hitMask = 0; // кого уже задели ЭТИМ взмахом
// Кого уже задели ЭТИМ взмахом. Шестьдесят четыре бита, а не тридцать два:
// ширина маски — это и есть потолок числа целей в мире (MAX_TARGETS), а
// этажи на глубине упираются именно в него.
uint64_t hitMask = 0;
// --- связка ---
int comboStep = 0; // какой удар связки будет следующим

66
src/sim/loot.cpp Normal file
View File

@ -0,0 +1,66 @@
#include "sim/loot.h"
#include "sim/agent.h"
void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay)
{
if (id == ItemId::NONE) return;
FloorItem it;
it.self = world.Create();
it.id = id;
it.pos = pos;
it.noPickup = pickupDelay;
world.Add<FloorItem>(it.self, it);
}
int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag,
int carriers, float carryBonus, CombatEventLog* events, float dt)
{
std::vector<FloorItem>& items = world.All<FloorItem>();
if (items.empty()) return 0;
const float reach = g_tune.lootPickupRadius;
int taken = 0;
for (FloorItem& it : items)
{
if (it.taken) continue;
if (it.noPickup > 0.0f)
{
it.noPickup -= dt;
continue; // только что выброшено — пусть полежит
}
// Слоты перебираются по порядку, а не «ближайшим бойцом»: при равном
// расстоянии двоих спор решается номером слота, как и везде в
// прототипе (docs/02-architecture.md, «Детерминизм»).
for (int i = 0; i < agentCount; ++i)
{
Agent& a = agents[i];
if (!a.alive) continue;
if (DistanceSq(a.pos, it.pos) > reach * reach) continue;
// Не влезает — предмет остаётся лежать. Причин две: кончились
// ячейки или кончилась переноска (sim/items.h). Обе одинаково
// обязаны быть видны как вещь под ногами, а не как пропажа.
const Inventory* inv = world.Get<Inventory>(a.self);
const float weaponLoad = inv ? inv->Load() : 0.0f;
if (!bag.Take(it.id, carriers, weaponLoad, carryBonus)) continue;
it.taken = true;
++taken;
if (events) events->Push(CombatEventKind::LOOT_TAKEN, i, it.pos, Vec2{});
break;
}
}
// Снятие отдельным проходом: Destroy перетасовывает пул swap-remove, и
// делать это внутри цикла по нему означало бы пропускать элементы.
if (taken > 0)
{
for (int i = int(items.size()) - 1; i >= 0; --i)
if (items[size_t(i)].taken) world.Destroy(items[size_t(i)].self);
}
return taken;
}

55
src/sim/loot.h Normal file
View File

@ -0,0 +1,55 @@
#pragma once
// -----------------------------------------------------------------------------
// Предмет, лежащий на полу, и его подбор.
//
// Зачем отдельная сущность, а не «выдать предмет при убийстве»: в игре про
// спуск находка — это МЕСТО, до которого надо дойти. Лут, падающий в инвентарь
// сам, не заставляет никуда идти и превращает «лучше лут глубже» в строку в
// логе. Лежащий предмет виден в темноте плохо, лежит он там, куда отряд ещё не
// заходил, и за ним надо разомкнуть строй — это и есть цена находки.
//
// Подбор ПАССИВНЫЙ: боец, прошедший по предмету, поднимает его. Отдельной
// кнопки нет намеренно — игрок и так не нажимает «огонь» (спек 5), и заводить
// ради лута первое ручное действие в игре значило бы менять её жанр.
//
// FloorItem — компонент ECS в своём пуле, как Target и Bullet. Правила боя про
// него не знают вообще: ни решатель, ни LaneClear, ни клинок его не видят.
// -----------------------------------------------------------------------------
#include <vector>
#include "engine/math.h"
#include "ecs/ecs.h"
#include "sim/events.h"
#include "sim/items.h"
struct Agent;
struct FloorItem
{
ecs::Entity self = ecs::NO_ENTITY;
ItemId id = ItemId::NONE;
Vec2 pos{};
// Предмет не удаляется в момент подбора: пул перетасовывается swap-remove,
// а по нему в этот момент идёт цикл. Помеченные снимаются одним проходом
// после него.
bool taken = false;
// Сколько секунд предмет НЕ подбирается. Нужно ровно одному случаю: вещь,
// которую боец только что выбросил, лежит у него под ногами, и пассивный
// подбор поднял бы её тем же шагом. Выбросить стало бы невозможно.
float noPickup = 0.0f;
};
// pickupDelay > 0 — предмет полежит, прежде чем его снова можно подобрать.
void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay = 0.0f);
// Подбор за шаг. Возвращает, сколько предметов поднято.
//
// Мёртвый боец ничего не подбирает, полная сумка — тоже: предмет остаётся
// лежать, и это правильно. «Некуда положить» обязано быть видно как лежащий
// под ногами предмет, а не как молча пропавшая находка.
// Подбирают В ОБЩУЮ СУМКУ: кто прошёл по предмету, тот его и поднял, но
// достанется он всему отряду. carriers — на скольких живых делится ноша.
int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag,
int carriers, float carryBonus, CombatEventLog* events, float dt);

234
src/sim/profile.cpp Normal file
View File

@ -0,0 +1,234 @@
#include "sim/profile.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include "engine/text_parse.h"
#include "sim/spawn_catalog.h"
namespace fs = std::filesystem;
namespace
{
// Стартовые комплекты по слотам строя. Те же, что раздаёт Game::EquipKits, и
// намеренно скромные: это дно, с которого начинают заново, а не награда.
const StarterKit STARTER[NUM_AGENTS] = {
{ItemId::SABER, ItemId::VEST},
{ItemId::SABER, ItemId::JACKET},
{ItemId::RIFLE, ItemId::JACKET},
{ItemId::RIFLE, ItemId::JACKET},
{ItemId::PISTOL, ItemId::SHROUD},
};
ItemId ItemFromToken(const std::string& token)
{
if (token == "-") return ItemId::NONE;
ItemId id = ItemId::NONE;
// Неизвестное имя — пустая ячейка, а не отказ читать профиль: предмет мог
// исчезнуть из игры между версиями, и терять из-за этого весь схрон нельзя.
ItemIdOfId(token, id);
return id;
}
} // namespace
int Profile::CheckpointCount() const
{
// Достигнутая глубина открывает все чекпоинты не глубже неё.
return 1 + std::max(0, (maxDepth - 1) / CHECKPOINT_STEP);
}
int Profile::DeepestCheckpoint() const { return CheckpointDepth(CheckpointCount() - 1); }
bool Profile::StashPush(ItemId id)
{
if (id == ItemId::NONE) return true; // «положить ничего» — не ошибка
if (int(stash.size()) >= STASH_CAP) return false;
stash.push_back(id);
return true;
}
bool Profile::StashRemoveAt(int index)
{
if (index < 0 || index >= int(stash.size())) return false;
stash.erase(stash.begin() + long(index));
return true;
}
void Profile::ResetLoadouts()
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
loadout[i] = Inventory{};
loadout[i].weapon = STARTER[i].weapon;
loadout[i].armor = STARTER[i].armor;
}
}
const char* UpgradeName(Upgrade u)
{
switch (u)
{
case Upgrade::AMMO: return "AMMO RESERVE";
case Upgrade::CARRY: return "CARRY LIMIT";
case Upgrade::SIGHT: return "SIGHT RANGE";
default: return "VIGOR";
}
}
const char* UpgradeEffect(Upgrade u)
{
switch (u)
{
case Upgrade::AMMO: return "+20 ROUNDS PER RAID";
case Upgrade::CARRY: return "+1.5 BEFORE SLOWDOWN";
case Upgrade::SIGHT: return "+6% VISION";
default: return "+12 HEALTH";
}
}
bool Profile::Load(const std::string& path, std::string* err)
{
std::ifstream in(path);
if (!in)
{
if (err) *err = "no profile at " + path;
return false;
}
Profile p;
p.ResetLoadouts();
p.stash.clear();
std::string line;
int lineNo = 0;
while (std::getline(in, line))
{
++lineNo;
const std::vector<std::string> t = text::Split(text::StripComment(line));
if (t.empty()) continue;
if (t[0] == "maxdepth" && t.size() >= 2) { p.maxDepth = std::max(1, text::ToInt(t[1], 1)); continue; }
if (t[0] == "runs" && t.size() >= 2) { p.runs = text::ToInt(t[1], 0); continue; }
if (t[0] == "extractions" && t.size() >= 2) { p.extractions = text::ToInt(t[1], 0); continue; }
if (t[0] == "wipes" && t.size() >= 2) { p.wipes = text::ToInt(t[1], 0); continue; }
if (t[0] == "xp" && t.size() >= 2) { p.bankedXp = text::ToFloat(t[1], 0.0f); continue; }
// upgrades <патроны> <переноска> <обзор> <живучесть>
if (t[0] == "upgrades")
{
for (int u = 0; u < int(Upgrade::COUNT) && size_t(1 + u) < t.size(); ++u)
p.upgrade[u] = std::max(0, std::min(UPGRADE_MAX_LEVEL,
text::ToInt(t[size_t(1 + u)], 0)));
continue;
}
// agent <слот> <оружие> <броня> <карман> <сумка x BAG_SLOTS>
if (t[0] == "agent" && t.size() >= 5)
{
const int slot = text::ToInt(t[1], -1);
if (slot < 0 || slot >= NUM_AGENTS)
{
if (err) *err = "line " + std::to_string(lineNo) + ": bad agent slot";
return false;
}
Inventory inv;
inv.weapon = ItemFromToken(t[2]);
inv.armor = ItemFromToken(t[3]);
inv.trinket = ItemFromToken(t[4]);
// Сумка в профиле больше не пишется: она общая на отряд и уезжает
// в схрон целиком при эвакуации (docs/17-extraction.md). Лишние
// лексемы в строке старого профиля просто игнорируются — читать
// прежние файлы это не мешает.
// Боец без оружия не может ни стрелять, ни рубить, и починить это
// в игре нечем: пустые руки не меняются на что-то из сумки, если
// сумка тоже пуста.
if (inv.weapon == ItemId::NONE) inv.weapon = STARTER[slot].weapon;
p.loadout[slot] = inv;
continue;
}
if (t[0] == "stash")
{
for (size_t i = 1; i < t.size(); ++i) p.StashPush(ItemFromToken(t[i]));
continue;
}
if (err) *err = "line " + std::to_string(lineNo) + ": unknown field `" + t[0] + "`";
return false;
}
*this = std::move(p);
return true;
}
bool Profile::Save(const std::string& path, std::string* err) const
{
std::error_code ec;
fs::create_directories(fs::path(path).parent_path(), ec);
std::ofstream f(path, std::ios::binary); // \n без \r: файл читают глазами
if (!f)
{
if (err) *err = "cannot open profile for writing";
return false;
}
f << "# профиль: всё, что переживает вылазку. Формат — sim/profile.h\n";
f << "maxdepth " << maxDepth << "\n";
f << "runs " << runs << "\n";
f << "extractions " << extractions << "\n";
f << "wipes " << wipes << "\n";
f << "xp " << int(bankedXp) << "\n";
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Inventory& inv = loadout[i];
f << "agent " << i << " " << IdOfItem(inv.weapon) << " " << IdOfItem(inv.armor) << " "
<< IdOfItem(inv.trinket);
f << "\n";
}
// Схрон разбит на строки по восемь: одна строка на полсотни предметов
// не читается ни в редакторе, ни в diff.
for (size_t i = 0; i < stash.size(); i += 8)
{
f << "stash";
for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << IdOfItem(stash[k]);
f << "\n";
}
if (!f.good())
{
if (err) *err = "profile write failed";
return false;
}
return true;
}
std::string DefaultProfilePath()
{
// Тот же порядок кандидатов, что у остальных данных: запуск из build/bin и
// из корня репозитория — оба рабочие. Профиль ищем существующий, а если
// его нет — пишем туда, где лежат ассеты.
static const char* const candidates[] = {
"save/profile.txt", "../save/profile.txt", "../../save/profile.txt",
};
std::error_code ec;
for (const char* c : candidates)
if (fs::exists(fs::path(c), ec)) return c;
for (const char* c : {"assets", "../assets", "../../assets"})
{
if (!fs::is_directory(fs::path(c), ec)) continue;
return std::string(fs::path(c).parent_path().string().empty()
? "save/profile.txt"
: (fs::path(c).parent_path() / "save" / "profile.txt").string());
}
return "save/profile.txt";
}

140
src/sim/profile.h Normal file
View File

@ -0,0 +1,140 @@
#pragma once
// -----------------------------------------------------------------------------
// Профиль: всё, что переживает забег.
//
// Игра стала экстракшен-шутером, и это меняет смысл спуска целиком. Раньше
// глубина была прогрессом: чем дальше, тем лучше. Теперь она СТАВКА — унесённое
// остаётся, потерянное не возвращается, и каждый этаж это заново заданный
// вопрос «ещё ниже или назад».
//
// Отсюда всё устройство файла:
// * снаряжение бойцов лежит ЗДЕСЬ, а не собирается заново при старте. То, в
// чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки;
// * схрон — общий запас сверх надетого. Он и есть накопленный результат;
// * максимальная глубина открывает чекпоинты. Без них десятитысячный этаж
// недостижим не по сложности, а по времени.
//
// Формат текстовый и построчный по той же причине, что у комнат: профиль лежит
// рядом с игрой, его правят руками при отладке, и он обязан читаться глазами.
//
// Профиль — состояние ИГРОКА, а не мира, поэтому он не в ecs::World и не
// сбрасывается ни Reset, ни Descend.
// -----------------------------------------------------------------------------
#include <string>
#include <vector>
#include "sim/items.h"
#include "tuning.h"
// Через сколько этажей ставится чекпоинт. Пять — это «достаточно редко, чтобы
// дойти до следующего было событием, и достаточно часто, чтобы потеря забега
// не откатывала в самое начало».
constexpr int CHECKPOINT_STEP = 5;
// Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без
// потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает.
constexpr int STASH_CAP = 48;
// Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже
// которого забег не проваливается: без него полный вайп означал бы отряд с
// голыми руками и конец игры вместо нового захода.
struct StarterKit
{
ItemId weapon = ItemId::RIFLE;
ItemId armor = ItemId::JACKET;
};
// -----------------------------------------------------------------------------
// Во что вкладывается вынесенный опыт.
//
// Улучшения РЕСУРСНЫЕ, а не силовые: больше патронов, больше переноски, дальше
// видно, больше запаса здоровья. Игра про исчерпание, и вкладываться в неё надо
// в то, что кончается, — иначе прокачка съедает сам жанр: отряд, который бьёт
// вдвое сильнее, перестаёт бояться, а страх тут единственная механика, которую
// нельзя восстановить балансом.
//
// Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре
// остаётся снаряжение, и второе личное измерение превратило бы сборы перед
// вылазкой в бухгалтерию на пятерых.
// -----------------------------------------------------------------------------
enum class Upgrade : uint8_t
{
AMMO = 0, // запас патронов на вылазку
CARRY, // норма переноски до перегруза
SIGHT, // радиус обзора
VIGOR, // запас здоровья
COUNT,
};
constexpr int UPGRADE_MAX_LEVEL = 5;
// Шаг улучшения за уровень.
constexpr float UPGRADE_AMMO_STEP = 20.0f;
constexpr float UPGRADE_CARRY_STEP = 1.5f;
constexpr float UPGRADE_SIGHT_STEP = 0.06f;
constexpr float UPGRADE_VIGOR_STEP = 12.0f;
// Цена следующего уровня. Растёт линейно: первая покупка обязана случиться на
// второй-третьей вылазке, иначе опыт снова «просто число», а десятая — стоить
// заметно, иначе кривая кончается.
inline float UpgradeCost(int currentLevel) { return 150.0f * float(currentLevel + 1); }
const char* UpgradeName(Upgrade u);
const char* UpgradeEffect(Upgrade u);
struct Profile
{
// Уровни улучшений, по одному на каждое. Живут в профиле, а не в игре:
// это то, что переживает вылазку.
int upgrade[int(Upgrade::COUNT)] = {};
int UpgradeLevel(Upgrade u) const { return upgrade[int(u)]; }
bool CanBuy(Upgrade u) const
{
return upgrade[int(u)] < UPGRADE_MAX_LEVEL && bankedXp >= UpgradeCost(upgrade[int(u)]);
}
bool Buy(Upgrade u)
{
if (!CanBuy(u)) return false;
bankedXp -= UpgradeCost(upgrade[int(u)]);
++upgrade[int(u)];
return true;
}
// Снаряжение пяти бойцов ровно в том виде, в каком они вернулись.
Inventory loadout[NUM_AGENTS];
// Общий запас сверх надетого.
std::vector<ItemId> stash;
// --- прогресс -------------------------------------------------------------
int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж
int runs = 0; // сколько вылазок начато
int extractions = 0; // сколько закончилось эвакуацией
int wipes = 0;
float bankedXp = 0.0f; // опыт, вынесенный из успешных вылазок
// --- чекпоинты ------------------------------------------------------------
// Самый глубокий чекпоинт, с которого можно начать. Глубина 1 доступна
// всегда: начать сверху — законный выбор, а не наказание.
int DeepestCheckpoint() const;
// Чекпоинт номер i (0 — первый этаж). Возвращает глубину.
static int CheckpointDepth(int index) { return 1 + index * CHECKPOINT_STEP; }
int CheckpointCount() const;
// --- схрон ----------------------------------------------------------------
bool StashPush(ItemId id); // false — схрон полон
bool StashRemoveAt(int index);
int StashCount() const { return int(stash.size()); }
// Сбросить снаряжение к стартовому комплекту. Схрон и прогресс не трогает:
// потерять вылазку — не то же самое, что потерять всё нажитое.
void ResetLoadouts();
bool Load(const std::string& path, std::string* err = nullptr);
bool Save(const std::string& path, std::string* err = nullptr) const;
};
// Где лежит профиль: рядом с бинарником или в корне репозитория. Возвращает
// путь, даже если файла ещё нет, — по нему и будет первая запись.
std::string DefaultProfilePath();

136
src/sim/spawn_catalog.cpp Normal file
View File

@ -0,0 +1,136 @@
#include "sim/spawn_catalog.h"
#include "sim/loot.h"
namespace
{
// Порядок здесь не важен, важна полнота: каждый вид, который может встретиться
// в комнате, обязан иметь id. Мишень приёмки (DUMMY) в каталоге движка есть
// намеренно — ею удобно ставить неподвижную цель, не заводя тварь.
struct Row
{
const char* id;
EnemyKind kind;
};
const Row ROWS[] = {
{"dummy", EnemyKind::DUMMY},
{"shambler", EnemyKind::SHAMBLER},
{"rusher", EnemyKind::RUSHER},
{"spitter", EnemyKind::SPITTER},
{"brute", EnemyKind::BRUTE},
{"lurker", EnemyKind::LURKER},
{"crate", EnemyKind::CRATE},
{"barrel", EnemyKind::BARREL},
{"locker", EnemyKind::LOCKER},
};
// Вторая половина того же моста: id каталога -> предмет игры. Отдельная
// таблица, а не поле в первой: тварь и предмет — разные пулы и разные
// перечисления, и объединяющий их «универсальный id» пришлось бы разбирать
// в каждом месте использования.
struct LootRow
{
const char* id;
ItemId item;
};
const LootRow LOOT_ROWS[] = {
{"rifle", ItemId::RIFLE}, {"pistol", ItemId::PISTOL},
{"shotgun", ItemId::SHOTGUN}, {"smg", ItemId::SMG},
{"magnum", ItemId::MAGNUM}, {"bow", ItemId::BOW},
{"saber", ItemId::SABER}, {"knife", ItemId::KNIFE},
{"cleaver", ItemId::CLEAVER},
{"jacket", ItemId::JACKET}, {"vest", ItemId::VEST},
{"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD},
{"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM},
{"spurs", ItemId::SPURS},
{"ammo", ItemId::AMMO},
{"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK},
};
} // namespace
bool EnemyKindOfId(const std::string& id, EnemyKind& out)
{
for (const Row& r : ROWS)
{
if (id != r.id) continue;
out = r.kind;
return true;
}
return false;
}
bool ItemIdOfId(const std::string& id, ItemId& out)
{
for (const LootRow& r : LOOT_ROWS)
{
if (id != r.id) continue;
out = r.item;
return true;
}
return false;
}
const char* IdOfEnemyKind(EnemyKind kind)
{
for (const Row& r : ROWS)
if (r.kind == kind) return r.id;
return "?";
}
const char* IdOfItem(ItemId item)
{
for (const LootRow& r : LOOT_ROWS)
if (r.item == item) return r.id;
return "-";
}
int SpawnFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, const Tilemap& map,
int* unknown)
{
int placed = 0;
int skipped = 0;
for (const LevelSpawn& s : list)
{
EnemyKind kind = EnemyKind::DUMMY;
if (!EnemyKindOfId(s.id, kind)) { ++skipped; continue; }
// Потолок тот же, что у захардкоженной расстановки: маска задетых целей
// в клинке 32-битная, и тридцать третья цель молча получала бы удары
// дважды за взмах (tuning.h).
if (placed >= MAX_TARGETS) { ++skipped; continue; }
// Сопротивление отбросу — свойство вида: расписывать его по слотам,
// как у мишеней приёмки, здесь не по чему, слотов нет.
MakeTarget(world, kind, s.pos, -1.0f, &map);
++placed;
}
if (unknown) *unknown = skipped;
return placed;
}
int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int* unknown)
{
int placed = 0;
int skipped = 0;
for (const LevelSpawn& s : list)
{
ItemId item = ItemId::NONE;
if (!ItemIdOfId(s.id, item)) { ++skipped; continue; }
// Из стены предмет не выталкивается, в отличие от твари: генератор
// кладёт лут только на достижимый пол, и «поправить» его здесь значило
// бы прятать ошибку раскладки вместо того, чтобы её увидеть.
SpawnFloorItem(world, item, s.pos);
++placed;
}
if (unknown) *unknown = skipped;
return placed;
}

40
src/sim/spawn_catalog.h Normal file
View File

@ -0,0 +1,40 @@
#pragma once
// -----------------------------------------------------------------------------
// Единственное место, где строковый id движка превращается в вид игры.
//
// Движок (и редактор вместе с ним) знает про сущности только то, что написано
// в assets/catalog/entities.txt: id, цвет, радиус, спрайт. Что такое SHAMBLER
// и чем он бьёт — знание игры, и оно живёт в sim/target.cpp. Таблица ниже
// связывает одно с другим, и других таких таблиц в проекте быть не должно:
// как только их станет две, они разойдутся.
//
// Новая тварь — это строка в каталоге, запись в EnemyDefOf и строка здесь.
// Ни редактор, ни формат комнат при этом не меняются вообще.
// -----------------------------------------------------------------------------
#include <string>
#include <vector>
#include "ecs/ecs.h"
#include "engine/level_gen.h"
#include "sim/items.h"
#include "sim/target.h"
#include "engine/tilemap.h"
// false — такого id в игре нет. Это не авария: комната могла ссылаться на
// сущность, которую из игры убрали, и уровень обязан собраться без неё.
bool EnemyKindOfId(const std::string& id, EnemyKind& out);
bool ItemIdOfId(const std::string& id, ItemId& out);
// Обратный перевод — для отчётов, dev-вывода и записи профиля.
const char* IdOfEnemyKind(EnemyKind kind);
// "-" для ItemId::NONE и для предмета, которого нет в каталоге: пустая ячейка
// в файле профиля обязана быть видимой, а не отсутствующей.
const char* IdOfItem(ItemId item);
// Выкатить в мир то, что нагенерил движок. Возвращает, сколько сущностей
// реально встало; unknown (если передан) — сколько id не опознано.
int SpawnFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, const Tilemap& map,
int* unknown = nullptr);
// То же для лежащих предметов.
int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int* unknown = nullptr);

View File

@ -76,9 +76,15 @@ void Squad::Init(ecs::World& world, Vec2 at)
const bool isMelee = (i < meleeCount);
const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount);
a.cls = isMelee ? WeaponClass::MELEE : WeaponClass::RANGED;
a.gunId = isPistol ? FirearmId::PISTOL : FirearmId::RIFLE;
a.weaponEntity = SpawnWeapon(world, a.cls, a.gunId);
// Стартовое оружие — то же, что и было: клинок, карабин, пистолет.
// Снаряжения на бойце НЕТ: броня меняет скорость, а на скорости меряются
// критерии приёмки. Комплекты раздаёт игровой режим (Game::EquipKits).
const ItemId weapon = isMelee ? ItemId::SABER
: isPistol ? ItemId::PISTOL
: ItemId::RIFLE;
a.weaponEntity = SpawnWeapon(world, weapon);
world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE});
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
a.pos = SlotWorld(i);
@ -93,6 +99,8 @@ void Squad::Init(ecs::World& world, Vec2 at)
SyncLoadout(world, a);
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
a.alive = true;
a.hp = a.maxHp;
}
}
@ -141,9 +149,17 @@ Vec2 Squad::GoalFor(int i) const
Vec2 Squad::Centroid() const
{
// Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд
// к телу павшего.
Vec2 c{};
for (int i = 0; i < NUM_AGENTS; ++i) c += agents[i].pos;
return c / float(NUM_AGENTS);
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!agents[i].alive) continue;
c += agents[i].pos;
++n;
}
return (n > 0) ? c / float(n) : anchor;
}
void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt)
@ -255,6 +271,13 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
{
Agent& a = agents[i];
a.prevPos = a.pos;
if (!a.alive) { a.vel = {}; continue; } // упавший лежит там, где упал
// Захваченного не двигает НИЧТО: ни строй, ни решатель, ни расступание
// из чужой линии. В этом вся цена захвата — боец выпал из геометрии
// отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт.
// Оружие при этом работает: захват держит ноги, а не руки.
if (a.Grabbed()) { a.vel = {}; continue; }
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
@ -274,7 +297,9 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
}
// --- обычные слои, порядок 6.7 --------------------------------------
const float maxSpeed = baseSpeed * a.SpeedScale();
// speedMul — это снаряжение: плита тормозит, шпоры разгоняют. Без
// снаряжения он равен 1, и движение остаётся прежним до последнего бита.
const float maxSpeed = baseSpeed * a.SpeedScale() * a.speedMul;
Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek;
desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation;

View File

@ -13,7 +13,7 @@
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/target.h"
#include "world/tilemap.h"
#include "engine/tilemap.h"
struct LaneRegistry; // ai/lane_registry.h — расступание из чужих линий (6.6)
@ -43,6 +43,25 @@ struct AgentSpan
struct Squad
{
// Что добавили улучшения профиля. Ноль по умолчанию — в песочнице приёмки
// профиля нет вовсе, и там всё считается ровно как считалось.
float carryBonus = 0.0f;
float sightBonus = 0.0f;
float hpBonus = 0.0f;
// Общая сумка отряда. Здесь, а не на бойце: отряд ходит одной кучей и несёт
// как одно целое (sim/items.h). Личным остаётся только надетое.
SquadBag bag;
// Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего.
int Carriers() const
{
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (agents[i].alive) ++n;
return n > 0 ? n : 1;
}
AgentSpan agents;
Vec2 anchor{};

View File

@ -32,29 +32,260 @@ const float kTargetKnockResist[NUM_TARGETS] = {
0.85f,
};
// Кто стоит в каждой точке в боевом режиме. Ближняя кучка — та, на которую
// отряд наткнётся первой: бегун, двое бредущих, плевок и ещё бегун. Громилы
// сидят в дальних комнатах: до них надо дойти.
const EnemyKind kTargetKinds[NUM_TARGETS] = {
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::RUSHER,
EnemyKind::SHAMBLER, EnemyKind::SPITTER, EnemyKind::BRUTE,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SPITTER,
EnemyKind::BRUTE,
};
// Твари СВЕРХ мишеней: расставлены по всей карте, а не только у старта.
// Без них зачистка первой кучки заканчивала вылазку, и карта стояла пустой.
// В песочнице приёмки их нет вообще — та расстановка не менялась.
const float kExtraSpots[NUM_EXTRA_ENEMIES][2] = {
{14.5f, 12.5f}, {18.5f, 16.5f}, {12.5f, 24.5f},
{36.5f, 12.5f}, {40.5f, 18.5f}, {38.5f, 27.5f},
{21.5f, 36.5f}, {25.5f, 41.5f}, {33.5f, 39.5f},
{16.5f, 32.5f}, {30.5f, 33.5f}, {41.5f, 43.5f},
};
// Хваты стоят ПОДАЛЬШЕ от старта, в глубине карты: захват — это ловушка для
// отряда, который уже разошёлся по позициям, а не встречающий у порога.
const EnemyKind kExtraKinds[NUM_EXTRA_ENEMIES] = {
EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::SHAMBLER, EnemyKind::RUSHER,
EnemyKind::LURKER, EnemyKind::BRUTE, EnemyKind::SPITTER,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::LURKER,
};
// Разрушаемая обстановка. Стоит ПАРАМИ и по краям комнат, а не по центру:
// брошенный скарб жмётся к стенам, и ровно поэтому он читается как обстановка,
// а не как расставленные мишени. Две штуки — у самого старта: игрок обязан
// узнать, что хлам ломается, до того как в него полетит первая тварь.
const float kPropSpots[NUM_PROPS][2] = {
{24.5f, 24.5f}, {23.5f, 25.5f}, // у старта: пара ящиков под рукой
{33.5f, 19.5f}, // ближняя кучка: укрытие сбоку
{28.5f, 6.5f}, {26.5f, 6.5f}, // северная комната, вдоль стены
{ 5.5f, 41.5f}, // западная комната
{44.5f, 33.5f}, {42.5f, 44.5f}, // восточная и юго-восточный угол
};
const EnemyKind kPropKinds[NUM_PROPS] = {
EnemyKind::CRATE, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::LOCKER, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::BARREL, EnemyKind::LOCKER,
};
} // namespace
void SpawnTargets(ecs::World& world)
EnemyDef EnemyDefOf(EnemyKind kind)
{
for (int i = 0; i < NUM_TARGETS; ++i)
EnemyDef d;
switch (kind)
{
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
// шагом, — и ровно поэтому страшен только гурьбой.
case EnemyKind::SHAMBLER:
d.name = "SHAMBLER";
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
d.aggroRadius = 9.5f; d.attackRange = 0.85f; d.damage = 13.0f;
d.windup = 0.42f; d.interval = 1.25f;
// Взмах лапой: широкий сектор перед собой. Отходить от него назад
// бесполезно — он и так короткий; уходить надо вбок.
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f;
d.lightPower = 0.05f;
d.r = 116; d.g = 52; d.b = 52;
break;
// Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда
// стрелок остаётся с ним один на один.
case EnemyKind::RUSHER:
d.name = "RUSHER";
d.hp = 110.0f; d.radius = 0.27f; d.speed = 3.3f; d.knockResist = 0.0f;
d.aggroRadius = 12.0f; d.attackRange = 0.75f; d.damage = 14.0f;
d.windup = 0.28f; d.interval = 1.10f;
// Рывок: разрыв в три метра он закрывает за пятую долю секунды и
// сносит ВСЕХ по прямой. Замах длиннее прежнего удара намеренно —
// это единственное окно, в котором отряд успевает разойтись с оси.
d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f;
d.lightPower = 0.07f;
d.r = 158; d.g = 74; d.b = 44;
break;
// Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт
// по площадям — из-за него отряд не может просто встать в линию.
case EnemyKind::SPITTER:
d.name = "SPITTER";
d.hp = 110.0f; d.radius = 0.31f; d.speed = 1.15f; d.knockResist = 0.1f;
d.aggroRadius = 10.5f; d.attackRange = 6.2f; d.damage = 16.0f;
d.windup = 0.55f; d.interval = 1.60f;
d.ranged = true; d.projectileSpeed = 8.5f;
// Снаряд летит дальше, чем плевок «достаёт»: отойти от него на шаг
// мало, надо разрывать дистанцию или прятаться за стену.
d.attack = AttackKind::SPIT; d.spitRange = 8.4f;
d.lightPower = 0.10f;
d.r = 96; d.g = 132; d.b = 72;
break;
// Громила: медленный, толстый, с одного удара снимает половину бойца.
// Клинком его надо разбирать вдвоём, и это правильная цена.
case EnemyKind::BRUTE:
d.name = "BRUTE";
d.hp = 520.0f; d.radius = 0.46f; d.speed = 1.05f; d.knockResist = 0.85f;
d.aggroRadius = 8.5f; d.attackRange = 1.25f; d.damage = 46.0f;
d.windup = 0.95f; d.interval = 2.60f;
// Удар оземь: круг вокруг себя, а не выпад в жертву. Разбирать
// громилу вдвоём теперь стоит денег — оба стоят в круге.
d.attack = AttackKind::SLAM; d.slamRadius = 2.15f;
d.lightPower = 0.12f;
d.r = 142; d.g = 60; d.b = 78;
break;
// Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют.
// Опасен не уроном, а тем, что вырывает одного из строя: линия огня
// рушится, отряд разваливается, и остальные твари доедают.
case EnemyKind::LURKER:
d.name = "LURKER";
d.hp = 150.0f; d.radius = 0.30f; d.speed = 1.90f; d.knockResist = 0.15f;
d.aggroRadius = 11.0f; d.attackRange = 0.90f; d.damage = 9.0f;
d.windup = 0.50f; d.interval = 2.40f;
// Урон тикает, пока держит: 9 за тик каждые 0.45 с — само по себе
// терпимо, смертельна именно неподвижность.
d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f;
d.lightPower = 0.09f;
d.r = 88; d.g = 62; d.b = 118;
break;
// --- разрушаемая обстановка -------------------------------------------
// Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы
// UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем
// ни идти, ни бить. Единственное, что у них есть, — HP и радиус.
//
// Сопротивление отбросу единица: ящик от попадания не ездит по полу.
// Живучесть подобрана от ОДНОГО магазина: труху сносит очередью на ходу,
// шкафчик требует остановиться — и в этом вся разница между ними.
case EnemyKind::CRATE:
d.name = "CRATE";
d.hp = 60.0f; d.radius = 0.38f; d.knockResist = 1.0f;
d.r = 104; d.g = 76; d.b = 46;
break;
case EnemyKind::BARREL:
d.name = "BARREL";
d.hp = 95.0f; d.radius = 0.33f; d.knockResist = 1.0f;
d.r = 92; d.g = 84; d.b = 58;
break;
case EnemyKind::LOCKER:
d.name = "LOCKER";
d.hp = 140.0f; d.radius = 0.36f; d.knockResist = 1.0f;
d.r = 78; d.g = 88; d.b = 96;
break;
// Мишень приёмки: всё по нулям, поведения нет. Вид атаки ей выставлен
// формально — с нулевым уроном и нулевой дальностью он не сработает
// никогда, а UpdateEnemies до неё вообще не доходит.
default:
d.name = "TARGET";
d.hp = g_tune.targetHP;
d.radius = g_tune.targetRadius;
d.attack = AttackKind::SWIPE;
d.r = 120; d.g = 44; d.b = 44;
break;
}
return d;
}
const char* EnemyName(EnemyKind kind) { return EnemyDefOf(kind).name; }
void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist,
const Tilemap* map)
{
const EnemyDef def = EnemyDefOf(kind);
Target t;
t.self = world.Create();
t.pos = {kTargetSpots[i][0], kTargetSpots[i][1]};
t.kind = kind;
t.pos = pos;
t.home = t.pos;
t.vel = {};
t.hp = g_tune.targetHP;
t.maxHp = g_tune.targetHP;
t.radius = g_tune.targetRadius;
t.hp = def.hp;
t.maxHp = def.hp;
t.radius = def.radius;
t.respawnTimer = 0.0f;
t.alive = true;
t.knockResist = kTargetKnockResist[i];
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —
// свойство вида. Отрицательное значение означает «взять из каталога».
t.knockResist = (knockResist >= 0.0f) ? knockResist : def.knockResist;
t.speed = def.speed;
t.aggroRadius = def.aggroRadius;
t.attackRange = def.attackRange;
t.damage = def.damage;
t.windup = def.windup;
t.interval = def.interval;
t.ranged = def.ranged;
t.projectileSpeed = def.projectileSpeed;
t.attack = def.attack;
t.swipeArcDeg = def.swipeArcDeg;
t.lungeDist = def.lungeDist;
t.lungeTime = def.lungeTime;
t.spitRange = def.spitRange;
t.slamRadius = def.slamRadius;
t.grabTime = def.grabTime;
t.grabTick = def.grabTick;
// Точки расставлены руками, а планировка генерируется: тварь, поставленная
// в стену, никогда бы оттуда не вышла — она сдвигается только когда идёт
// за кем-то, а из стены ей никого не видно.
if (map)
{
map->ResolveCircle(t.pos, t.radius);
t.home = t.pos;
}
world.Add<Target>(t.self, t);
}
void SpawnTargets(ecs::World& world, bool hostile, const Tilemap* map)
{
// Три полосы подряд в одном пуле: мишени, твари сверх них, обстановка.
// В песочнице приёмки живёт только первая — ни одной лишней сущности,
// и все замеры остаются сравнимыми с прежними.
const int total = hostile ? (NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS) : NUM_TARGETS;
for (int i = 0; i < total; ++i)
{
const int extraIdx = i - NUM_TARGETS;
const int propIdx = extraIdx - NUM_EXTRA_ENEMIES;
const bool extra = (extraIdx >= 0 && propIdx < 0);
const bool prop = (propIdx >= 0);
const EnemyKind kind = !hostile ? EnemyKind::DUMMY
: prop ? kPropKinds[propIdx]
: extra ? kExtraKinds[extraIdx]
: kTargetKinds[i];
const Vec2 pos = prop ? Vec2{kPropSpots[propIdx][0], kPropSpots[propIdx][1]}
: extra ? Vec2{kExtraSpots[extraIdx][0], kExtraSpots[extraIdx][1]}
: Vec2{kTargetSpots[i][0], kTargetSpots[i][1]};
MakeTarget(world, kind, pos, hostile ? -1.0f : kTargetKnockResist[i], map);
}
}
void KnockTarget(Target& t, Vec2 dir, float speed)
{
// Обстановка не ездит по полу от попаданий. Домой её никто не тянет (это
// свойство мишени приёмки), так что сдвинутый ящик уехал бы навсегда.
if (t.IsProp()) return;
t.vel += dir * (speed * t.KnockScale());
}
@ -67,30 +298,58 @@ void UpdateTargets(std::vector<Target>& targets, float dt)
for (Target& t : targets)
{
t.hurtTimer = std::max(0.0f, t.hurtTimer - dt);
if (t.IsDummy())
{
// Мишень приёмки: ручки tuning продолжают крутить именно её.
t.radius = g_tune.targetRadius;
t.maxHp = g_tune.targetHP;
}
t.pos += t.vel * dt;
t.vel *= damp;
t.pos += (t.home - t.pos) * pull;
// Возврат на своё место — свойство МИШЕНИ, а не тела: тварь, которую
// тянет домой, не может ни догнать отряд, ни отойти от него.
if (t.IsDummy()) t.pos += (t.home - t.pos) * pull;
if (t.alive) continue;
// Разбитый хлам остаётся разбитым: RespawnDelay() возвращает ему
// отрицательный срок, и без этой проверки он воскресал бы в тот же кадр.
if (t.IsProp()) continue;
t.respawnTimer -= dt;
if (t.respawnTimer <= 0.0f)
{
const EnemyDef def = EnemyDefOf(t.kind);
t.alive = true;
t.hp = g_tune.targetHP;
t.hp = t.IsDummy() ? g_tune.targetHP : def.hp;
t.pos = t.home;
t.vel = {};
t.victim = -1;
t.aggro = false;
t.attackTimer = 0.0f;
t.windupTimer = 0.0f;
// Недоведённый рывок и незакрытый захват пережили бы смерть и
// вернулись бы вместе с тварью. Захваченного бойца при этом никто
// не держит: его таймер гаснет сам, пока держать некому.
t.lungeTimer = 0.0f;
t.lungeMask = 0;
t.grabbed = -1;
t.grabTimer = 0.0f;
t.grabTickTimer = 0.0f;
}
}
}
int CountAliveTargets(const std::vector<Target>& targets)
{
// Хлам здесь не считается: это знаменатель «по скольким целям отряд может
// работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает
// огневую позицию хуже и зачистку — незаконченной.
int n = 0;
for (const Target& t : targets)
if (t.alive) ++n;
if (t.alive && !t.IsProp()) ++n;
return n;
}

View File

@ -1,21 +1,116 @@
#pragma once
// -----------------------------------------------------------------------------
// Мишени: статичные, не атакуют, респавнятся на том же месте.
// 12 штук кучками (5 / 3 / 3 / 1) — расстановка захардкожена.
// Противник. Одна сущность на две роли, и это сделано намеренно.
//
// Target — компонент ECS: сущности мишеней создаются в SpawnTargets, а вектор,
// по которому ходят правила, — это плотный массив пула (см. ecs/ecs.h).
// * EnemyKind::DUMMY — прежняя МИШЕНЬ: стоит на месте, не атакует, после
// смерти возвращается. На ней меряются все критерии приёмки, поэтому её
// поведение не изменилось ни на кадр: --accept обязан считать то же самое.
// * остальные виды — живые твари: замечают отряд, идут, бьют, стреляют.
//
// Разводить их по двум компонентам не за чем: решатель огневых позиций, правило
// 6.3, клинок и пули работают с ними одинаково — как с телом, у которого есть
// позиция, радиус и здоровье. Разница живёт ровно в одном месте: UpdateEnemies
// (sim/enemy.cpp) шевелит тех, у кого kind != DUMMY.
//
// Target — компонент ECS: сущности создаются в SpawnTargets, а вектор, по
// которому ходят правила, — это плотный массив пула (см. ecs/ecs.h).
// -----------------------------------------------------------------------------
#include <cstdint>
#include <vector>
#include "core/math.h"
#include "engine/math.h"
#include "ecs/ecs.h"
#include "tuning.h"
#include "engine/tilemap.h"
enum class EnemyKind : uint8_t
{
DUMMY = 0, // мишень приёмки: не ходит, не бьёт
SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную
RUSHER, // бегун: тонкий, быстрый, налетает первым
SPITTER, // плевок: держится поодаль и стреляет
BRUTE, // громила: медленный, толстый, сносит одним ударом
LURKER, // хват: подбирается и держит бойца на месте
// --- разрушаемая обстановка -----------------------------------------------
// Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает.
// Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило
// 6.3 работают с ними как с любым телом «позиция + радиус + HP», и ни одну
// из этих систем трогать не пришлось. Разница ровно в двух местах:
// приоритет цели (ai/) и то, что UpdateEnemies их не шевелит.
CRATE, // ящик: труха и хлам, разлетается быстро
BARREL, // бочка: ржавая, гулкая, держит дольше
LOCKER, // шкафчик: жестяной, самый живучий из хлама
COUNT,
};
// Первый вид обстановки. Всё, что >= него, — хлам, а не тварь.
constexpr EnemyKind FIRST_PROP_KIND = EnemyKind::CRATE;
// Чем именно бьёт вид. Один удар на всех делал любую тварь одинаковой: подошла —
// сняла здоровье. Разная ГЕОМЕТРИЯ атаки — единственное, что заставляет игрока
// по-разному расставлять отряд против разных тварей.
//
// Общее для всех пяти: сначала замах (windupTimer), и он ВИДЕН. Без телеграфа
// смерть бойца выглядит случайной, а не проигранной.
enum class AttackKind : uint8_t
{
SWIPE = 0, // сектор перед собой: уводи бойца вбок, а не назад
LUNGE, // рывок на lungeDist за lungeTime; урон всем, кого задел по пути
SPIT, // снаряд: единственная атака, которую перекрывает стена
SLAM, // круг радиусом slamRadius вокруг себя: опасен и соседям жертвы
GRAB, // захват: держит бойца grabTime секунд и тикает уроном
};
// Описание вида: одна запись каталога, как у оружия.
struct EnemyDef
{
const char* name = "dummy";
float hp = 500.0f;
float radius = 0.34f;
float speed = 0.0f; // 0 — с места не сходит
float knockResist = 0.0f;
float aggroRadius = 0.0f; // с какой дистанции замечает отряд
float attackRange = 0.0f;
float damage = 0.0f;
float windup = 0.0f; // телеграф: замах видно до удара
float interval = 1.0f; // между атаками
bool ranged = false;
float projectileSpeed = 9.0f;
// --- геометрия атаки ------------------------------------------------------
// Числа здесь, а не в коде поведения: форма удара — свойство ВИДА, и менять
// её надо в каталоге, рядом с остальными статами. Общие множители (сложность,
// длина всех телеграфов) живут в tuning.h.
AttackKind attack = AttackKind::SWIPE;
float swipeArcDeg = 90.0f; // SWIPE: полная ширина сектора
float lungeDist = 0.0f; // LUNGE: длина рывка, она же дистанция начала
float lungeTime = 0.18f; // LUNGE: за сколько он проходится
// SPIT: сколько летит снаряд. Больше attackRange намеренно — иначе плевок
// гаснет ровно там, откуда цель уже успела шагнуть, и стрелять смысла нет.
float spitRange = 0.0f;
float slamRadius = 0.0f; // SLAM: радиус круга поражения
float grabTime = 0.0f; // GRAB: сколько держит
float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит
float lightPower = 0.0f; // светятся ли глаза (render-only)
// Цвет тела. Нейтральные байты: типов рендера в sim/ нет.
uint8_t r = 120, g = 44, b = 44;
};
EnemyDef EnemyDefOf(EnemyKind kind);
const char* EnemyName(EnemyKind kind);
struct Target
{
ecs::Entity self = ecs::NO_ENTITY;
EnemyKind kind = EnemyKind::DUMMY;
Vec2 pos{};
// Своё место. Удар отбрасывает мишень, после чего она возвращается сюда:
// без отдачи попадание в упор ничем не отличается от промаха.
@ -38,6 +133,74 @@ struct Target
const float r = Clampf(knockResist * g_tune.knockResistScale, 0.0f, 0.98f);
return 1.0f - r;
}
// --- статы вида (копия записи каталога, чтобы ИИ не лазал в таблицу) ------
float speed = 0.0f;
float aggroRadius = 0.0f;
float attackRange = 0.0f;
float damage = 0.0f;
float windup = 0.0f;
float interval = 1.0f;
bool ranged = false;
float projectileSpeed = 9.0f;
// --- геометрия атаки (копия записи каталога) ------------------------------
AttackKind attack = AttackKind::SWIPE;
float swipeArcDeg = 90.0f;
float lungeDist = 0.0f;
float lungeTime = 0.18f;
float spitRange = 0.0f;
float slamRadius = 0.0f;
float grabTime = 0.0f;
float grabTick = 0.5f;
// --- состояние ИИ ---------------------------------------------------------
int victim = -1; // индекс бойца, за которым идёт; -1 — никого
float attackTimer = 0.0f; // до следующей атаки
float windupTimer = 0.0f; // > 0 — замах уже виден игроку
float facing = 0.0f;
float hurtTimer = 0.0f; // недавно получил урон (render-only)
bool aggro = false; // заметил отряд
// Опыт за эту смерть уже начислен. Не «мёртв ли» — смерть длится много
// шагов до респавна, и без отдельного флага опыт капал бы каждый кадр,
// пока тело лежит. Респавн флаг снимает: вернувшаяся тварь — новая цель.
bool countedDead = false;
// --- рывок (LUNGE) --------------------------------------------------------
// Рывок — это ДВИЖЕНИЕ по времени, а не телепорт: игрок обязан видеть бросок
// и успевать понять, кого именно им снесло.
float lungeTimer = 0.0f; // > 0 — рывок идёт прямо сейчас
Vec2 lungeDir{}; // ось зафиксирована на старте и не доводится
uint32_t lungeMask = 0; // кого уже задел ЭТИМ рывком: бит = индекс бойца
// --- захват (GRAB) --------------------------------------------------------
int grabbed = -1; // кого держит; -1 — никого
float grabTimer = 0.0f; // сколько ещё продержит
float grabTickTimer = 0.0f; // до следующего тика урона
bool IsDummy() const { return kind == EnemyKind::DUMMY; }
// Хлам, а не противник: ящик, бочка, шкафчик. Проверяется во всех местах,
// где «цель» означала «кого-то, кто может убить»: приоритет ИИ, счётчик
// напряжения, глаза в темноте, знаменатель покрытия в решателе.
bool IsProp() const { return kind >= FIRST_PROP_KIND; }
// Мишень приёмки встаёт быстро — на ней меряют. Тварь не возвращается,
// пока отряд не успеет уйти: иначе зачистка не имеет смысла.
// Разбитый ящик не встаёт НИКОГДА: разрушаемая обстановка, которая
// отрастает обратно, — это не обстановка, а мишень.
float RespawnDelay() const
{
if (IsProp()) return -1.0f;
return IsDummy() ? g_tune.targetRespawn : g_tune.enemyRespawn;
}
// Замах виден: единственный признак, по которому игрок успевает среагировать.
bool Telegraphing() const { return windupTimer > 0.0f; }
// Атака уже пошла и назад не отыгрывается: тварь в рывке или держит бойца.
// В этих фазах она не выбирает цель заново и не ходит по своим делам.
bool Busy() const { return lungeTimer > 0.0f || grabbed >= 0; }
};
// Импульс от удара (dir нормализован вызывающим). speed — импульс ДО того,
@ -45,7 +208,23 @@ struct Target
// иначе половина источников урона про него забудет.
void KnockTarget(Target& t, Vec2 dir, float speed);
void SpawnTargets(ecs::World& world);
// Собрать сущность-цель заданного вида в точке и добавить её в мир.
// knockResist < 0 — взять из каталога вида; иначе это расписанное по слоту
// значение мишени приёмки. map (если передан) выталкивает тело из стены.
//
// Одна функция на обе расстановки — захардкоженную и собранную из комнат
// (sim/spawn_catalog.h). Двадцать полей Target, скопированных в два места,
// разошлись бы на первой же новой характеристике вида.
void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist,
const Tilemap* map);
// hostile = false — прежняя расстановка мишеней приёмки (все DUMMY).
// hostile = true — та же расстановка видами тварей ПЛЮС NUM_EXTRA_ENEMIES
// по всей карте: игра.
// map нужен, чтобы вытолкнуть тварь из стены, если точка попала в блок.
void SpawnTargets(ecs::World& world, bool hostile = false, const Tilemap* map = nullptr);
// Отдача, возврат мишени на место и респавн. ИИ здесь нет — он в sim/enemy.cpp.
void UpdateTargets(std::vector<Target>& targets, float dt);
// Число живых мишеней (знаменатель в оценке покрытия, спек 6.5).

View File

@ -21,7 +21,7 @@
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/math.h"
#include "engine/math.h"
#include "tuning.h"
enum class WeaponClass : uint8_t
@ -38,8 +38,20 @@ inline const char* WeaponClassName(WeaponClass c)
enum class FirearmId : uint8_t
{
RIFLE = 0, // штатный карабин отряда: числа из tuning.h
PISTOL = 1, // короткоствол: ближе, быстрее, мельче магазин
COUNT = 2,
PISTOL, // короткоствол: ближе, быстрее, мельче магазин
SHOTGUN, // картечь: залп дробин, в упор рвёт, дальше двух шагов бесполезен
SMG, // шквал: очередь дешёвыми патронами, ствол уводит адски
MAGNUM, // один тяжёлый выстрел: медленно, больно, ошибок не прощает
BOW, // тихий составной лук: редкий тяжёлый снаряд без дульной вспышки
COUNT,
};
enum class BladeId : uint8_t
{
SABER = 0, // штатный клинок: числа из tuning.h
KNIFE, // нож: узкая дуга, зато почти без замаха
CLEAVER, // тесак: широкий, тяжёлый, сносит с ног
COUNT,
};
// --- ствол -------------------------------------------------------------------
@ -69,6 +81,10 @@ struct FirearmDef
int magazine = 24;
float reloadTime = 1.9f;
// Сколько снарядов уходит за один спуск. Дробины летят в ОДНОМ уже
// проверенном конусе разброса — правило 6.3 от залпа не слабеет.
int pellets = 1;
// визуал атаки (нейтральные числа: пиксели, секунды, байты RGB)
float tracerLength = 1.30f;
float muzzleRadiusPx = 5.0f;
@ -104,6 +120,7 @@ struct WeaponItem
{
WeaponClass cls = WeaponClass::RANGED;
FirearmId gunId = FirearmId::RIFLE;
BladeId bladeId = BladeId::SABER;
FirearmDef gun{};
BladeDef blade{};
};
@ -180,17 +197,175 @@ inline FirearmDef PistolDef()
return d;
}
// Картечь: один спуск — залп дробин в общем конусе. Дальность позорная, зато
// в упор снимает столько же, сколько очередь карабина. Конус широкий всегда,
// поэтому проверка «свой на линии» отсекает выстрел куда чаще, чем у ствола —
// это не баг картечи, это её цена.
inline FirearmDef ShotgunDef()
{
FirearmDef d;
d.name = "shotgun";
d.range = 3.4f;
d.fireInterval = 0.85f;
d.damage = 26.0f; // на дробину
d.laneHalfWidth = 0.10f;
d.muzzleOffset = 0.32f;
d.bulletSpeed = 19.0f;
d.bulletRadius = 0.05f;
d.knockback = 0.55f;
d.spreadStandDeg = 7.5f;
d.spreadMoveDeg = 13.0f;
d.spreadRecoilDeg = 1.4f;
d.spreadRecoilMax = 5.0f;
d.recoilDecay = 5.0f;
d.magazine = 5;
d.reloadTime = 2.6f;
d.pellets = 6;
d.tracerLength = 0.55f;
d.muzzleRadiusPx = 7.5f;
d.muzzleTime = 0.10f;
d.muzzleFlamePx = 0.50f;
d.flashPower = 0.85f;
d.flashRadius = 5.2f;
d.shakePerShot = 1.55f;
d.tracerR = 255; d.tracerG = 190; d.tracerB = 130;
d.muzzleR = 255; d.muzzleG = 196; d.muzzleB = 110;
return d;
}
// Шквал: патроны дешёвые, урон мизерный, но ствол не молчит. Увод копится
// быстрее, чем оседает, поэтому длинная очередь сама себя разбрасывает.
inline FirearmDef SmgDef()
{
FirearmDef d;
d.name = "smg";
d.range = 5.4f;
d.fireInterval = 0.09f;
d.damage = 6.0f;
d.laneHalfWidth = 0.10f;
d.muzzleOffset = 0.28f;
d.bulletSpeed = 20.0f;
d.bulletRadius = 0.035f;
d.knockback = 0.16f;
d.spreadStandDeg = 1.9f;
d.spreadMoveDeg = 7.0f;
d.spreadRecoilDeg = 0.7f;
d.spreadRecoilMax = 7.0f;
d.recoilDecay = 5.5f;
d.magazine = 40;
d.reloadTime = 2.2f;
d.tracerLength = 0.95f;
d.muzzleRadiusPx = 3.8f;
d.muzzleTime = 0.05f;
d.muzzleFlamePx = 0.22f;
d.flashPower = 0.38f;
d.flashRadius = 3.0f;
d.shakePerShot = 0.22f;
d.tracerR = 220; d.tracerG = 240; d.tracerB = 255;
d.muzzleR = 210; d.muzzleG = 230; d.muzzleB = 255;
return d;
}
// Тяжёлый револьвер: бьёт дальше карабина и валит с ног, но между выстрелами
// целая секунда и в барабане шесть патронов. Оружие для того, кто попадает.
inline FirearmDef MagnumDef()
{
FirearmDef d;
d.name = "magnum";
d.range = 8.6f;
d.fireInterval = 0.95f;
d.damage = 78.0f;
d.laneHalfWidth = 0.10f;
d.muzzleOffset = 0.30f;
d.bulletSpeed = 30.0f;
d.bulletRadius = 0.05f;
d.knockback = 1.30f;
d.spreadStandDeg = 0.6f;
d.spreadMoveDeg = 8.0f; // с рук на бегу — не стрелять
d.spreadRecoilDeg = 2.2f;
d.spreadRecoilMax = 6.0f;
d.recoilDecay = 3.0f;
d.magazine = 6;
d.reloadTime = 3.0f;
d.tracerLength = 1.85f;
d.muzzleRadiusPx = 6.5f;
d.muzzleTime = 0.09f;
d.muzzleFlamePx = 0.45f;
d.flashPower = 0.80f;
d.flashRadius = 5.0f;
d.shakePerShot = 1.90f;
d.tracerR = 255; d.tracerG = 246; d.tracerB = 226;
d.muzzleR = 255; d.muzzleG = 232; d.muzzleB = 176;
return d;
}
inline FirearmDef BowDef()
{
FirearmDef d;
d.name = "bow";
d.range = 8.0f;
d.fireInterval = 0.82f;
d.damage = 54.0f;
d.laneHalfWidth = 0.08f;
d.muzzleOffset = 0.34f;
d.bulletSpeed = 15.0f;
d.bulletRadius = 0.035f;
d.knockback = 0.72f;
d.spreadStandDeg = 0.7f;
d.spreadMoveDeg = 7.5f;
d.spreadRecoilDeg = 0.0f;
d.spreadRecoilMax = 0.0f;
d.recoilDecay = 8.0f;
d.magazine = 1;
d.reloadTime = 0.72f;
d.tracerLength = 0.38f;
d.muzzleRadiusPx = 0.0f;
d.muzzleTime = 0.0f;
d.muzzleFlamePx = 0.0f;
d.flashPower = 0.0f;
d.flashRadius = 0.0f;
d.shakePerShot = 0.18f;
d.tracerR = 178; d.tracerG = 205; d.tracerB = 180;
d.muzzleR = 0; d.muzzleG = 0; d.muzzleB = 0;
return d;
}
inline FirearmDef FirearmDefOf(FirearmId id)
{
return (id == FirearmId::PISTOL) ? PistolDef() : RifleDef();
switch (id)
{
case FirearmId::PISTOL: return PistolDef();
case FirearmId::SHOTGUN: return ShotgunDef();
case FirearmId::SMG: return SmgDef();
case FirearmId::MAGNUM: return MagnumDef();
case FirearmId::BOW: return BowDef();
default: return RifleDef();
}
}
inline const char* FirearmName(FirearmId id)
{
return (id == FirearmId::PISTOL) ? "PISTOL" : "RIFLE";
switch (id)
{
case FirearmId::PISTOL: return "PISTOL";
case FirearmId::SHOTGUN: return "SHOTGUN";
case FirearmId::SMG: return "SMG";
case FirearmId::MAGNUM: return "MAGNUM";
case FirearmId::BOW: return "BOW";
default: return "RIFLE";
}
}
// Клинок пока один, и его числа целиком в tuning.
// Штатный клинок: числа целиком в tuning, ручки оверлея крутят именно его.
inline BladeDef DefaultBladeDef()
{
BladeDef d;
@ -205,3 +380,59 @@ inline BladeDef DefaultBladeDef()
d.lungeSpeed = g_tune.meleeLungeSpeed;
return d;
}
// Нож: узкая дуга и почти нет замаха. В тесноте это решает — сектор шириной
// в ладонь почти всегда чист, тогда как тесак некуда занести.
inline BladeDef KnifeDef()
{
BladeDef d;
d.name = "knife";
d.reach = 1.00f;
d.arcDeg = 55.0f;
d.windup = 0.08f;
d.strike = 0.09f;
d.recover = 0.13f;
d.interval = 0.34f;
d.damage = 62.0f;
d.knockback = 0.9f;
d.lungeSpeed = 4.2f;
return d;
}
// Тесак: широкий замах, тяжёлый урон, долгое восстановление. Своих распугивает
// сам по себе — резерв сектора у него больше всех.
inline BladeDef CleaverDef()
{
BladeDef d;
d.name = "cleaver";
d.reach = 1.65f;
d.arcDeg = 135.0f;
d.windup = 0.30f;
d.strike = 0.18f;
d.recover = 0.42f;
d.interval = 0.95f;
d.damage = 240.0f;
d.knockback = 4.6f;
d.lungeSpeed = 2.8f;
return d;
}
inline BladeDef BladeDefOf(BladeId id)
{
switch (id)
{
case BladeId::KNIFE: return KnifeDef();
case BladeId::CLEAVER: return CleaverDef();
default: return DefaultBladeDef();
}
}
inline const char* BladeName(BladeId id)
{
switch (id)
{
case BladeId::KNIFE: return "KNIFE";
case BladeId::CLEAVER: return "CLEAVER";
default: return "SABER";
}
}

View File

@ -1,41 +1,53 @@
#pragma once
#include <algorithm>
#include "engine/config.h"
// -----------------------------------------------------------------------------
// Единственное место, где живут числа прототипа.
// Единственное место, где живут числа ИГРЫ.
// В коде не должно быть магических констант — всё берётся отсюда.
//
// * блок COMPILE-TIME — размеры буферов/карты, менять только пересборкой;
// * блок COMPILE-TIME — размеры буферов игры, менять только пересборкой;
// * struct Tuning — всё, что подкручивается в рантайме ([ ] выбор, - = шаг).
//
// Размеры кадра, тайла и карты сюда БОЛЬШЕ НЕ ВХОДЯТ: это хозяйство движка, оно
// в engine/config.h. Имена те же, файл включён выше — по коду игры ничего не
// меняется, но движок теперь собирается без единого файла игры.
// -----------------------------------------------------------------------------
// --- compile-time константы --------------------------------------------------
constexpr int INTERNAL_W = 480; // внутреннее разрешение фреймбуфера
constexpr int INTERNAL_H = 270;
constexpr int WINDOW_SCALE = 3; // целочисленный апскейл
constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE;
constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE;
constexpr int TILE_W = 32; // ромб пола
constexpr int TILE_H = 16;
constexpr int TILE_HALF_W = TILE_W / 2;
constexpr int TILE_HALF_H = TILE_H / 2;
constexpr int WALL_H = 32; // спрайт стены 32x32 (верхний ромб + грани)
constexpr float FIXED_DT = 1.0f / 60.0f; // шаг симуляции
constexpr int MAX_STEPS_PER_FRAME = 5; // защита от спирали смерти
constexpr int MAP_W = 48;
constexpr int MAP_H = 48;
// Точка старта отряда (центральная арена).
constexpr float SQUAD_START_X = 26.5f; // ближе к кучке: дальность стала 7.2
constexpr float SQUAD_START_Y = 26.5f;
constexpr int NUM_AGENTS = 5;
constexpr int NUM_TARGETS = 12;
constexpr int NUM_TARGETS = 12; // мишени приёмки: расстановка захардкожена
constexpr int MAX_BULLETS = 512;
// Твари, которых боевой режим доставляет СВЕРХ мишеней. В песочнице приёмки их
// нет: расстановка мишеней и все замеры на ней остаются прежними.
constexpr int NUM_EXTRA_ENEMIES = 12;
// Разрушаемые объекты (ящики, бочки, шкафчики). Спавнятся ТОЛЬКО в боевом режиме;
// приёмка их не видит. Приоритет — ниже всех: ИИ переключается на них только
// если ни одного живого врага в поле зрения нет.
constexpr int NUM_PROPS = 8;
// Потолок на число целей в мире. Он не «на всякий случай»: MeleeWeapon::hitMask —
// это маска «кого уже задел ЭТИМ взмахом», индексируемая номером цели, и цель
// за её пределами молча получала бы удары дважды за взмах.
//
// Было 32. Игра про бесконечный спуск упёрлась в этот потолок примерно на
// шестнадцатом этаже: бюджет опасности перестал влезать в число тел, и глубже
// этажи переставали становиться страшнее. Маска расширена до 64 бит — это не
// «запас на будущее», а ровно та цифра, которой сейчас не хватало.
constexpr int MAX_TARGETS = 64;
static_assert(NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS <= MAX_TARGETS,
"целей больше, чем битов в MeleeWeapon::hitMask");
// У движка потолок населения свой (MAX_LEVEL_SPAWNS в engine/level_gen.h):
// генератор уровней не обязан знать про маску удара клинком. Что одно влезает
// в другое, проверяется там, где видны оба, — в src/game.h.
// Сколько слотов строя занимают бойцы ближнего боя (слоты 0..N-1).
// В клине это остриё и левое крыло — те, кто и так идёт первым.
constexpr int DEFAULT_MELEE_COUNT = 2;
@ -140,6 +152,64 @@ struct Tuning
// серию заново с первого удара.
float meleeComboWindow = 1.10f;
// --- живучесть отряда и тварей -------------------------------------------
// У мишеней приёмки эти числа не работают: DUMMY не бьёт и не ходит,
// поэтому --accept от них не зависит вообще.
float agentHP = 100.0f; // запас бойца до брони и оберегов
float enemyDamageScale = 1.0f; // общая сложность: во сколько раз больно
float enemyRespawn = 16.0f; // через сколько тварь возвращается
float enemyLightPower = 0.14f; // как ярко тлеют глаза в темноте
// Сколько цель «светится» после попадания. Единственный признак, по которому
// в темноте видно, что отряд по кому-то реально работает, а не мажет.
float targetHurtTime = 0.22f;
// --- расход ------------------------------------------------------------------
// Патроны на вылазку. Не «на этаж»: запас едет вниз вместе с отрядом, и
// решение «ещё глубже или наверх» упирается именно в него.
float startAmmo = 120.0f;
// При какой доле здоровья боец сам возьмётся за бинт и на каком удалении
// от ближайшей твари это ему позволено. Лечиться в бою нельзя: перевязка
// посреди схватки обесценила бы урон.
float healAtFrac = 0.55f;
float healSafeDist = 9.0f;
// --- спуск, находки, опыт ---------------------------------------------------
// Ничего из этого не работает в песочнице приёмки: там нет ни лута, ни
// спуска, и `--accept` этих чисел не касается.
//
// Радиус подбора заметно больше тела бойца: подбор пассивный, и промазать
// мимо лежащего предмета, пройдя вплотную, игрок не должен — он ведь по
// нему не кликал.
float lootPickupRadius = 0.65f;
// На каком расстоянии от точки спуска отряд считается дошедшим. Меряется
// по ЯКОРЮ, а не по бойцам: спуск — это решение игрока, а не то, что
// случается от того, что один боец забрёл не туда.
float descendRadius = 1.10f;
// Сколько опыта даёт единица опасности убитого. Опыт этажа сверх этого
// множится на xpScale из кривой глубины (engine/progression.h).
float xpPerDanger = 10.0f;
// --- разрушаемая обстановка ------------------------------------------------
// Радиус, в котором ОДНО ПРИСУТСТВИЕ живой твари запрещает бойцу работать
// по хламу. Намеренно шире дальности любого ствола: привязать его к дальности
// значило бы «пока не достаю — стреляю по ящику», и боец встречал бы
// подошедшую тварь с пустым магазином. Ящик ломают, когда вокруг ЧИСТО.
float propYieldRadius = 15.0f;
// --- атаки тварей ---------------------------------------------------------
// Форма каждой атаки (сектор, радиус круга, длина рывка) — свойство ВИДА и
// живёт в EnemyDef (sim/target.cpp). Здесь только то, что общее для всех:
// именно этими двумя ручками правится «успеваю ли я среагировать».
//
// enemyWindupScale — множитель ВСЕХ телеграфов сразу. Опускать его ниже
// ~0.5 нечестно: замах становится короче времени реакции, и смерть бойца
// снова выглядит случайной.
float enemyWindupScale = 1.0f;
// Сколько захват держится без подтверждения от твари. Тварь каждый шаг
// продлевает его на эту величину; убили её — боец освобождается сам, и
// ни bullet.cpp, ни клинку не приходится знать про захват вообще.
float enemyGrabHold = 0.15f;
// мишени
float targetHP = 500.0f;
float targetRespawn = 3.0f;
@ -412,6 +482,13 @@ inline const TunableParam TUNABLES[] = {
{"reloadTime", &Tuning::reloadRifle, 0.05f, 0.1f, 8.0f},
{"bulletKnock", &Tuning::bulletKnock, 0.05f, 0.0f, 6.0f},
{"knockResist", &Tuning::knockResistScale, 0.05f, 0.0f, 2.0f},
// --- живучесть и сложность ---
{"agentHP", &Tuning::agentHP, 5.0f, 10.0f, 500.0f},
{"enemyDamage", &Tuning::enemyDamageScale, 0.05f, 0.0f, 4.0f},
{"enemyRespawn", &Tuning::enemyRespawn, 1.0f, 1.0f, 300.0f},
{"enemyWindup", &Tuning::enemyWindupScale, 0.05f, 0.25f, 3.0f},
{"enemyGrabHold", &Tuning::enemyGrabHold, 0.01f, 0.02f, 1.0f},
{"propYield", &Tuning::propYieldRadius, 0.5f, 0.0f, 64.0f},
// --- ближний бой ---
{"meleeReach", &Tuning::meleeReach, 0.05f, 0.4f, 4.0f},
{"meleeArc", &Tuning::meleeArcDeg, 5.0f, 20.0f, 300.0f},

1279
src/ui/menu.cpp Normal file

File diff suppressed because it is too large Load Diff

109
src/ui/menu.h Normal file
View File

@ -0,0 +1,109 @@
#pragma once
// -----------------------------------------------------------------------------
// Экраны вне боя: главное меню, пауза, отряд с инвентарём, экран поражения.
//
// Это ВТОРОЕ и последнее место в игре, где разрешены примитивы raylib
// (первое — debug/overlay). Причина та же: здесь нужны слова, а на 480x270
// собственный шрифт стоил бы дороже, чем весь остальной UI вместе взятый.
// Мир по-прежнему рисуется только в софтверный фреймбуфер — меню ложится
// поверх уже готового кадра и никак на него не влияет.
//
// Ui не владеет игрой: он читает её и просит Game сделать конкретные вещи
// (Reset, смена оружия). Никакой логики боя здесь нет.
//
// Управление мышиное: навёл — подсветилось, кликнул — выбралось, потащил
// предмет — переложился. Клавиатура осталась целиком, это второй путь к тем же
// действиям, а не запасной.
// -----------------------------------------------------------------------------
#include "engine/framebuffer.h"
#include "sim/items.h"
struct Game;
enum class Screen : uint8_t
{
MENU = 0, // главное меню
GAME, // бой
PAUSE,
SQUAD, // отряд: снаряжение, сумки и схрон
DEPTH, // с какого чекпоинта начать вылазку
UPGRADE, // во что вложить вынесенный опыт
SUMMARY, // итог вылазки: что вынесли и что потеряли
DEFEAT, // отряд полёг (устарел, ведёт на SUMMARY)
};
// Роль интерактивного элемента. Она же — адрес ячейки инвентаря: слот руки,
// брони и кармана существует по одному, у сумки роль общая, а номер в index.
enum class UiPart : uint8_t
{
NONE = 0,
MENU_ITEM, // пункт главного меню
PAUSE_ITEM,
DEFEAT_ITEM,
AGENT, // карточка бойца
HANDS,
ARMOR,
POCKET,
BAG,
STASH, // ячейка схрона: то, что лежит на базе между вылазками
STAT, // строка характеристик: наводишь — читаешь, что это значит
UPGRADE_ITEM, // строка улучшения: клик покупает следующий уровень
DEPTH_ITEM, // кнопка выбора чекпоинта
SUMMARY_ITEM, // кнопка на экране итога
};
// Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне
// со сдвигом и целым масштабом, и всё, что считает координаты в окне, съезжает.
struct UiRect
{
int x = 0, y = 0, w = 0, h = 0;
bool Has(float fx, float fy) const
{
return fx >= float(x) && fy >= float(y) && fx < float(x + w) && fy < float(y + h);
}
};
struct Ui
{
Screen screen = Screen::MENU;
bool quit = false;
bool started = false; // была ли начата вылазка (появляется «продолжить»)
int menuIndex = 0;
int agentIndex = 0; // выбранный боец на экране отряда
int slotIndex = 0; // выбранная ячейка сумки
int depthIndex = 0; // выбранный чекпоинт на экране глубины
int stashScroll = 0; // прокрутка схрона: он длиннее видимой сетки
float blink = 0.0f; // общий таймер для мигания подсказок
bool cursorHidden = false; // системный курсор скрыт (рисуем свой)
// Меню перезапустило мир. Забирает main и сбрасывает View: кровь и туман
// войны прошлой вылазки не должны переезжать в новую.
bool worldReset = false;
// --- мышь ----------------------------------------------------------------
// Курсор в координатах КАДРА и то, над чем он висит. Всё это считается
// в Update и только читается в Draw: рисование не имеет права трогать
// состояние, иначе подсветка начнёт зависеть от порядка вызовов.
float mx = 0.0f, my = 0.0f;
bool mouseIn = false; // курсор внутри кадра (не на чёрных полях)
UiPart hoverPart = UiPart::NONE;
int hoverIndex = -1;
// Перетаскивание предмета: что тащим и откуда взяли.
bool dragging = false;
UiPart dragFrom = UiPart::NONE;
int dragIndex = -1;
ItemId dragItem = ItemId::NONE;
bool dropOk = false; // слот под курсором примет предмет?
// Ввод меню: мышь и клавиатура в одном месте. fit — куда лёг фреймбуфер
// в окне, по нему считается позиция курсора в координатах кадра.
// Возвращает true, если симуляцию на этом кадре шагать НЕ надо.
bool Update(Game& game, const ScreenRect& fit, float dt);
// Поверх готового кадра. fit — куда лёг фреймбуфер в окне.
void Draw(const Game& game, const ScreenRect& fit) const;
};