Offer runnable C++ and Lua project starters
This commit is contained in:
+50
-7
@@ -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
@@ -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)) {
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user