diff --git a/apps/editor_main.cpp b/apps/editor_main.cpp index bf8a9d6..c428bf3 100644 --- a/apps/editor_main.cpp +++ b/apps/editor_main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #ifdef FASET_HAS_EDITOR_UI #include @@ -40,7 +41,7 @@ void help() { std::cout << "Faset Editor\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 --command JSON [--wait]\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; try { 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; bool mcp = false, gui = true, explicit_gui = false, wait = false; + bool explicit_language = false; std::uint64_t frames = 0; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; @@ -74,6 +76,10 @@ int editor_main(int argc, char** argv) { new_name = value(); else if (arg == "--dimension") dimension = std::stoi(value()); + else if (arg == "--language") { + language = value(); + explicit_language = true; + } else if (arg == "--scene") scene = path_from_utf8(value()); else if (arg == "--mcp") @@ -100,30 +106,65 @@ int editor_main(int argc, char** argv) { throw Error("cli.option", "Unknown option: " + arg); } 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) gui = false; require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project", "Use --project PATH for command, MCP, or --new modes"); auto previous_project = project; +#ifdef FASET_HAS_EDITOR_UI + std::optional retry_create; + std::string creation_error; +#endif for (;;) { if (project.empty()) { #ifdef FASET_HAS_EDITOR_UI 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) return 0; project = selection->path; new_name = selection->create ? selection->name : std::string(); dimension = selection->dimension; + language = selection->language; + explicit_language = selection->create; #else throw Error("editor.gui_unavailable", "This build has no graphical project launcher; use --project PATH"); #endif } - Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine), - executable_directory(argv[0])}); - if (!new_name.empty()) - session.scaffold(new_name, dimension); + std::unique_ptr session_holder; + try { + session_holder = std::make_unique(SessionConfig{ + 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(); #ifdef FASET_HAS_EDITOR_UI if (gui && std::filesystem::exists(project / "project.faset.json")) @@ -167,6 +208,8 @@ int editor_main(int argc, char** argv) { project.clear(); scene.clear(); new_name.clear(); + explicit_language = false; + language = "cpp"; continue; #else throw Error( diff --git a/apps/project_launcher.cpp b/apps/project_launcher.cpp index 2302b82..3ec8d6a 100644 --- a/apps/project_launcher.cpp +++ b/apps/project_launcher.cpp @@ -75,6 +75,22 @@ ProjectSelection open_project(const fs::path& path) { throw std::runtime_error("Project name or scene type is invalid."); 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) { try { auto j = read_json(file); @@ -124,9 +140,11 @@ struct ProjectLauncher::Impl { std::optional selected; bool create = false, cancelled = false, browsing = false, browser_valid = false; int dimension = 3; + std::string language = "cpp"; std::string error; Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, - const fs::path& recents) + const fs::path& recents, const std::optional& retry, + std::string creation_error) : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"), recents_file(recents.empty() ? recent_path() : recents) { auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); @@ -147,13 +165,22 @@ struct ProjectLauncher::Impl { ui.set_ime( [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); }); - 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)); - 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-create")->on_click = [this](Widget&) { set_mode(true); }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; 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-cancel")->on_click = [this](Widget&) { cancelled = true; }; 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."); 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."); - 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."); auto parent = path.parent_path(); while (!parent.empty() && !fs::exists(parent)) parent = parent.parent_path(); if (parent.empty() || !fs::is_directory(parent)) throw std::runtime_error("Project parent directory is unavailable."); - selected = ProjectSelection{path, name, dimension, true}; + selected = ProjectSelection{path, name, dimension, true, language}; } else selected = open_project(path); error.clear(); @@ -351,14 +378,24 @@ struct ProjectLauncher::Impl { ui.find("launcher-create")->selected = create; ui.find("launcher-2d")->selected = dimension == 2; 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 = dimension == 2 ? Appearance::Quiet : Appearance::Default; ui.find("launcher-3d")->appearance = 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", "launcher-dimensions", "launcher-name-group", "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("launcher-heading")->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, - const fs::path& initial, const fs::path& recents) - : impl_(std::make_unique(r, engine, initial, recents)) {} + const fs::path& initial, const fs::path& recents, + const std::optional& retry, + std::string creation_error) + : impl_(std::make_unique(r, engine, initial, recents, retry, + std::move(creation_error))) {} ProjectLauncher::~ProjectLauncher() = default; void ProjectLauncher::frame(const std::vector& events) { impl_->frame(events); @@ -445,9 +485,11 @@ bool ProjectLauncher::cancelled() const { std::optional run_project_launcher(const fs::path& engine, const fs::path& initial, std::uint64_t max_frames, - const fs::path& capture) { + const fs::path& capture, + const std::optional& retry, + std::string creation_error) { 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; while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && (max_frames == 0 || frames < max_frames)) { diff --git a/assets/ui/project-launcher-layout.json b/assets/ui/project-launcher-layout.json index 896c9a0..ab03e67 100644 --- a/assets/ui/project-launcher-layout.json +++ b/assets/ui/project-launcher-layout.json @@ -41,6 +41,15 @@ {"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-actions-space", "kind": "row", "layout": {"height": 22}}, {"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}}, diff --git a/docs/manual/getting-started/build.md b/docs/manual/getting-started/build.md index 614f5c2..6b7aed6 100644 --- a/docs/manual/getting-started/build.md +++ b/docs/manual/getting-started/build.md @@ -31,17 +31,27 @@ cmake --build --preset linux-debug --parallel 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 -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 -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**. 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. For an optimized build use `linux-release`. The `linux-sanitize` preset enables diff --git a/docs/manual/scripting/lua.md b/docs/manual/scripting/lua.md index 778be21..96dbcbb 100644 --- a/docs/manual/scripting/lua.md +++ b/docs/manual/scripting/lua.md @@ -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. 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 Add explicit entry scripts to `project.faset.json`: diff --git a/include/faset/editor/build_service.hpp b/include/faset/editor/build_service.hpp index 5ea32b2..9b1593a 100644 --- a/include/faset/editor/build_service.hpp +++ b/include/faset/editor/build_service.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace faset::editor { @@ -38,6 +39,9 @@ class BuildService { BuildService(const BuildService&) = delete; BuildService& operator=(const BuildService&) = delete; 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_cook(Json resolved_scene); // Publishes output/generations/; current.json changes only after all validation succeeds. diff --git a/include/faset/editor/project_launcher.hpp b/include/faset/editor/project_launcher.hpp index aca2fb7..adfc45f 100644 --- a/include/faset/editor/project_launcher.hpp +++ b/include/faset/editor/project_launcher.hpp @@ -8,6 +8,7 @@ struct ProjectSelection { std::string name; int dimension = 3; bool create = false; + std::string language = "cpp"; }; // Records only existing, valid projects. Call after successful Session setup. void remember_project(const std::filesystem::path& project); @@ -15,7 +16,9 @@ class ProjectLauncher { public: ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root, const std::filesystem::path& initial_project = {}, - const std::filesystem::path& recent_store = {}); + const std::filesystem::path& recent_store = {}, + const std::optional& retry = {}, + std::string creation_error = {}); ~ProjectLauncher(); void frame(const std::vector&); const render::Snapshot& snapshot() const; @@ -30,5 +33,7 @@ class ProjectLauncher { std::optional run_project_launcher(const std::filesystem::path& engine_root, 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& retry = {}, + std::string creation_error = {}); } // namespace faset::editor diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp index 4577e72..3790084 100644 --- a/include/faset/editor/session.hpp +++ b/include/faset/editor/session.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace faset::editor { @@ -33,6 +34,7 @@ class Session { return plugins_ ? plugins_->panels() : Json::array(); } void scaffold(const std::string& name, int dimension); + void scaffold(const std::string& name, int dimension, std::string_view language); const SessionConfig& config() const { return config_; } diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 4635df8..643e533 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -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); } +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(); + std::erase_if(components, [](const Json& component) { + return !component.at("type").get().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 Json JobStatus::json() const { 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")) 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> 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() { return impl_->enqueue("build"); } diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 1da1eaa..75eb3e3 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -113,6 +113,10 @@ void Session::scaffold(const std::string& name, int dimension) { builds_.scaffold(name, dimension); 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 list = Json::array(); const auto directory = assets_.cache_root() / "assets"; diff --git a/tests/build_service_tests.cpp b/tests/build_service_tests.cpp index 5417bd1..d875fff 100644 --- a/tests/build_service_tests.cpp +++ b/tests/build_service_tests.cpp @@ -1,8 +1,12 @@ #include "assets_image_fixtures.hpp" +#include +#include #include #include #include +#include #include +#include #include #include #include @@ -42,6 +46,102 @@ Json scene(int dimension) { {"name", "Build test"}, {"dimension", dimension}, {"entities", 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(); + std::erase_if(components, [&](const Json& component) { + const auto custom = + !component.at("type").get().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 ready = 0; + std::atomic 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) { fs::create_directories(root); require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency"); @@ -379,6 +479,7 @@ int test_main(int argc, char** argv) { try { fs::create_directories(temporary); lua_project_contracts(temporary / "lua-project"); + starter_contracts(temporary / "starters"); const auto original_executable = fs::absolute(path_from_utf8(argv[0])); const auto executable = temporary / original_executable.filename(); fs::copy_file(original_executable, executable); diff --git a/tests/editor_ui_launcher.cpp b/tests/editor_ui_launcher.cpp index f60a539..5c07262 100644 --- a/tests/editor_ui_launcher.cpp +++ b/tests/editor_ui_launcher.cpp @@ -111,6 +111,9 @@ int main() { text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename())); click(launcher, "launcher-2d"); 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.capture(root / "launcher-create.ppm"); launcher.frame({key("Return", true)}); @@ -118,6 +121,7 @@ int main() { "Create through keyboard rejected: " + launcher.widgets().find("launcher-error")->text); check(launcher.selection()->create && launcher.selection()->dimension == 2 && + launcher.selection()->language == "lua" && launcher.selection()->name == "Тестовый проект", "Create through keyboard preserves typed project metadata"); check(launcher.selection()->path == new_project, @@ -127,6 +131,19 @@ int main() { check(!std::filesystem::exists(new_project), "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), root / "Missing", recents); diff --git a/tools/project_templates/lua-main.lua b/tools/project_templates/lua-main.lua new file mode 100644 index 0000000..e00ec8c --- /dev/null +++ b/tools/project_templates/lua-main.lua @@ -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