Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <faset/editor/mcp.hpp>
|
||||
#include <thread>
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include <stb_image_write.h>
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
std::string base64(const std::vector<unsigned char>& bytes) {
|
||||
constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((bytes.size() + 2) / 3) * 4);
|
||||
for (std::size_t i = 0; i < bytes.size(); i += 3) {
|
||||
const auto a = bytes[i];
|
||||
const auto b = i + 1 < bytes.size() ? bytes[i + 1] : 0,
|
||||
c = i + 2 < bytes.size() ? bytes[i + 2] : 0;
|
||||
out += alphabet[a >> 2];
|
||||
out += alphabet[((a & 3) << 4) | (b >> 4)];
|
||||
out += i + 1 < bytes.size() ? alphabet[((b & 15) << 2) | (c >> 6)] : '=';
|
||||
out += i + 2 < bytes.size() ? alphabet[c & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::vector<unsigned char> png(render::Renderer& renderer, std::array<float, 4> rectangle) {
|
||||
const auto width = renderer.width(), height = renderer.height();
|
||||
const auto pixels = renderer.pixels();
|
||||
int x = std::clamp(static_cast<int>(rectangle[0]), 0, static_cast<int>(width) - 1),
|
||||
y = std::clamp(static_cast<int>(rectangle[1]), 0, static_cast<int>(height) - 1);
|
||||
const int w = std::clamp(static_cast<int>(rectangle[2]), 1, static_cast<int>(width) - x),
|
||||
h = std::clamp(static_cast<int>(rectangle[3]), 1, static_cast<int>(height) - y);
|
||||
std::vector<unsigned char> cropped(static_cast<std::size_t>(w) * h * 4), encoded;
|
||||
for (int row = 0; row < h; ++row)
|
||||
std::copy_n(pixels.begin() + (static_cast<std::size_t>(row + y) * width + x) * 4,
|
||||
static_cast<std::size_t>(w) * 4,
|
||||
cropped.begin() + static_cast<std::size_t>(row) * w * 4);
|
||||
const auto writer = [](void* context, void* data, int count) {
|
||||
auto& out = *static_cast<std::vector<unsigned char>*>(context);
|
||||
auto* begin = static_cast<unsigned char*>(data);
|
||||
out.insert(out.end(), begin, begin + count);
|
||||
};
|
||||
require(stbi_write_png_to_func(writer, &encoded, w, h, 4, cropped.data(), w * 4) != 0,
|
||||
"capture.encode", "Cannot encode editor screenshot");
|
||||
return encoded;
|
||||
}
|
||||
} // namespace
|
||||
int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
|
||||
const std::filesystem::path& capture) {
|
||||
render::Renderer renderer({1440, 900,
|
||||
"Faset — " + session.project().value("name", std::string("Project")),
|
||||
false, true});
|
||||
const auto font = session.config().engine_root / "assets/fonts/NotoSans.ttf";
|
||||
const auto theme = session.config().engine_root / "assets/ui/dark.json";
|
||||
EditorUI ui(session, renderer, font, theme);
|
||||
McpServer server(session.commands());
|
||||
StdioTransport transport;
|
||||
session.commands().add(
|
||||
"faset_editor_capture",
|
||||
"Capture the Editor or its authoring viewport as a PNG image. Requires the graphical "
|
||||
"Editor and Vulkan; never captures a Player process.",
|
||||
Commands::object_schema(
|
||||
{{"path", {{"type", "string"}}}, {"viewport_only", {{"type", "boolean"}}}}),
|
||||
[&](const Json& arguments) {
|
||||
session.poll();
|
||||
ui.frame({});
|
||||
renderer.render(ui.snapshot());
|
||||
auto region =
|
||||
arguments.value("viewport_only", true)
|
||||
? ui.snapshot().scene_rect
|
||||
: std::array<float, 4>{0, 0, float(renderer.width()), float(renderer.height())};
|
||||
if (region[2] <= 0 || region[3] <= 0)
|
||||
region = {0, 0, float(renderer.width()), float(renderer.height())};
|
||||
auto encoded = png(renderer, region);
|
||||
const auto relative =
|
||||
arguments.value("path", std::string(".faset/screenshots/editor.png"));
|
||||
atomic_write(
|
||||
project_path(session.config().project_root, relative),
|
||||
std::string_view(reinterpret_cast<const char*>(encoded.data()), encoded.size()));
|
||||
return Json{{"path", relative},
|
||||
{"mimeType", "image/png"},
|
||||
{"width", static_cast<int>(region[2])},
|
||||
{"height", static_cast<int>(region[3])},
|
||||
{"image_base64", base64(encoded)}};
|
||||
},
|
||||
true);
|
||||
std::uint64_t frame = 0;
|
||||
while (!renderer.should_close() && (max_frames == 0 || frame < max_frames)) {
|
||||
if (enable_mcp)
|
||||
for (const auto& line : transport.poll()) {
|
||||
try {
|
||||
const auto reply = server.handle(Json::parse(line));
|
||||
if (reply)
|
||||
transport.send(*reply);
|
||||
} catch (const Json::exception&) {
|
||||
transport.send(server.parse_error());
|
||||
}
|
||||
}
|
||||
session.poll();
|
||||
ui.frame(renderer.poll_events());
|
||||
renderer.render(ui.snapshot());
|
||||
++frame;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
if (!capture.empty())
|
||||
renderer.capture(capture);
|
||||
return renderer.stats().validation_errors == 0 ? 0 : 2;
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -0,0 +1,172 @@
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/mcp.hpp>
|
||||
#include <faset/editor/session.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
namespace faset::editor {
|
||||
int run_editor_ui(Session&, bool, std::uint64_t, const std::filesystem::path&);
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
volatile std::sig_atomic_t interrupted = 0;
|
||||
void interrupt(int) {
|
||||
interrupted = 1;
|
||||
}
|
||||
std::filesystem::path executable_directory(const char* argument) {
|
||||
#ifdef _WIN32
|
||||
std::wstring path(32768, L'\0');
|
||||
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
|
||||
if (length == 0 || length >= path.size())
|
||||
throw std::runtime_error("Cannot locate Editor executable");
|
||||
path.resize(length);
|
||||
return std::filesystem::path(path).parent_path();
|
||||
#else
|
||||
std::error_code error;
|
||||
const auto path = std::filesystem::read_symlink("/proc/self/exe", error);
|
||||
return error ? std::filesystem::absolute(argument).parent_path() : path.parent_path();
|
||||
#endif
|
||||
}
|
||||
void help() {
|
||||
std::cout
|
||||
<< "Faset Editor\n"
|
||||
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n"
|
||||
" faset_editor --project PATH --mcp [--gui]\n"
|
||||
" faset_editor --project PATH --command JSON [--wait]\n"
|
||||
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
|
||||
"MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n";
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
using namespace faset;
|
||||
using namespace faset::editor;
|
||||
try {
|
||||
std::filesystem::path project, engine = FASET_ENGINE_SOURCE, scene, capture;
|
||||
std::string new_name, command;
|
||||
int dimension = 3;
|
||||
bool mcp = false, gui = true, explicit_gui = false, wait = false;
|
||||
std::uint64_t frames = 0;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
auto value = [&]() {
|
||||
require(i + 1 < argc, "cli.argument", "Missing value for " + arg);
|
||||
return std::string(argv[++i]);
|
||||
};
|
||||
if (arg == "--help" || arg == "-h") {
|
||||
help();
|
||||
return 0;
|
||||
}
|
||||
if (arg == "--project")
|
||||
project = value();
|
||||
else if (arg == "--engine")
|
||||
engine = value();
|
||||
else if (arg == "--new")
|
||||
new_name = value();
|
||||
else if (arg == "--dimension")
|
||||
dimension = std::stoi(value());
|
||||
else if (arg == "--scene")
|
||||
scene = value();
|
||||
else if (arg == "--mcp")
|
||||
mcp = true;
|
||||
else if (arg == "--gui") {
|
||||
gui = true;
|
||||
explicit_gui = true;
|
||||
} else if (arg == "--headless")
|
||||
gui = false;
|
||||
else if (arg == "--command") {
|
||||
command = value();
|
||||
gui = false;
|
||||
} else if (arg == "--wait")
|
||||
wait = true;
|
||||
else if (arg == "--frames") {
|
||||
const auto text = value();
|
||||
require(!text.empty() && text.find_first_not_of("0123456789") == std::string::npos,
|
||||
"cli.frames", "Frame count must be positive");
|
||||
frames = std::stoull(text);
|
||||
require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range");
|
||||
} else if (arg == "--capture")
|
||||
capture = value();
|
||||
else
|
||||
throw Error("cli.option", "Unknown option: " + arg);
|
||||
}
|
||||
require(!project.empty(), "cli.project", "Use --project PATH to select a project");
|
||||
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
|
||||
if (mcp && !explicit_gui)
|
||||
gui = false;
|
||||
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
|
||||
executable_directory(argv[0])});
|
||||
if (!new_name.empty())
|
||||
session.scaffold(new_name, dimension);
|
||||
const auto settings = session.project();
|
||||
if (scene.empty())
|
||||
scene = settings.value("start_scene", std::string());
|
||||
if (!scene.empty() &&
|
||||
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
||||
session.authoring().open(scene);
|
||||
if (!command.empty()) {
|
||||
const auto request = Json::parse(command);
|
||||
auto result = session.commands().call(request.at("name"),
|
||||
request.value("arguments", Json::object()));
|
||||
if (wait && result.contains("job")) {
|
||||
const auto id = result.at("job");
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
for (;;) {
|
||||
session.poll();
|
||||
result = session.commands().call("faset_job", {{"id", id}});
|
||||
const auto state = result.value("state", std::string());
|
||||
if (state == "succeeded" || state == "failed" || state == "cancelled" ||
|
||||
state == "conflict")
|
||||
break;
|
||||
require(std::chrono::steady_clock::now() - started < std::chrono::minutes(30),
|
||||
"job.timeout", "Command wait exceeded 30 minutes");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
}
|
||||
std::cout << result.dump(2) << '\n';
|
||||
const auto state = result.value("state", std::string());
|
||||
return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0;
|
||||
}
|
||||
if (gui) {
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
return run_editor_ui(session, mcp, frames, capture);
|
||||
#else
|
||||
throw Error("editor.gui_unavailable",
|
||||
"This build has no graphical editor; use --mcp or --command, or build with "
|
||||
"FASET_BUILD_EDITOR=ON");
|
||||
#endif
|
||||
}
|
||||
require(mcp, "cli.mode", "Headless mode requires --mcp or --command");
|
||||
std::signal(SIGINT, interrupt);
|
||||
std::signal(SIGTERM, interrupt);
|
||||
McpServer server(session.commands());
|
||||
StdioTransport transport;
|
||||
while (!interrupted && !transport.closed()) {
|
||||
for (const auto& line : transport.poll()) {
|
||||
try {
|
||||
const auto reply = server.handle(Json::parse(line));
|
||||
if (reply)
|
||||
transport.send(*reply);
|
||||
} catch (const Json::exception&) {
|
||||
transport.send(server.parse_error());
|
||||
}
|
||||
}
|
||||
session.poll();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
return 0;
|
||||
} catch (const Error& error) {
|
||||
std::cerr << error.json().dump() << '\n';
|
||||
return 1;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << Json{{"code", "editor.failure"}, {"message", error.what()}}.dump() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#if defined(_WIN32)
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
std::filesystem::path executableDirectory(const char* argument) {
|
||||
#if defined(_WIN32)
|
||||
std::wstring path(32768, L'\0');
|
||||
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
|
||||
if (length == 0 || length >= path.size())
|
||||
throw std::runtime_error("Cannot locate Player executable");
|
||||
path.resize(length);
|
||||
return std::filesystem::path(path).parent_path();
|
||||
#else
|
||||
std::error_code error;
|
||||
auto executable = std::filesystem::read_symlink("/proc/self/exe", error);
|
||||
return error ? std::filesystem::absolute(argument).parent_path() : executable.parent_path();
|
||||
#endif
|
||||
}
|
||||
std::uint64_t count(const std::string& value) {
|
||||
if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("Frame count must be a positive integer");
|
||||
auto result = std::stoull(value);
|
||||
if (result == 0 || result > 10000000)
|
||||
throw std::invalid_argument("Frame count out of range");
|
||||
return result;
|
||||
}
|
||||
faset::runtime::RuntimeConfig simulationConfig(const nlohmann::json& scene) {
|
||||
faset::runtime::RuntimeConfig config;
|
||||
if (!scene.contains("simulation"))
|
||||
return config;
|
||||
const auto& settings = scene.at("simulation");
|
||||
if (!settings.is_object())
|
||||
throw std::invalid_argument("simulation must be an object");
|
||||
config.fixedDelta = settings.value("fixed_delta", config.fixedDelta);
|
||||
auto boundedInteger = [&](const char* key, int fallback, int maximum) {
|
||||
if (!settings.contains(key))
|
||||
return fallback;
|
||||
const auto& value = settings.at(key);
|
||||
if (!value.is_number_integer())
|
||||
throw std::invalid_argument(std::string(key) + " must be an integer");
|
||||
// Check before narrowing, so very large unsigned values cannot wrap into
|
||||
// a valid configuration on a platform with a narrower unsigned type.
|
||||
const auto numeric = value.get<double>();
|
||||
if (numeric < 1 || numeric > maximum)
|
||||
throw std::invalid_argument(std::string(key) + " is out of range");
|
||||
return value.get<int>();
|
||||
};
|
||||
config.maxCatchUpTicks = static_cast<unsigned>(
|
||||
boundedInteger("max_catch_up_ticks", static_cast<int>(config.maxCatchUpTicks), 1024));
|
||||
config.physicsSubsteps = boundedInteger("physics_substeps", config.physicsSubsteps, 128);
|
||||
if (settings.contains("gravity")) {
|
||||
const auto& gravity = settings.at("gravity");
|
||||
if (!gravity.is_array() || gravity.size() != 3)
|
||||
throw std::invalid_argument("gravity must contain three numbers");
|
||||
config.gravity = gravity.get<faset::runtime::Vec3>();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
void validatePackagedShaders(const std::filesystem::path& directory) {
|
||||
for (const auto* name : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) {
|
||||
const auto path = directory / "shaders" / name;
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Packaged shader is missing: " + path.string());
|
||||
const auto bytes = faset::read_text(path);
|
||||
if (bytes.size() < 20 || bytes.size() % 4 != 0 ||
|
||||
static_cast<unsigned char>(bytes[0]) != 0x03 ||
|
||||
static_cast<unsigned char>(bytes[1]) != 0x02 ||
|
||||
static_cast<unsigned char>(bytes[2]) != 0x23 ||
|
||||
static_cast<unsigned char>(bytes[3]) != 0x07)
|
||||
throw std::runtime_error("Packaged shader is not a SPIR-V module: " + path.string());
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath;
|
||||
bool headless = false, validateOnly = false;
|
||||
std::uint64_t maximumFrames = 0;
|
||||
std::set<std::string> options;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
if (arg == "--help") {
|
||||
std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] "
|
||||
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH]\n"
|
||||
"No --scene: open scene.fscene beside the executable. CACHE contains "
|
||||
"assets/<id>/.\n"
|
||||
"Headless uses offscreen Vulkan; --frames uses the configured fixed "
|
||||
"simulation delta.\n"
|
||||
"--validate checks scene/resources on CPU without gameplay callbacks "
|
||||
"or Vulkan initialization.\n"
|
||||
"--control is an optional editor mailbox for pause/resume/step/stop, "
|
||||
"without world queries.\n"
|
||||
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, "
|
||||
"N single-step, Escape quit.\n";
|
||||
return 0;
|
||||
}
|
||||
if (!options.insert(arg).second)
|
||||
throw std::invalid_argument("Repeated option: " + arg);
|
||||
auto value = [&]() -> std::string {
|
||||
if (i + 1 >= argc)
|
||||
throw std::invalid_argument("Missing value for " + arg);
|
||||
return argv[++i];
|
||||
};
|
||||
if (arg == "--scene")
|
||||
scenePath = value();
|
||||
else if (arg == "--assets")
|
||||
assetsPath = value();
|
||||
else if (arg == "--capture")
|
||||
capturePath = value();
|
||||
else if (arg == "--control")
|
||||
controlPath = value();
|
||||
else if (arg == "--frames")
|
||||
maximumFrames = count(value());
|
||||
else if (arg == "--headless")
|
||||
headless = true;
|
||||
else if (arg == "--validate")
|
||||
validateOnly = true;
|
||||
else
|
||||
throw std::invalid_argument("Unknown option: " + arg);
|
||||
}
|
||||
if (scenePath.empty())
|
||||
scenePath = executableDirectory(argv[0]) / "scene.fscene";
|
||||
scenePath = std::filesystem::absolute(scenePath).lexically_normal();
|
||||
if (assetsPath.empty())
|
||||
assetsPath = scenePath.parent_path();
|
||||
if (!std::filesystem::is_directory(assetsPath))
|
||||
throw std::invalid_argument("Asset cache directory does not exist: " +
|
||||
assetsPath.string());
|
||||
if (headless && maximumFrames == 0)
|
||||
maximumFrames = 1;
|
||||
const auto document = faset::player::readScene(scenePath);
|
||||
const auto config = simulationConfig(document);
|
||||
const auto executableRoot = executableDirectory(argv[0]);
|
||||
if (scenePath.extension() == ".fscene" &&
|
||||
std::filesystem::equivalent(scenePath.parent_path(), executableRoot))
|
||||
validatePackagedShaders(executableRoot);
|
||||
if (validateOnly) {
|
||||
if (!capturePath.empty() || !controlPath.empty())
|
||||
throw std::invalid_argument("--validate cannot capture or control a running game");
|
||||
faset::runtime::Runtime validator(config);
|
||||
validator.load(document);
|
||||
faset::player::SceneView view(assetsPath);
|
||||
view.build(document, 16.0f / 9.0f);
|
||||
for (const auto& diagnostic : view.diagnostics()) {
|
||||
if (diagnostic.starts_with("error:"))
|
||||
throw std::runtime_error(diagnostic);
|
||||
std::cerr << diagnostic << '\n';
|
||||
}
|
||||
std::cout << nlohmann::json{{"validated", true},
|
||||
{"dimension", document.value("dimension", 3)}}
|
||||
.dump()
|
||||
<< '\n';
|
||||
return 0;
|
||||
}
|
||||
faset::runtime::Runtime world(config);
|
||||
faset::gameplay::registerGameplay(world);
|
||||
world.load(document);
|
||||
faset::player::SceneView view(assetsPath);
|
||||
faset::render::Renderer renderer(
|
||||
{1280, 720, document.value("name", std::string("Faset Player")), headless, true});
|
||||
std::set<std::string> held;
|
||||
bool stop = false;
|
||||
std::uint64_t frames = 0;
|
||||
std::size_t logCursor = 0;
|
||||
std::set<std::string> reported;
|
||||
std::uint64_t controlSequence = 0;
|
||||
std::string previousControl;
|
||||
auto previous = std::chrono::steady_clock::now();
|
||||
while (!stop && !renderer.should_close() &&
|
||||
(maximumFrames == 0 || frames < maximumFrames)) {
|
||||
faset::runtime::InputState input;
|
||||
bool singleStep = false;
|
||||
if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) {
|
||||
try {
|
||||
if (std::filesystem::file_size(controlPath) > 65536)
|
||||
throw std::runtime_error("control message exceeds 64 KiB");
|
||||
auto content = faset::read_text(controlPath);
|
||||
if (content != previousControl) {
|
||||
previousControl = content;
|
||||
const auto message = nlohmann::json::parse(content);
|
||||
const auto& sequence = message.at("sequence");
|
||||
if (!(sequence.is_number_unsigned() ||
|
||||
(sequence.is_number_integer() && sequence.get<std::int64_t>() >= 0)))
|
||||
throw std::invalid_argument(
|
||||
"control sequence must be a nonnegative integer");
|
||||
const auto value = sequence.get<std::uint64_t>();
|
||||
if (value > controlSequence) {
|
||||
const auto command = message.at("command").get<std::string>();
|
||||
if (command == "pause")
|
||||
world.setPaused(true);
|
||||
else if (command == "resume")
|
||||
world.setPaused(false);
|
||||
else if (command == "step") {
|
||||
world.setPaused(true);
|
||||
singleStep = true;
|
||||
} else if (command == "stop")
|
||||
stop = true;
|
||||
else
|
||||
throw std::invalid_argument("unsupported control command");
|
||||
controlSequence = value;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
const std::string message =
|
||||
std::string("Player control ignored: ") + error.what();
|
||||
if (reported.insert(message).second)
|
||||
std::cerr << message << '\n';
|
||||
}
|
||||
}
|
||||
for (const auto& event : renderer.poll_events()) {
|
||||
using Type = faset::render::Event::Type;
|
||||
if (event.type == Type::Quit)
|
||||
stop = true;
|
||||
if (event.type == Type::FocusLost)
|
||||
held.clear();
|
||||
std::string key = event.key;
|
||||
std::transform(key.begin(), key.end(), key.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||
if (event.type == Type::KeyUp)
|
||||
held.erase(key);
|
||||
if (event.type == Type::KeyDown) {
|
||||
held.insert(key);
|
||||
if (key == "ESCAPE")
|
||||
stop = true;
|
||||
if (!event.repeat) {
|
||||
if (key == "SPACE")
|
||||
input.jumpPressed = true;
|
||||
if (key == "E")
|
||||
input.interactPressed = true;
|
||||
if (key == "P")
|
||||
world.setPaused(!world.paused());
|
||||
if (key == "N")
|
||||
singleStep = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stop)
|
||||
break;
|
||||
input.horizontal = float(held.contains("D") || held.contains("RIGHT")) -
|
||||
float(held.contains("A") || held.contains("LEFT"));
|
||||
input.vertical = float(held.contains("W") || held.contains("UP")) -
|
||||
float(held.contains("S") || held.contains("DOWN"));
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const double elapsed = maximumFrames
|
||||
? config.fixedDelta
|
||||
: std::chrono::duration<double>(now - previous).count();
|
||||
previous = now;
|
||||
if (singleStep && world.paused())
|
||||
world.singleStep(input);
|
||||
else
|
||||
world.advance(elapsed, input);
|
||||
auto snapshot = view.build(world.snapshotJson(), static_cast<float>(renderer.width()) /
|
||||
std::max(1u, renderer.height()));
|
||||
for (const auto& diagnostic : view.diagnostics()) {
|
||||
if (diagnostic.starts_with("error:"))
|
||||
throw std::runtime_error(diagnostic);
|
||||
if (reported.insert(diagnostic).second)
|
||||
std::cerr << diagnostic << '\n';
|
||||
}
|
||||
while (logCursor < world.diagnostics().size())
|
||||
std::cerr << world.diagnostics()[logCursor++] << '\n';
|
||||
renderer.render(snapshot);
|
||||
++frames;
|
||||
}
|
||||
if (!capturePath.empty()) {
|
||||
if (frames == 0)
|
||||
throw std::runtime_error("No frame was rendered for capture");
|
||||
renderer.capture(capturePath);
|
||||
}
|
||||
const auto stats = renderer.stats();
|
||||
std::cout << nlohmann::json{{"frames", frames},
|
||||
{"ticks", world.snapshot().tick},
|
||||
{"dimension", document.value("dimension", 3)},
|
||||
{"device", stats.device},
|
||||
{"validation_errors", stats.validation_errors}}
|
||||
.dump()
|
||||
<< '\n';
|
||||
return stats.validation_errors == 0 ? 0 : 2;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Player failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <faset/core/io.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
std::filesystem::path output;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string argument = argv[i];
|
||||
if (argument == "--help") {
|
||||
std::cout << "faset_schema_exporter [--output PATH]\nExports declarative gameplay "
|
||||
"schemas without creating a world.\n";
|
||||
return 0;
|
||||
}
|
||||
if (argument == "--output" && i + 1 < argc && output.empty())
|
||||
output = argv[++i];
|
||||
else
|
||||
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
|
||||
argument);
|
||||
}
|
||||
const auto types = faset::gameplay::schema();
|
||||
if (!types.is_array())
|
||||
throw std::runtime_error("Gameplay schema() must return a type array");
|
||||
const nlohmann::json manifest{{"format", "faset.schema"}, {"version", 1}, {"types", types}};
|
||||
if (output.empty())
|
||||
std::cout << manifest.dump(2) << '\n';
|
||||
else
|
||||
faset::atomic_write_json(output, manifest);
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Schema export failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user