Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
|
||||
namespace faset::gameplay {
|
||||
void registerGameplay(runtime::Runtime& engine) {
|
||||
runtime::Behavior character;
|
||||
character.fixedUpdate = [](runtime::Runtime& world, runtime::EntityHandle self, double) {
|
||||
const auto fields = world.fields(self, "gameplay.character");
|
||||
auto velocity = world.velocity(self);
|
||||
const auto input = world.input();
|
||||
velocity[0] = input.horizontal * fields.value("speed", 4.0f);
|
||||
// Support comes from native contact normals, not velocity at the jump apex.
|
||||
if (input.jumpPressed && world.grounded(self))
|
||||
velocity[1] = fields.value("jump_speed", 5.0f);
|
||||
world.setVelocity(self, velocity);
|
||||
};
|
||||
engine.registerBehavior("gameplay.character", std::move(character));
|
||||
|
||||
runtime::Behavior door;
|
||||
auto open = std::make_shared<std::map<std::pair<std::uint64_t, std::uint32_t>, bool>>();
|
||||
door.onStart = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
|
||||
(*open)[{self.session, self.slot}] = false;
|
||||
};
|
||||
door.onDestroy = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
|
||||
open->erase({self.session, self.slot});
|
||||
};
|
||||
door.fixedUpdate = [open](runtime::Runtime& world, runtime::EntityHandle self, double dt) {
|
||||
const auto fields = world.fields(self, "gameplay.door");
|
||||
auto pose = world.transform(self);
|
||||
auto& opened = (*open)[{self.session, self.slot}];
|
||||
if (world.input().interactPressed)
|
||||
opened = !opened;
|
||||
const float target =
|
||||
opened ? fields.value("open_angle", 1.5707963f) : fields.value("closed_angle", 0.0f);
|
||||
const float distance = target - pose.rotation[1];
|
||||
const float amount = std::max(0.0f, fields.value("speed", 1.5f)) * static_cast<float>(dt);
|
||||
pose.rotation[1] += std::clamp(distance, -amount, amount);
|
||||
world.setTransform(self, pose);
|
||||
};
|
||||
engine.registerBehavior("gameplay.door", std::move(door));
|
||||
}
|
||||
|
||||
nlohmann::json schema() {
|
||||
// Explicit declarations shared by Player and SchemaExporter. This function
|
||||
// constructs descriptions only: no Runtime, physics world or lifecycle.
|
||||
return nlohmann::json::array(
|
||||
{{{"id", "gameplay.character"},
|
||||
{"version", 1},
|
||||
{"name", "Character"},
|
||||
{"fields",
|
||||
{{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 4.0}, {"min", 0.0}}},
|
||||
{"jump_speed",
|
||||
{{"id", "jump_speed"}, {"type", "number"}, {"default", 5.0}, {"min", 0.0}}}}}},
|
||||
{{"id", "gameplay.door"},
|
||||
{"version", 1},
|
||||
{"name", "Door"},
|
||||
{"fields",
|
||||
{{"open_angle",
|
||||
{{"id", "open_angle"}, {"type", "number"}, {"default", 1.5707963}, {"units", "rad"}}},
|
||||
{"closed_angle",
|
||||
{{"id", "closed_angle"}, {"type", "number"}, {"default", 0.0}, {"units", "rad"}}},
|
||||
{"speed",
|
||||
{{"id", "speed"},
|
||||
{"type", "number"},
|
||||
{"default", 1.5},
|
||||
{"min", 0.0},
|
||||
{"units", "rad/s"}}}}}}});
|
||||
}
|
||||
} // namespace faset::gameplay
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
|
||||
namespace faset::gameplay {
|
||||
void registerGameplay(runtime::Runtime& runtime);
|
||||
nlohmann::json schema();
|
||||
} // namespace faset::gameplay
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run unmodified Blender and the real Editor importer; verify rename and failure recovery."""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def verify(blender, editor, output):
|
||||
fixture = output / "fixture"
|
||||
result = subprocess.run([str(blender), "--background", "--factory-startup", "--python-exit-code", "1",
|
||||
"--python", str(ROOT / "tests/blender/generate_fixture.py"), "--", str(ROOT), str(fixture)],
|
||||
check=True, capture_output=True, text=True)
|
||||
(output / "blender.log").write_text(result.stdout + result.stderr)
|
||||
assert "FASET_BLENDER_ROUNDTRIP_EXPORT_OK" in result.stdout
|
||||
project = output / "project"
|
||||
source = project / "Assets/door"
|
||||
source.mkdir(parents=True, exist_ok=True)
|
||||
def command(name, arguments):
|
||||
process = subprocess.run([str(editor), "--project", str(project), "--command",
|
||||
json.dumps({"name": name, "arguments": arguments}), "--wait"], capture_output=True, text=True)
|
||||
assert process.stdout, process.stderr
|
||||
return json.loads(process.stdout)
|
||||
def import_stage(stage):
|
||||
shutil.copytree(fixture / stage, source, dirs_exist_ok=True)
|
||||
return command("faset_import", {"path": "Assets/door/manifest.json"})
|
||||
first = import_stage("initial")
|
||||
assert first["state"] == "succeeded", first
|
||||
identity = first["result"]["asset_id"]
|
||||
manifest = first["result"]["manifest"]
|
||||
# Keep an authoring scene with independently owned placement/material values.
|
||||
document = command("faset_document_create", {"name": "Roundtrip", "dimension": 3})
|
||||
scene = document["scene"]
|
||||
scene["entities"] = [{"id": "door", "name": "Gameplay door", "parent": None, "components": [
|
||||
{"id": "door-pose", "type": "faset.transform", "version": 1,
|
||||
"fields": {"position": [4, 0, 2], "rotation": [0, 0, 0], "scale": [1, 1, 1]}},
|
||||
{"id": "door-mesh", "type": "faset.mesh", "version": 1,
|
||||
"fields": {"asset": identity, "primitive": "asset", "color": [0.5, 0.8, 0.3, 1]}}]}]
|
||||
scene_file = project / "roundtrip.scene.json"
|
||||
scene_file.write_text(json.dumps(scene))
|
||||
before = scene_file.read_bytes()
|
||||
renamed = import_stage("renamed")
|
||||
assert renamed["state"] == "succeeded", renamed
|
||||
assert renamed["result"]["asset_id"] == identity
|
||||
assert renamed["result"]["generation"] != first["result"]["generation"]
|
||||
def ids(value):
|
||||
return sorted(value["outputs"])
|
||||
assert ids(manifest) == ids(renamed["result"]["manifest"]), (manifest, renamed)
|
||||
active = command("faset_assets", {})
|
||||
removed = import_stage("removed")
|
||||
assert removed["state"] == "conflict", removed
|
||||
assert command("faset_assets", {}) == active
|
||||
(source / "manifest.json").write_text('{"broken":true}')
|
||||
failed = command("faset_import", {"path": "Assets/door/manifest.json"})
|
||||
assert failed["state"] == "failed", failed
|
||||
assert command("faset_assets", {}) == active
|
||||
assert scene_file.read_bytes() == before
|
||||
report = {"blender": subprocess.check_output([str(blender), "--version"], text=True).splitlines()[0],
|
||||
"asset_id": identity, "initial_generation": first["result"]["generation"],
|
||||
"renamed_generation": renamed["result"]["generation"],
|
||||
"stable_output_ids": True, "removed_output_conflict": True,
|
||||
"failed_import_keeps_generation": True, "authoring_preserved": True}
|
||||
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--blender", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--editor", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--output", type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.output:
|
||||
args.output.resolve().mkdir(parents=True, exist_ok=True)
|
||||
verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve())
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="faset-blender-") as temporary:
|
||||
verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary))
|
||||
Reference in New Issue
Block a user