402 lines
20 KiB
C++
402 lines
20 KiB
C++
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
// Точка входа редактора комнат. Окно, цикл, финальный блит — и всё.
|
|||
|
|
//
|
|||
|
|
// Приложение отдельное от игры намеренно: редактор линкуется только с 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;
|
|||
|
|
}
|