Offer runnable C++ and Lua project starters

This commit is contained in:
Emil
2026-09-24 01:59:17 +03:00
parent d4c1f0e86b
commit 6459d69cc7
13 changed files with 487 additions and 22 deletions
+50 -7
View File
@@ -4,6 +4,7 @@
#include <faset/editor/mcp.hpp> #include <faset/editor/mcp.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <iostream> #include <iostream>
#include <memory>
#include <thread> #include <thread>
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp> #include <faset/editor/editor_ui.hpp>
@@ -40,7 +41,7 @@ void help() {
std::cout std::cout
<< "Faset Editor\n" << "Faset Editor\n"
" faset_editor Open the project launcher\n" " faset_editor Open the project launcher\n"
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n" " faset_editor --project PATH [--new NAME --dimension 2|3 [--language cpp|lua]] [--scene RELATIVE_PATH]\n"
" faset_editor --project PATH --mcp [--gui]\n" " faset_editor --project PATH --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n" " faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n" "Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
@@ -52,9 +53,10 @@ int editor_main(int argc, char** argv) {
using namespace faset::editor; using namespace faset::editor;
try { try {
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture; std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture;
std::string new_name, command; std::string new_name, command, language = "cpp";
int dimension = 3; int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false; bool mcp = false, gui = true, explicit_gui = false, wait = false;
bool explicit_language = false;
std::uint64_t frames = 0; std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -74,6 +76,10 @@ int editor_main(int argc, char** argv) {
new_name = value(); new_name = value();
else if (arg == "--dimension") else if (arg == "--dimension")
dimension = std::stoi(value()); dimension = std::stoi(value());
else if (arg == "--language") {
language = value();
explicit_language = true;
}
else if (arg == "--scene") else if (arg == "--scene")
scene = path_from_utf8(value()); scene = path_from_utf8(value());
else if (arg == "--mcp") else if (arg == "--mcp")
@@ -100,30 +106,65 @@ int editor_main(int argc, char** argv) {
throw Error("cli.option", "Unknown option: " + arg); throw Error("cli.option", "Unknown option: " + arg);
} }
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command"); require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
require(!explicit_language || !new_name.empty(), "cli.language",
"--language requires --new NAME");
require(language == "cpp" || language == "lua", "cli.language",
"Language must be cpp or lua");
if (mcp && !explicit_gui) if (mcp && !explicit_gui)
gui = false; gui = false;
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project", require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
"Use --project PATH for command, MCP, or --new modes"); "Use --project PATH for command, MCP, or --new modes");
auto previous_project = project; auto previous_project = project;
#ifdef FASET_HAS_EDITOR_UI
std::optional<ProjectSelection> retry_create;
std::string creation_error;
#endif
for (;;) { for (;;) {
if (project.empty()) { if (project.empty()) {
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
const auto selection = const auto selection =
run_project_launcher(engine, previous_project, frames, capture); run_project_launcher(engine, previous_project, frames, capture,
retry_create, creation_error);
retry_create.reset();
creation_error.clear();
if (!selection) if (!selection)
return 0; return 0;
project = selection->path; project = selection->path;
new_name = selection->create ? selection->name : std::string(); new_name = selection->create ? selection->name : std::string();
dimension = selection->dimension; dimension = selection->dimension;
language = selection->language;
explicit_language = selection->create;
#else #else
throw Error("editor.gui_unavailable", throw Error("editor.gui_unavailable",
"This build has no graphical project launcher; use --project PATH"); "This build has no graphical project launcher; use --project PATH");
#endif #endif
} }
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine), std::unique_ptr<Session> session_holder;
executable_directory(argv[0])}); try {
if (!new_name.empty()) session_holder = std::make_unique<Session>(SessionConfig{
session.scaffold(new_name, dimension); std::filesystem::absolute(project), std::filesystem::absolute(engine),
executable_directory(argv[0])});
if (!new_name.empty()) {
if (explicit_language)
session_holder->scaffold(new_name, dimension, language);
else
session_holder->scaffold(new_name, dimension);
}
} catch (const std::exception& error) {
#ifdef FASET_HAS_EDITOR_UI
if (gui && !new_name.empty()) {
retry_create = ProjectSelection{std::filesystem::absolute(project),
new_name, dimension, true, language};
creation_error = error.what();
project.clear();
new_name.clear();
scene.clear();
continue;
}
#endif
throw;
}
Session& session = *session_holder;
const auto settings = session.project(); const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json")) if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -167,6 +208,8 @@ int editor_main(int argc, char** argv) {
project.clear(); project.clear();
scene.clear(); scene.clear();
new_name.clear(); new_name.clear();
explicit_language = false;
language = "cpp";
continue; continue;
#else #else
throw Error( throw Error(
+52 -10
View File
@@ -75,6 +75,22 @@ ProjectSelection open_project(const fs::path& path) {
throw std::runtime_error("Project name or scene type is invalid."); throw std::runtime_error("Project name or scene type is invalid.");
return {path, std::move(name), dimension, false}; return {path, std::move(name), dimension, false};
} }
bool fresh_destination(const fs::path& path) {
if (!fs::exists(path))
return true;
if (!fs::is_directory(path) || fs::is_symlink(path))
return false;
for (const auto& entry : fs::directory_iterator(path)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
return false;
for (const auto& internal : fs::directory_iterator(entry.path()))
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
return false;
}
return true;
}
Json recent_records(const fs::path& file) { Json recent_records(const fs::path& file) {
try { try {
auto j = read_json(file); auto j = read_json(file);
@@ -124,9 +140,11 @@ struct ProjectLauncher::Impl {
std::optional<ProjectSelection> selected; std::optional<ProjectSelection> selected;
bool create = false, cancelled = false, browsing = false, browser_valid = false; bool create = false, cancelled = false, browsing = false, browser_valid = false;
int dimension = 3; int dimension = 3;
std::string language = "cpp";
std::string error; std::string error;
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial,
const fs::path& recents) const fs::path& recents, const std::optional<ProjectSelection>& retry,
std::string creation_error)
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"), : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) { recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
@@ -147,13 +165,22 @@ struct ProjectLauncher::Impl {
ui.set_ime( ui.set_ime(
[this](bool enabled) { renderer.set_text_input(enabled); }, [this](bool enabled) { renderer.set_text_input(enabled); },
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); }); [this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial; const auto start = retry ? retry->path
: initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
ui.update_text("launcher-path", path_to_utf8(start)); ui.update_text("launcher-path", path_to_utf8(start));
create = initial.empty(); create = retry ? retry->create : initial.empty();
if (retry) {
ui.update_text("launcher-name", retry->name);
dimension = retry->dimension;
language = retry->language;
error = std::move(creation_error);
}
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); }; ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); }; ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; }; ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
ui.find("launcher-cpp")->on_click = [this](Widget&) { language = "cpp"; };
ui.find("launcher-lua")->on_click = [this](Widget&) { language = "lua"; };
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); }; ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; }; ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); }; ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -209,14 +236,14 @@ struct ProjectLauncher::Impl {
throw std::runtime_error("Enter a project name."); throw std::runtime_error("Enter a project name.");
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos) if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos)
throw std::runtime_error("Use a short project name on one line."); throw std::runtime_error("Use a short project name on one line.");
if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path))) if (!fresh_destination(path))
throw std::runtime_error("Create requires a new or empty directory."); throw std::runtime_error("Create requires a new or empty directory.");
auto parent = path.parent_path(); auto parent = path.parent_path();
while (!parent.empty() && !fs::exists(parent)) while (!parent.empty() && !fs::exists(parent))
parent = parent.parent_path(); parent = parent.parent_path();
if (parent.empty() || !fs::is_directory(parent)) if (parent.empty() || !fs::is_directory(parent))
throw std::runtime_error("Project parent directory is unavailable."); throw std::runtime_error("Project parent directory is unavailable.");
selected = ProjectSelection{path, name, dimension, true}; selected = ProjectSelection{path, name, dimension, true, language};
} else } else
selected = open_project(path); selected = open_project(path);
error.clear(); error.clear();
@@ -351,14 +378,24 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create; ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2; ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3; ui.find("launcher-3d")->selected = dimension == 3;
ui.find("launcher-cpp")->selected = language == "cpp";
ui.find("launcher-lua")->selected = language == "lua";
ui.find("launcher-2d")->appearance = ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default; dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance = ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default; dimension == 3 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-cpp")->appearance =
language == "cpp" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-lua")->appearance =
language == "lua" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-language-hint")->text =
language == "cpp" ? "Compiled gameplay. Add Lua later if you need quick reloads."
: "Lua gameplay reloads during development without a C++ rebuild.";
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label", for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions", "launcher-name-group", "launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space", "launcher-name-space", "launcher-path-space",
"launcher-dimension-group"}) "launcher-dimension-group", "launcher-language-group",
"launcher-language-space"})
ui.find(id)->visible = create; ui.find(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project"; ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->text = create ? "Create project" : "Open project"; ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
@@ -424,8 +461,11 @@ struct ProjectLauncher::Impl {
} }
}; };
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine, ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
const fs::path& initial, const fs::path& recents) const fs::path& initial, const fs::path& recents,
: impl_(std::make_unique<Impl>(r, engine, initial, recents)) {} const std::optional<ProjectSelection>& retry,
std::string creation_error)
: impl_(std::make_unique<Impl>(r, engine, initial, recents, retry,
std::move(creation_error))) {}
ProjectLauncher::~ProjectLauncher() = default; ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) { void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events); impl_->frame(events);
@@ -445,9 +485,11 @@ bool ProjectLauncher::cancelled() const {
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine, std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
const fs::path& initial, const fs::path& initial,
std::uint64_t max_frames, std::uint64_t max_frames,
const fs::path& capture) { const fs::path& capture,
const std::optional<ProjectSelection>& retry,
std::string creation_error) {
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true}); render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
ProjectLauncher launcher(renderer, engine, initial); ProjectLauncher launcher(renderer, engine, initial, {}, retry, std::move(creation_error));
std::uint64_t frames = 0; std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) { (max_frames == 0 || frames < max_frames)) {
+9
View File
@@ -41,6 +41,15 @@
{"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}} {"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}}
]} ]}
]}, ]},
{"id": "launcher-language-space", "kind": "row", "layout": {"height": 18}},
{"id": "launcher-language-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-language-label", "kind": "label", "text": "Gameplay language", "layout": {"height": 24}},
{"id": "launcher-languages", "kind": "row", "layout": {"height": 42, "gap": 4}, "children": [
{"id": "launcher-cpp", "kind": "button", "text": "C++", "layout": {"width": 116}},
{"id": "launcher-lua", "kind": "button", "text": "Lua", "layout": {"width": 116}}
]},
{"id": "launcher-language-hint", "kind": "label", "text": "Compiled gameplay. Add Lua later if you need quick reloads.", "font_size": 12, "layout": {"height": 30}}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}}, {"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}},
{"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}}, {"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}}, {"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
+13 -3
View File
@@ -31,17 +31,27 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug ctest --preset linux-debug
``` ```
Create a project and open the native Editor: Create a runnable C++ or Lua starter and open the native Editor:
```sh ```sh
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 --language cpp
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
``` ```
You can also run `build/linux-debug/faset_editor` without arguments to open the You can also run `build/linux-debug/faset_editor` without arguments to open the
project launcher and create or select a project using the native interface. project launcher. Its Create view offers C++ 2D, C++ 3D, Lua 2D, and Lua 3D.
Each explicit starter includes `Scenes/main.scene.json` with a player, ground,
and a behavior bound to `Scripts/Gameplay.cpp` or `Scripts/main.lua`. 3D starters
also include a camera and light. Existing files in a destination directory are
never replaced by starter creation. The older `--new NAME --dimension 2|3`
command without `--language` retains its original scene-free C++ scaffold.
Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**. Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay. The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
Lua starters declare their entry script in `project.faset.json`, install LuaLS
annotations/configuration, and need no `Gameplay.cpp`. Use **Refresh Lua** after
editing a Lua behavior declaration; development **Reload Lua** can apply a valid
script edit to a running Player without recompiling C++.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation. See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables For an optimized build use `linux-release`. The `linux-sanitize` preset enables
+17
View File
@@ -5,6 +5,23 @@ behaviors share the same runtime lifecycle, typed entity operations, scene compo
and Inspector metadata. The Editor does not run gameplay code in its own process. and Inspector metadata. The Editor does not run gameplay code in its own process.
There is no built-in script editor: edit `.lua` files in Zed or another external editor. There is no built-in script editor: edit `.lua` files in Zed or another external editor.
## Start with a runnable Lua project
Choose **Create project → Lua → 2D or 3D** in the native launcher, or run:
```sh
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
The starter creates `Scripts/main.lua`, a scene with a player and ground, and a
manifest entry for that script. The player moves horizontally and jumps with the
default input actions. The 3D choice also provides a camera and directional light.
Open `Scripts/main.lua` in your external editor and change `speed` or
`fixed_update`. **Refresh Lua** updates declared Inspector fields; **Reload Lua**
updates a development Player after a valid edit without rebuilding native gameplay.
The starter installs LuaLS declarations and `.luarc.json`; reopening it in Zed or
another LuaLS-enabled editor provides completions for the Faset API.
## Enable Lua in a project ## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`: Add explicit entry scripts to `project.faset.json`:
+4
View File
@@ -3,6 +3,7 @@
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
#include <string_view>
#include <vector> #include <vector>
namespace faset::editor { namespace faset::editor {
@@ -38,6 +39,9 @@ class BuildService {
BuildService(const BuildService&) = delete; BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete; BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension); void scaffold(const std::string& name, int dimension);
// Explicit starter choice creates a runnable start scene. The legacy overload above
// remains intentionally scene-free for existing command-line and API callers.
void scaffold(const std::string& name, int dimension, std::string_view language);
std::string start_build(); std::string start_build();
std::string start_cook(Json resolved_scene); std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds. // Publishes output/generations/<id>; current.json changes only after all validation succeeds.
+7 -2
View File
@@ -8,6 +8,7 @@ struct ProjectSelection {
std::string name; std::string name;
int dimension = 3; int dimension = 3;
bool create = false; bool create = false;
std::string language = "cpp";
}; };
// Records only existing, valid projects. Call after successful Session setup. // Records only existing, valid projects. Call after successful Session setup.
void remember_project(const std::filesystem::path& project); void remember_project(const std::filesystem::path& project);
@@ -15,7 +16,9 @@ class ProjectLauncher {
public: public:
ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root, ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, const std::filesystem::path& initial_project = {},
const std::filesystem::path& recent_store = {}); const std::filesystem::path& recent_store = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
~ProjectLauncher(); ~ProjectLauncher();
void frame(const std::vector<render::Event>&); void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const; const render::Snapshot& snapshot() const;
@@ -30,5 +33,7 @@ class ProjectLauncher {
std::optional<ProjectSelection> std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root, run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, const std::filesystem::path& initial_project = {},
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {}); std::uint64_t max_frames = 0, const std::filesystem::path& capture = {},
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
} // namespace faset::editor } // namespace faset::editor
+2
View File
@@ -6,6 +6,7 @@
#include <faset/editor/plugins.hpp> #include <faset/editor/plugins.hpp>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <string_view>
#include <thread> #include <thread>
namespace faset::editor { namespace faset::editor {
@@ -33,6 +34,7 @@ class Session {
return plugins_ ? plugins_->panels() : Json::array(); return plugins_ ? plugins_->panels() : Json::array();
} }
void scaffold(const std::string& name, int dimension); void scaffold(const std::string& name, int dimension);
void scaffold(const std::string& name, int dimension, std::string_view language);
const SessionConfig& config() const { const SessionConfig& config() const {
return config_; return config_;
} }
+189
View File
@@ -1,3 +1,4 @@
#include <algorithm>
#include <atomic> #include <atomic>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
@@ -6,6 +7,7 @@
#include <faset/assets/asset_data.hpp> #include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp> #include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
@@ -91,6 +93,102 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio
} }
throw std::runtime_error("Build did not produce " + target); throw std::runtime_error("Build did not produce " + target);
} }
Json starter_scene(const std::string& name, int dimension, std::string_view language) {
const auto schemas = authoring::builtin_schemas();
auto scene = authoring::make_scene(name + " — Starter", dimension);
scene["simulation"] = authoring::default_simulation_settings();
auto add_component = [&](Json& entity, std::string type, Json fields) {
entity["components"].push_back({{"id", new_id()},
{"type", std::move(type)},
{"version", 1},
{"fields", std::move(fields)}});
};
auto ground = authoring::make_entity(schemas, "Ground");
auto& ground_pose = ground["components"][0]["fields"];
ground_pose["position"] = {0, -1, 0};
ground_pose["scale"] = dimension == 2 ? Json::array({12, 1, 1}) : Json::array({12, 1, 12});
if (dimension == 2) {
add_component(ground, "faset.sprite", {{"size", {1, 1}},
{"color", {0.20, 0.25, 0.31, 1}}});
} else {
add_component(ground, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.20, 0.25, 0.31, 1}}});
}
auto ground_body = schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) +
"d");
ground_body["body_type"] = "static";
add_component(ground, "faset.rigid_body_" + std::to_string(dimension) + "d",
std::move(ground_body));
scene["entities"].push_back(std::move(ground));
auto actor = authoring::make_entity(schemas, "Player");
actor["components"][0]["fields"]["position"] = {0, 1, 0};
if (dimension == 2) {
add_component(actor, "faset.sprite", {{"size", {0.8, 0.8}},
{"color", {0.25, 0.72, 0.85, 1}}});
} else {
actor["components"][0]["fields"]["scale"] = {0.8, 0.8, 0.8};
add_component(actor, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.25, 0.72, 0.85, 1}}});
}
add_component(actor, "faset.rigid_body_" + std::to_string(dimension) + "d",
schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) + "d"));
add_component(actor, language == "lua" ? "starter.player" : "gameplay.character",
{{"speed", 4.0}, {"jump_speed", 5.0}});
scene["entities"].push_back(std::move(actor));
if (dimension == 3) {
auto camera = authoring::make_entity(schemas, "Camera");
camera["components"][0]["fields"]["position"] = {0, 2.5, 8};
camera["components"][0]["fields"]["rotation"] = {-0.18, 0, 0};
add_component(camera, "faset.camera", schemas.default_fields("faset.camera"));
scene["entities"].push_back(std::move(camera));
auto sun = authoring::make_entity(schemas, "Sun");
add_component(sun, "faset.light", schemas.default_fields("faset.light"));
scene["entities"].push_back(std::move(sun));
}
// Validate every built-in component now; the behavior component is validated by
// SchemaExporter from the selected C++/Lua module during the first build.
auto builtins = scene;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [](const Json& component) {
return !component.at("type").get<std::string>().starts_with("faset.");
});
}
authoring::validate_scene(builtins, schemas);
return scene;
}
struct StarterCreateLock {
fs::path path;
explicit StarterCreateLock(const fs::path& root) : path(root / ".faset/starter-create.lock") {
if (!fs::create_directory(path))
throw std::runtime_error("Another project creation is in progress; remove a stale " +
path_to_utf8(path) + " only after closing that Editor");
}
~StarterCreateLock() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
StarterCreateLock(const StarterCreateLock&) = delete;
StarterCreateLock& operator=(const StarterCreateLock&) = delete;
};
void require_new_starter_directory(const fs::path& root) {
// BuildService creates .faset/cache in its constructor. Any other content is
// user-owned, including a previous starter, and must be left intact.
for (const auto& entry : fs::directory_iterator(root)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
throw std::runtime_error("Create requires a new or empty project directory");
for (const auto& internal : fs::directory_iterator(entry.path())) {
if (internal.path().filename() == "starter-create.lock" &&
internal.is_directory() && !internal.is_symlink())
continue;
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
throw std::runtime_error("Create requires a new or empty project directory");
}
}
}
} // namespace } // namespace
Json JobStatus::json() const { Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state}, return {{"id", id}, {"kind", kind}, {"state", state},
@@ -905,6 +1003,97 @@ void BuildService::scaffold(const std::string& name, int dimension) {
if (!fs::exists(c.project_root / ".gitignore")) if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n"); atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
} }
void BuildService::scaffold(const std::string& name, int dimension,
std::string_view language) {
if (name.empty() || (dimension != 2 && dimension != 3) ||
(language != "cpp" && language != "lua"))
throw std::invalid_argument("Starter requires a name, 2D/3D and cpp/lua language");
const auto& c = impl_->config;
StarterCreateLock lock(c.project_root);
require_new_starter_directory(c.project_root);
const auto scene = starter_scene(name, dimension, language);
const auto source = c.engine_root / "tools/project_templates" /
(language == "lua" ? "lua-main.lua" : "Gameplay.cpp");
const auto module = read_text(source);
const auto header = language == "cpp"
? read_text(c.engine_root / "tools/project_templates/Gameplay.hpp")
: std::string();
const auto lua_annotations = language == "lua"
? read_text(c.engine_root / "tools/lua/faset.lua")
: std::string();
const auto lua_config = language == "lua"
? read_json(c.engine_root / "tools/lua/luarc.json")
: Json();
const auto lua_scripts_config =
language == "lua" ? read_json(c.engine_root / "tools/lua/luarc-scripts.json") : Json();
const auto stage = lock.path / "staged";
fs::create_directories(stage / "Scripts");
fs::create_directories(stage / "Scenes");
fs::create_directories(stage / "Assets");
if (language == "cpp") {
atomic_write(stage / "Scripts/Gameplay.cpp", module);
atomic_write(stage / "Scripts/Gameplay.hpp", header);
} else {
atomic_write(stage / "Scripts/main.lua", module);
atomic_write(stage / ".faset/lua/faset.lua", lua_annotations);
atomic_write_json(stage / ".luarc.json", lua_config);
atomic_write_json(stage / "Scripts/.luarc.json", lua_scripts_config);
}
atomic_write_json(stage / "Scenes/main.scene.json", scene);
Json project = {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}};
if (language == "lua")
project["scripting"] = {{"lua", {{"scripts", Json::array({"Scripts/main.lua"})}}}};
atomic_write_json(stage / "project.faset.json", project);
atomic_write(stage / ".gitignore",
".faset/*\n!.faset/lua/\n.faset/lua/*\n!.faset/lua/faset.lua\nExports/\n");
std::vector<std::pair<fs::path, std::string>> created;
auto publish = [&](const fs::path& relative) {
const auto source = stage / relative;
const auto target = c.project_root / relative;
fs::create_directories(target.parent_path());
if (!fs::copy_file(source, target, fs::copy_options::none))
throw std::runtime_error("Starter destination appeared during creation: " +
path_to_utf8(target));
created.emplace_back(target, sha256_file(source));
};
try {
publish(".gitignore");
if (language == "cpp") {
publish("Scripts/Gameplay.cpp");
publish("Scripts/Gameplay.hpp");
} else {
publish("Scripts/main.lua");
publish(".faset/lua/faset.lua");
publish(".luarc.json");
publish("Scripts/.luarc.json");
}
publish("Scenes/main.scene.json");
fs::create_directory(c.project_root / "Assets");
// The manifest is the final commit marker: an interrupted create has no
// valid project record and never replaces an existing project file.
publish("project.faset.json");
} catch (...) {
for (auto it = created.rbegin(); it != created.rend(); ++it) {
try {
std::error_code ignored;
if (fs::is_regular_file(it->first, ignored) &&
sha256_file(it->first) == it->second)
fs::remove(it->first, ignored);
} catch (...) { /* Preserve the original create failure. */
}
}
for (const auto& relative : {"Assets", "Scenes", "Scripts", ".faset/lua"}) {
std::error_code ignored;
fs::remove(c.project_root / relative, ignored); // Empty directories only.
}
throw;
}
}
std::string BuildService::start_build() { std::string BuildService::start_build() {
return impl_->enqueue("build"); return impl_->enqueue("build");
} }
+4
View File
@@ -113,6 +113,10 @@ void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension); builds_.scaffold(name, dimension);
log("Created project: " + name); log("Created project: " + name);
} }
void Session::scaffold(const std::string& name, int dimension, std::string_view language) {
builds_.scaffold(name, dimension, language);
log("Created " + std::string(language) + " starter project: " + name);
}
Json Session::assets_list() const { Json Session::assets_list() const {
Json list = Json::array(); Json list = Json::array();
const auto directory = assets_.cache_root() / "assets"; const auto directory = assets_.cache_root() / "assets";
+101
View File
@@ -1,8 +1,12 @@
#include "assets_image_fixtures.hpp" #include "assets_image_fixtures.hpp"
#include <algorithm>
#include <atomic>
#include <bit> #include <bit>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <functional>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
@@ -42,6 +46,102 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()}, {"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}}; {"instances", Json::array()}};
} }
void starter_contracts(const fs::path& root) {
for (const int dimension : {2, 3}) {
for (const char* language : {"cpp", "lua"}) {
const auto project = root / path_from_utf8(std::string("Starter café ") + language +
std::to_string(dimension));
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService service(config);
service.scaffold("Starter", dimension, language);
const auto manifest = read_json(project / "project.faset.json");
const auto start = read_json(project / "Scenes/main.scene.json");
require(manifest.at("dimension") == dimension &&
manifest.at("start_scene") == "Scenes/main.scene.json",
"Explicit starter has the requested scene type and startup path");
require(start.at("dimension") == dimension && !start.at("entities").empty(),
"Starter scene is valid and has visible contents");
auto builtins = start;
bool has_behavior = false;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [&](const Json& component) {
const auto custom =
!component.at("type").get<std::string>().starts_with("faset.");
has_behavior |= custom;
return custom;
});
}
require(has_behavior, "Starter scene binds its gameplay behavior");
authoring::validate_scene(builtins, authoring::builtin_schemas());
if (std::string_view(language) == "lua") {
require(!fs::exists(project / "Scripts/Gameplay.cpp") &&
!fs::exists(project / "Scripts/Gameplay.hpp"),
"Lua starter has no C++ gameplay stub");
require(manifest.at("scripting").at("lua").at("scripts") ==
Json::array({"Scripts/main.lua"}) &&
scripting::loadLuaProject(project).enabled() &&
fs::is_regular_file(project / ".luarc.json") &&
fs::is_regular_file(project / "Scripts/.luarc.json") &&
fs::is_regular_file(project / ".faset/lua/faset.lua"),
"Lua starter declares an actual runnable module");
} else {
require(fs::is_regular_file(project / "Scripts/Gameplay.cpp") &&
fs::is_regular_file(project / "Scripts/Gameplay.hpp") &&
!manifest.contains("scripting"),
"C++ starter contains its compiled gameplay module");
}
const auto marker = read_text(project / "Scenes/main.scene.json");
bool rejected = false;
try {
service.scaffold("Again", dimension, language);
} catch (const std::exception&) {
rejected = true;
}
require(rejected && read_text(project / "Scenes/main.scene.json") == marker,
"Explicit creation rejects existing project without overwriting it");
}
}
editor::BuildConfig legacy;
legacy.project_root = root / "legacy";
legacy.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService(legacy).scaffold("Legacy", 3);
require(!fs::exists(legacy.project_root / "Scenes/main.scene.json"),
"Two-argument scaffold retains its prior no-scene behavior");
const auto concurrent_root = root / "concurrent";
editor::BuildConfig concurrent;
concurrent.project_root = concurrent_root;
concurrent.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService first(concurrent), second(concurrent);
std::atomic<int> ready = 0;
std::atomic<int> successes = 0;
auto attempt = [&](editor::BuildService& service, const char* name) {
++ready;
while (ready.load() < 2)
std::this_thread::yield();
try {
service.scaffold(name, 2, "lua");
++successes;
} catch (const std::exception&) {
}
};
std::thread a(attempt, std::ref(first), "First");
std::thread b(attempt, std::ref(second), "Second");
a.join();
b.join();
require(successes == 1, "Two concurrent creators publish exactly one starter");
const auto winner = read_json(concurrent_root / "project.faset.json");
require(winner.at("name") == "First" || winner.at("name") == "Second",
"Published manifest belongs to the successful creator");
require(fs::is_regular_file(concurrent_root / "Scripts/main.lua") &&
fs::is_regular_file(concurrent_root / "Scenes/main.scene.json"),
"The winning starter publishes all required files");
const auto ignore = read_text(concurrent_root / ".gitignore");
require(ignore.find("!.faset/lua/faset.lua") != std::string::npos,
"LuaLS declarations are commit-friendly in generated projects");
}
void lua_project_contracts(const fs::path& root) { void lua_project_contracts(const fs::path& root) {
fs::create_directories(root); fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency"); require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
@@ -379,6 +479,7 @@ int test_main(int argc, char** argv) {
try { try {
fs::create_directories(temporary); fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project"); lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0])); const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename(); const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable); fs::copy_file(original_executable, executable);
+17
View File
@@ -111,6 +111,9 @@ int main() {
text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename())); text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename()));
click(launcher, "launcher-2d"); click(launcher, "launcher-2d");
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection"); check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
click(launcher, "launcher-lua");
check(launcher.widgets().find("launcher-lua")->selected,
"Lua project language selection");
renderer.render(launcher.snapshot()); renderer.render(launcher.snapshot());
renderer.capture(root / "launcher-create.ppm"); renderer.capture(root / "launcher-create.ppm");
launcher.frame({key("Return", true)}); launcher.frame({key("Return", true)});
@@ -118,6 +121,7 @@ int main() {
"Create through keyboard rejected: " + "Create through keyboard rejected: " +
launcher.widgets().find("launcher-error")->text); launcher.widgets().find("launcher-error")->text);
check(launcher.selection()->create && launcher.selection()->dimension == 2 && check(launcher.selection()->create && launcher.selection()->dimension == 2 &&
launcher.selection()->language == "lua" &&
launcher.selection()->name == "Тестовый проект", launcher.selection()->name == "Тестовый проект",
"Create through keyboard preserves typed project metadata"); "Create through keyboard preserves typed project metadata");
check(launcher.selection()->path == new_project, check(launcher.selection()->path == new_project,
@@ -127,6 +131,19 @@ int main() {
check(!std::filesystem::exists(new_project), check(!std::filesystem::exists(new_project),
"Launcher does not create a partial project before Session scaffold"); "Launcher does not create a partial project before Session scaffold");
} }
{
editor::ProjectSelection retry{new_project, "Retry starter", 3, true, "lua"};
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
recents, retry, "Template source is unavailable");
launcher.frame({});
check(launcher.widgets().find("launcher-create")->selected &&
launcher.widgets().find("launcher-lua")->selected &&
launcher.widgets().find("launcher-path")->text == path_to_utf8(new_project) &&
launcher.widgets().find("launcher-name")->text == "Retry starter" &&
launcher.widgets().find("launcher-error")->text ==
"Template source is unavailable",
"Failed starter returns to Create with the chosen options and error");
}
{ {
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
root / "Missing", recents); root / "Missing", recents);
+22
View File
@@ -0,0 +1,22 @@
-- Faset starter gameplay. Edit this file and use Refresh Lua or development reload;
-- the native C++ engine does not need to recompile for a Lua-only edit.
local Player = faset.behavior {
id = "starter.player",
version = 1,
name = "Player",
fields = {
speed = { name = "Move speed", type = "number", default = 4, min = 0 },
jump_speed = { name = "Jump speed", type = "number", default = 5, min = 0 }
}
}
function Player:fixed_update(delta)
local velocity = self.entity:velocity()
velocity.x = faset.input().horizontal * self.fields.speed
if faset.input().jump_pressed and self.entity:is_grounded() then
velocity.y = self.fields.jump_speed
end
self.entity:set_velocity(velocity)
end
return Player