Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+207 -65
View File
@@ -1,7 +1,7 @@
#include "Physics.hpp"
#include <algorithm>
#include <box2d/box2d.h>
#include <box3d/box3d.h>
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <unordered_map>
@@ -15,103 +15,245 @@ b3Quat quaternion(Vec3 e) {
return b3MulQuat(z, b3MulQuat(y, x));
}
Vec3 euler(b3Quat q) {
const float x=q.v.x, y=q.v.y, z=q.v.z, w=q.s;
return {std::atan2(2*(w*x+y*z), 1-2*(x*x+y*y)),
std::asin(std::clamp(2*(w*y-z*x), -1.0f, 1.0f)),
std::atan2(2*(w*z+x*y), 1-2*(y*y+z*z))};
}
const float x = q.v.x, y = q.v.y, z = q.v.z, w = q.s;
return {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)),
std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))};
}
} // namespace
struct Physics::Impl {
struct Body { b2BodyId two{}; b3BodyId three{}; std::uint64_t shape{}; bool dynamic{}; };
struct Body {
b2BodyId two{};
b3BodyId three{};
std::uint64_t shape{};
bool dynamic{};
bool contactQueryValid{};
};
int dimension;
int substeps;
Vec3 up{0, 1, 0};
b2WorldId world2{};
b3WorldId world3{};
std::unordered_map<std::uint32_t, Body> bodies;
std::unordered_map<std::uint64_t, std::uint32_t> shapes;
Impl(int dim, Vec3 gravity, int count):dimension(dim),substeps(count) {
if(dim==2) { auto def=b2DefaultWorldDef(); def.gravity={gravity[0],gravity[1]}; world2=b2CreateWorld(&def); }
else { auto def=b3DefaultWorldDef(); def.gravity={gravity[0],gravity[1],gravity[2]}; world3=b3CreateWorld(&def); }
Impl(int dim, Vec3 gravity, int count) : dimension(dim), substeps(count) {
const float length = std::sqrt(gravity[0] * gravity[0] + gravity[1] * gravity[1] +
(dim == 3 ? gravity[2] * gravity[2] : 0));
if (length > 0.00001f)
up = {-gravity[0] / length, -gravity[1] / length, dim == 3 ? -gravity[2] / length : 0};
if (dim == 2) {
auto def = b2DefaultWorldDef();
def.gravity = {gravity[0], gravity[1]};
world2 = b2CreateWorld(&def);
} else {
auto def = b3DefaultWorldDef();
def.gravity = {gravity[0], gravity[1], gravity[2]};
world3 = b3CreateWorld(&def);
}
}
~Impl() {
if (dimension == 2)
b2DestroyWorld(world2);
else
b3DestroyWorld(world3);
}
~Impl() { if(dimension==2) b2DestroyWorld(world2); else b3DestroyWorld(world3); }
};
Physics::Physics(int dimension, Vec3 gravity, int substeps):impl_(std::make_unique<Impl>(dimension,gravity,substeps)){}
Physics::~Physics()=default;
Physics::Physics(int dimension, Vec3 gravity, int substeps)
: impl_(std::make_unique<Impl>(dimension, gravity, substeps)) {}
Physics::~Physics() = default;
void Physics::add(std::uint32_t id, const Transform& t, const BodySettings& settings) {
if(contains(id)) throw std::logic_error("physics body already exists");
Impl::Body body{}; body.dynamic=settings.type=="dynamic";
if(impl_->dimension==2) {
auto def=b2DefaultBodyDef();
def.type=settings.type=="static"?b2_staticBody:settings.type=="kinematic"?b2_kinematicBody:b2_dynamicBody;
def.position={t.position[0],t.position[1]}; def.rotation=b2MakeRot(t.rotation[2]);
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1]}; def.gravityScale=settings.gravityScale;
body.two=b2CreateBody(impl_->world2,&def);
auto shape=b2DefaultShapeDef(); shape.density=settings.density; shape.material.friction=settings.friction;
shape.material.restitution=settings.restitution; shape.enableContactEvents=true;
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
const auto box=b2MakeBox(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]));
body.shape=b2StoreShapeId(b2CreatePolygonShape(body.two,&shape,&box));
if (contains(id))
throw std::logic_error("physics body already exists");
Impl::Body body{};
body.dynamic = settings.type == "dynamic";
if (impl_->dimension == 2) {
auto def = b2DefaultBodyDef();
def.type = settings.type == "static" ? b2_staticBody
: settings.type == "kinematic" ? b2_kinematicBody
: b2_dynamicBody;
def.position = {t.position[0], t.position[1]};
def.rotation = b2MakeRot(t.rotation[2]);
def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1]};
def.gravityScale = settings.gravityScale;
body.two = b2CreateBody(impl_->world2, &def);
auto shape = b2DefaultShapeDef();
shape.density = settings.density;
shape.material.friction = settings.friction;
shape.material.restitution = settings.restitution;
shape.enableContactEvents = true;
shape.filter.categoryBits = settings.categoryBits;
shape.filter.maskBits = settings.maskBits;
const auto box = b2MakeBox(settings.halfExtents[0] * std::abs(t.scale[0]),
settings.halfExtents[1] * std::abs(t.scale[1]));
body.shape = b2StoreShapeId(b2CreatePolygonShape(body.two, &shape, &box));
} else {
auto def=b3DefaultBodyDef();
def.type=settings.type=="static"?b3_staticBody:settings.type=="kinematic"?b3_kinematicBody:b3_dynamicBody;
def.position={t.position[0],t.position[1],t.position[2]}; def.rotation=quaternion(t.rotation);
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1],settings.linearVelocity[2]}; def.gravityScale=settings.gravityScale;
body.three=b3CreateBody(impl_->world3,&def);
auto shape=b3DefaultShapeDef(); shape.density=settings.density; shape.baseMaterial.friction=settings.friction;
shape.baseMaterial.restitution=settings.restitution; shape.enableContactEvents=true;
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
auto box=b3MakeBoxHull(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]),settings.halfExtents[2]*std::abs(t.scale[2]));
body.shape=b3StoreShapeId(b3CreateHullShape(body.three,&shape,&box.base));
auto def = b3DefaultBodyDef();
def.type = settings.type == "static" ? b3_staticBody
: settings.type == "kinematic" ? b3_kinematicBody
: b3_dynamicBody;
def.position = {t.position[0], t.position[1], t.position[2]};
def.rotation = quaternion(t.rotation);
def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1],
settings.linearVelocity[2]};
def.gravityScale = settings.gravityScale;
body.three = b3CreateBody(impl_->world3, &def);
auto shape = b3DefaultShapeDef();
shape.density = settings.density;
shape.baseMaterial.friction = settings.friction;
shape.baseMaterial.restitution = settings.restitution;
shape.enableContactEvents = true;
shape.filter.categoryBits = settings.categoryBits;
shape.filter.maskBits = settings.maskBits;
auto box = b3MakeBoxHull(settings.halfExtents[0] * std::abs(t.scale[0]),
settings.halfExtents[1] * std::abs(t.scale[1]),
settings.halfExtents[2] * std::abs(t.scale[2]));
body.shape = b3StoreShapeId(b3CreateHullShape(body.three, &shape, &box.base));
}
impl_->shapes.emplace(body.shape,id); impl_->bodies.emplace(id,body);
impl_->shapes.emplace(body.shape, id);
impl_->bodies.emplace(id, body);
}
void Physics::remove(std::uint32_t id) {
const auto it=impl_->bodies.find(id); if(it==impl_->bodies.end()) return;
const auto it = impl_->bodies.find(id);
if (it == impl_->bodies.end())
return;
impl_->shapes.erase(it->second.shape);
if(impl_->dimension==2) b2DestroyBody(it->second.two); else b3DestroyBody(it->second.three);
if (impl_->dimension == 2)
b2DestroyBody(it->second.two);
else
b3DestroyBody(it->second.three);
impl_->bodies.erase(it);
}
bool Physics::contains(std::uint32_t id) const { return impl_->bodies.contains(id); }
bool Physics::dynamic(std::uint32_t id) const { return impl_->bodies.at(id).dynamic; }
bool Physics::contains(std::uint32_t id) const {
return impl_->bodies.contains(id);
}
bool Physics::dynamic(std::uint32_t id) const {
return impl_->bodies.at(id).dynamic;
}
Transform Physics::transform(std::uint32_t id, Transform t) const {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) { auto p=b2Body_GetPosition(body.two); t.position[0]=p.x;t.position[1]=p.y;t.rotation[2]=b2Rot_GetAngle(b2Body_GetRotation(body.two)); }
else { auto p=b3Body_GetPosition(body.three);t.position={float(p.x),float(p.y),float(p.z)};t.rotation=euler(b3Body_GetRotation(body.three)); }
const auto& body = impl_->bodies.at(id);
if (impl_->dimension == 2) {
auto p = b2Body_GetPosition(body.two);
t.position[0] = p.x;
t.position[1] = p.y;
t.rotation[2] = b2Rot_GetAngle(b2Body_GetRotation(body.two));
} else {
auto p = b3Body_GetPosition(body.three);
t.position = {float(p.x), float(p.y), float(p.z)};
t.rotation = euler(b3Body_GetRotation(body.three));
}
return t;
}
Vec3 Physics::velocity(std::uint32_t id) const {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) { auto v=b2Body_GetLinearVelocity(body.two);return {v.x,v.y,0}; }
auto v=b3Body_GetLinearVelocity(body.three);return {v.x,v.y,v.z};
const auto& body = impl_->bodies.at(id);
if (impl_->dimension == 2) {
auto v = b2Body_GetLinearVelocity(body.two);
return {v.x, v.y, 0};
}
auto v = b3Body_GetLinearVelocity(body.three);
return {v.x, v.y, v.z};
}
bool Physics::grounded(std::uint32_t id) const {
const auto& body = impl_->bodies.at(id);
if (!body.contactQueryValid)
return false;
auto supports = [&](Vec3 normal, float sign) {
return sign * (normal[0] * impl_->up[0] + normal[1] * impl_->up[1] +
normal[2] * impl_->up[2]) >
0.6f;
};
if (impl_->dimension == 2) {
const int capacity = b2Body_GetContactCapacity(body.two);
if (capacity <= 0)
return false;
std::vector<b2ContactData> contacts(static_cast<std::size_t>(capacity));
const int count = b2Body_GetContactData(body.two, contacts.data(), capacity);
for (int i = 0; i < count; ++i) {
const auto& c = contacts[i];
const float sign = b2StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f;
if (!supports({c.manifold.normal.x, c.manifold.normal.y, 0}, sign))
continue;
for (int p = 0; p < c.manifold.pointCount; ++p)
if (c.manifold.points[p].separation <= 0.02f)
return true;
}
} else {
const int capacity = b3Body_GetContactCapacity(body.three);
if (capacity <= 0)
return false;
std::vector<b3ContactData> contacts(static_cast<std::size_t>(capacity));
const int count = b3Body_GetContactData(body.three, contacts.data(), capacity);
for (int i = 0; i < count; ++i) {
const auto& c = contacts[i];
const float sign = b3StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f;
// Box3D's manifold pointer is consumed now and never retained.
for (int m = 0; m < c.manifoldCount; ++m) {
const auto& manifold = c.manifolds[m];
if (!supports({manifold.normal.x, manifold.normal.y, manifold.normal.z}, sign))
continue;
for (int p = 0; p < manifold.pointCount; ++p)
if (manifold.points[p].separation <= 0.02f)
return true;
}
}
}
return false;
}
void Physics::teleport(std::uint32_t id, const Transform& t) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_SetTransform(body.two,{t.position[0],t.position[1]},b2MakeRot(t.rotation[2]));
else b3Body_SetTransform(body.three,{t.position[0],t.position[1],t.position[2]},quaternion(t.rotation));
auto& body = impl_->bodies.at(id);
body.contactQueryValid = false;
if (impl_->dimension == 2) {
b2Body_SetTransform(body.two, {t.position[0], t.position[1]}, b2MakeRot(t.rotation[2]));
b2Body_SetAwake(body.two, true);
} else {
b3Body_SetTransform(body.three, {t.position[0], t.position[1], t.position[2]},
quaternion(t.rotation));
b3Body_SetAwake(body.three, true);
}
}
void Physics::setVelocity(std::uint32_t id, Vec3 v) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_SetLinearVelocity(body.two,{v[0],v[1]}); else b3Body_SetLinearVelocity(body.three,{v[0],v[1],v[2]});
const auto& body = impl_->bodies.at(id);
if (impl_->dimension == 2)
b2Body_SetLinearVelocity(body.two, {v[0], v[1]});
else
b3Body_SetLinearVelocity(body.three, {v[0], v[1], v[2]});
}
void Physics::impulse(std::uint32_t id, Vec3 v) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_ApplyLinearImpulseToCenter(body.two,{v[0],v[1]},true); else b3Body_ApplyLinearImpulseToCenter(body.three,{v[0],v[1],v[2]},true);
const auto& body = impl_->bodies.at(id);
if (impl_->dimension == 2)
b2Body_ApplyLinearImpulseToCenter(body.two, {v[0], v[1]}, true);
else
b3Body_ApplyLinearImpulseToCenter(body.three, {v[0], v[1], v[2]}, true);
}
std::vector<Contact> Physics::step(float delta) {
std::vector<Contact> contacts;
auto append=[&](std::uint64_t a,std::uint64_t b,bool began) {
auto first=impl_->shapes.find(a),second=impl_->shapes.find(b);
if(first!=impl_->shapes.end() && second!=impl_->shapes.end()) contacts.push_back({first->second,second->second,began});
auto append = [&](std::uint64_t a, std::uint64_t b, bool began) {
auto first = impl_->shapes.find(a), second = impl_->shapes.find(b);
if (first != impl_->shapes.end() && second != impl_->shapes.end())
contacts.push_back({first->second, second->second, began});
};
if(impl_->dimension==2) {
b2World_Step(impl_->world2,delta,impl_->substeps);auto events=b2World_GetContactEvents(impl_->world2);
for(int i=0;i<events.beginCount;++i) append(b2StoreShapeId(events.beginEvents[i].shapeIdA),b2StoreShapeId(events.beginEvents[i].shapeIdB),true);
for(int i=0;i<events.endCount;++i) append(b2StoreShapeId(events.endEvents[i].shapeIdA),b2StoreShapeId(events.endEvents[i].shapeIdB),false);
if (impl_->dimension == 2) {
b2World_Step(impl_->world2, delta, impl_->substeps);
auto events = b2World_GetContactEvents(impl_->world2);
for (int i = 0; i < events.beginCount; ++i)
append(b2StoreShapeId(events.beginEvents[i].shapeIdA),
b2StoreShapeId(events.beginEvents[i].shapeIdB), true);
for (int i = 0; i < events.endCount; ++i)
append(b2StoreShapeId(events.endEvents[i].shapeIdA),
b2StoreShapeId(events.endEvents[i].shapeIdB), false);
} else {
b3World_Step(impl_->world3,delta,impl_->substeps);auto events=b3World_GetContactEvents(impl_->world3);
for(int i=0;i<events.beginCount;++i) append(b3StoreShapeId(events.beginEvents[i].shapeIdA),b3StoreShapeId(events.beginEvents[i].shapeIdB),true);
for(int i=0;i<events.endCount;++i) append(b3StoreShapeId(events.endEvents[i].shapeIdA),b3StoreShapeId(events.endEvents[i].shapeIdB),false);
b3World_Step(impl_->world3, delta, impl_->substeps);
auto events = b3World_GetContactEvents(impl_->world3);
for (int i = 0; i < events.beginCount; ++i)
append(b3StoreShapeId(events.beginEvents[i].shapeIdA),
b3StoreShapeId(events.beginEvents[i].shapeIdB), true);
for (int i = 0; i < events.endCount; ++i)
append(b3StoreShapeId(events.endEvents[i].shapeIdA),
b3StoreShapeId(events.endEvents[i].shapeIdB), false);
}
for (auto& [id, body] : impl_->bodies) {
(void)id;
body.contactQueryValid = true;
}
return contacts;
}
}
} // namespace faset::runtime::detail
+10 -4
View File
@@ -14,9 +14,13 @@ struct BodySettings {
std::uint64_t categoryBits{1};
std::uint64_t maskBits{~std::uint64_t{0}};
};
struct Contact { std::uint32_t first; std::uint32_t second; bool began; };
struct Contact {
std::uint32_t first;
std::uint32_t second;
bool began;
};
class Physics {
public:
public:
Physics(int dimension, Vec3 gravity, int substeps);
~Physics();
void add(std::uint32_t id, const Transform&, const BodySettings&);
@@ -25,12 +29,14 @@ public:
bool dynamic(std::uint32_t id) const;
Transform transform(std::uint32_t id, Transform previous) const;
Vec3 velocity(std::uint32_t id) const;
bool grounded(std::uint32_t id) const;
void teleport(std::uint32_t id, const Transform&);
void setVelocity(std::uint32_t id, Vec3);
void impulse(std::uint32_t id, Vec3);
std::vector<Contact> step(float delta);
private:
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}
} // namespace faset::runtime::detail
+702 -223
View File
@@ -1,10 +1,10 @@
#include <faset/runtime/Runtime.hpp>
#include "Physics.hpp"
#include <entt/entt.hpp>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <deque>
#include <entt/entt.hpp>
#include <faset/runtime/Runtime.hpp>
#include <numbers>
#include <set>
#include <stdexcept>
@@ -12,282 +12,761 @@
namespace faset::runtime {
namespace {
using Json=nlohmann::json;
using Json = nlohmann::json;
std::atomic<std::uint64_t> nextSession{1};
constexpr const char* body2="faset.rigid_body_2d";
constexpr const char* body3="faset.rigid_body_3d";
void require(bool condition,const std::string& message) { if(!condition) throw std::invalid_argument(message); }
template<std::size_t N> std::array<float,N> vectorValue(const Json& object,const char* key,std::array<float,N> fallback) {
if(!object.contains(key)) return fallback;
const auto& value=object.at(key); require(value.is_array()&&value.size()==N,std::string(key)+": wrong vector size");
for(std::size_t i=0;i<N;++i) { require(value[i].is_number(),std::string(key)+": expected number"); fallback[i]=value[i].get<float>(); require(std::isfinite(fallback[i]),std::string(key)+": nonfinite value"); }
constexpr const char* body2 = "faset.rigid_body_2d";
constexpr const char* body3 = "faset.rigid_body_3d";
void require(bool condition, const std::string& message) {
if (!condition)
throw std::invalid_argument(message);
}
template <std::size_t N>
std::array<float, N> vectorValue(const Json& object, const char* key,
std::array<float, N> fallback) {
if (!object.contains(key))
return fallback;
const auto& value = object.at(key);
require(value.is_array() && value.size() == N, std::string(key) + ": wrong vector size");
for (std::size_t i = 0; i < N; ++i) {
require(value[i].is_number(), std::string(key) + ": expected number");
fallback[i] = value[i].get<float>();
require(std::isfinite(fallback[i]), std::string(key) + ": nonfinite value");
}
return fallback;
}
float number(const Json& fields,const char* key,float fallback) {
if(!fields.contains(key)) return fallback;
require(fields.at(key).is_number(),std::string(key)+": expected number");
float v=fields.at(key).get<float>();require(std::isfinite(v),std::string(key)+": nonfinite value");return v;
float number(const Json& fields, const char* key, float fallback) {
if (!fields.contains(key))
return fallback;
require(fields.at(key).is_number(), std::string(key) + ": expected number");
float v = fields.at(key).get<float>();
require(std::isfinite(v), std::string(key) + ": nonfinite value");
return v;
}
Transform readTransform(const Json& fields) {
return {vectorValue<3>(fields,"position",{0,0,0}),vectorValue<3>(fields,"rotation",{0,0,0}),vectorValue<3>(fields,"scale",{1,1,1})};
return {vectorValue<3>(fields, "position", {0, 0, 0}),
vectorValue<3>(fields, "rotation", {0, 0, 0}),
vectorValue<3>(fields, "scale", {1, 1, 1})};
}
void validateTransform(const Transform& t) {
for(const auto& values:{t.position,t.rotation,t.scale}) for(float value:values) require(std::isfinite(value),"nonfinite transform");
for (const auto& values : {t.position, t.rotation, t.scale})
for (float value : values)
require(std::isfinite(value), "nonfinite transform");
}
Json transformJson(const Transform& t) { return {{"position",t.position},{"rotation",t.rotation},{"scale",t.scale}}; }
detail::BodySettings settings(const Json& fields,int dimension) {
Json transformJson(const Transform& t) {
return {{"position", t.position}, {"rotation", t.rotation}, {"scale", t.scale}};
}
detail::BodySettings settings(const Json& fields, int dimension) {
detail::BodySettings b;
b.type=fields.value("body_type",std::string("dynamic"));require(b.type=="dynamic"||b.type=="static"||b.type=="kinematic","invalid body_type");
if(dimension==2) {
auto half=vectorValue<2>(fields,"half_extents",{0.5f,0.5f});b.halfExtents={half[0],half[1],0.5f};
auto vel=vectorValue<2>(fields,"linear_velocity",{0,0});b.linearVelocity={vel[0],vel[1],0};
} else { b.halfExtents=vectorValue<3>(fields,"half_extents",{0.5f,0.5f,0.5f});b.linearVelocity=vectorValue<3>(fields,"linear_velocity",{0,0,0}); }
for(float extent:b.halfExtents) require(extent>0&&extent<100000,"half_extents must be positive and finite");
b.density=number(fields,"density",1); b.friction=number(fields,"friction",0.3f);
b.restitution=number(fields,"restitution",0);b.gravityScale=number(fields,"gravity_scale",1);
require(b.density>0&&b.friction>=0&&b.restitution>=0&&b.restitution<=1,"invalid physics material");
auto bits=[&](const char* name,std::uint64_t fallback) { if(!fields.contains(name))return fallback; const auto& value=fields.at(name);require(value.is_number_unsigned()||(value.is_number_integer()&&value.get<std::int64_t>()>=0),std::string(name)+": expected nonnegative bits");return value.get<std::uint64_t>(); };
b.categoryBits=bits("category_bits",1);b.maskBits=bits("mask_bits",~std::uint64_t{0});return b;
}
void validateEntity(const Json& entity,int dimension) {
require(entity.is_object(),"entity must be an object");
require(entity.contains("id")&&entity["id"].is_string()&&!entity["id"].get<std::string>().empty(),"entity requires id");
require(!entity.contains("name")||entity["name"].is_string(),"entity name must be a string");
require(entity.contains("components")&&entity["components"].is_array(),"entity requires components array");
if(entity.contains("parent"))require(entity["parent"].is_null()||entity["parent"].is_string(),"parent must be an id or null");
std::set<std::string> types, ids;Transform transform{};bool physical=false;
for(const auto& component:entity["components"]) {
require(component.is_object()&&component.contains("id")&&component["id"].is_string()&&!component["id"].get<std::string>().empty(),"component requires id");
require(component.contains("type")&&component["type"].is_string()&&!component["type"].get<std::string>().empty(),"component requires type");
require(component.value("version",1)==1,"unsupported component version");
require(component.contains("fields")&&component["fields"].is_object(),"component requires fields");
require(ids.insert(component["id"].get<std::string>()).second,"duplicate component id");
const auto type=component["type"].get<std::string>();require(types.insert(type).second,"duplicate component type");
const auto& f=component["fields"];
if(type=="faset.transform")transform=readTransform(f);
if(type==body2||type==body3) { require(type==(dimension==2?body2:body3),"physics dimension does not match scene");settings(f,dimension);physical=true; }
if(type=="faset.sprite") {vectorValue<4>(f,"color",{1,1,1,1});auto size=vectorValue<2>(f,"size",{1,1});require(size[0]>0&&size[1]>0,"sprite size must be positive");require(!f.contains("texture")||f["texture"].is_string(),"sprite texture must be a string");require(!f.contains("layer")||f["layer"].is_number_integer(),"sprite layer must be an integer");}
if(type=="faset.mesh") {vectorValue<4>(f,"color",{1,1,1,1});require(!f.contains("asset")||f["asset"].is_string(),"mesh asset must be a string");require(!f.contains("primitive")||f["primitive"].is_string(),"mesh primitive must be a string");}
b.type = fields.value("body_type", std::string("dynamic"));
require(b.type == "dynamic" || b.type == "static" || b.type == "kinematic",
"invalid body_type");
if (dimension == 2) {
auto half = vectorValue<2>(fields, "half_extents", {0.5f, 0.5f});
b.halfExtents = {half[0], half[1], 0.5f};
auto vel = vectorValue<2>(fields, "linear_velocity", {0, 0});
b.linearVelocity = {vel[0], vel[1], 0};
} else {
b.halfExtents = vectorValue<3>(fields, "half_extents", {0.5f, 0.5f, 0.5f});
b.linearVelocity = vectorValue<3>(fields, "linear_velocity", {0, 0, 0});
}
if(physical) {
require(!entity.contains("parent")||entity["parent"].is_null(),"physics bodies must be root entities in the initial runtime");
for(int i=0;i<dimension;++i)require(std::abs(transform.scale[i])>0.00001f,"physics scale must be nonzero");
if(dimension==2) require(transform.rotation[0]==0&&transform.rotation[1]==0,"2D physics rotates only around Z");
}
}
Transform interpolate(const Transform& a,const Transform& b,float alpha) {
Transform out;
for(int i=0;i<3;++i) {
out.position[i]=std::lerp(a.position[i],b.position[i],alpha);out.scale[i]=std::lerp(a.scale[i],b.scale[i],alpha);
}
auto quaternion=[](Vec3 r) {
const float cx=std::cos(r[0]*0.5f),sx=std::sin(r[0]*0.5f),cy=std::cos(r[1]*0.5f),sy=std::sin(r[1]*0.5f),cz=std::cos(r[2]*0.5f),sz=std::sin(r[2]*0.5f);
return Vec4{sx*cy*cz-cx*sy*sz,cx*sy*cz+sx*cy*sz,cx*cy*sz-sx*sy*cz,cx*cy*cz+sx*sy*sz};
for (float extent : b.halfExtents)
require(extent > 0 && extent < 100000, "half_extents must be positive and finite");
b.density = number(fields, "density", 1);
b.friction = number(fields, "friction", 0.3f);
b.restitution = number(fields, "restitution", 0);
b.gravityScale = number(fields, "gravity_scale", 1);
require(b.density > 0 && b.friction >= 0 && b.restitution >= 0 && b.restitution <= 1,
"invalid physics material");
auto bits = [&](const char* name, std::uint64_t fallback) {
if (!fields.contains(name))
return fallback;
const auto& value = fields.at(name);
require(value.is_number_unsigned() ||
(value.is_number_integer() && value.get<std::int64_t>() >= 0),
std::string(name) + ": expected nonnegative bits");
return value.get<std::uint64_t>();
};
auto qa=quaternion(a.rotation),qb=quaternion(b.rotation);float dot=0;
for(int i=0;i<4;++i)dot+=qa[i]*qb[i];
if(dot<0){for(auto& q:qb)q=-q;dot=-dot;}
float wa=1-alpha,wb=alpha;
if(dot<0.9995f){const float angle=std::acos(std::clamp(dot,-1.0f,1.0f)),denom=std::sin(angle);wa=std::sin((1-alpha)*angle)/denom;wb=std::sin(alpha*angle)/denom;}
Vec4 q{};float length=0;for(int i=0;i<4;++i){q[i]=wa*qa[i]+wb*qb[i];length+=q[i]*q[i];}for(auto& v:q)v/=std::sqrt(length);
const auto [x,y,z,w]=q;
out.rotation={std::atan2(2*(w*x+y*z),1-2*(x*x+y*y)),std::asin(std::clamp(2*(w*y-z*x),-1.0f,1.0f)),std::atan2(2*(w*z+x*y),1-2*(y*y+z*z))};
b.categoryBits = bits("category_bits", 1);
b.maskBits = bits("mask_bits", ~std::uint64_t{0});
return b;
}
void validateEntity(const Json& entity, int dimension) {
require(entity.is_object(), "entity must be an object");
require(entity.contains("id") && entity["id"].is_string() &&
!entity["id"].get<std::string>().empty(),
"entity requires id");
require(!entity.contains("name") || entity["name"].is_string(), "entity name must be a string");
require(entity.contains("components") && entity["components"].is_array(),
"entity requires components array");
if (entity.contains("parent"))
require(entity["parent"].is_null() || entity["parent"].is_string(),
"parent must be an id or null");
std::set<std::string> types, ids;
Transform transform{};
bool physical = false;
for (const auto& component : entity["components"]) {
require(component.is_object() && component.contains("id") && component["id"].is_string() &&
!component["id"].get<std::string>().empty(),
"component requires id");
require(component.contains("type") && component["type"].is_string() &&
!component["type"].get<std::string>().empty(),
"component requires type");
require(component.value("version", 1) == 1, "unsupported component version");
require(component.contains("fields") && component["fields"].is_object(),
"component requires fields");
require(ids.insert(component["id"].get<std::string>()).second, "duplicate component id");
const auto type = component["type"].get<std::string>();
require(types.insert(type).second, "duplicate component type");
const auto& f = component["fields"];
if (type == "faset.transform")
transform = readTransform(f);
if (type == body2 || type == body3) {
require(type == (dimension == 2 ? body2 : body3),
"physics dimension does not match scene");
settings(f, dimension);
physical = true;
}
if (type == "faset.sprite") {
vectorValue<4>(f, "color", {1, 1, 1, 1});
auto size = vectorValue<2>(f, "size", {1, 1});
require(size[0] > 0 && size[1] > 0, "sprite size must be positive");
require(!f.contains("texture") || f["texture"].is_string(),
"sprite texture must be a string");
require(!f.contains("layer") || f["layer"].is_number_integer(),
"sprite layer must be an integer");
}
if (type == "faset.mesh") {
vectorValue<4>(f, "color", {1, 1, 1, 1});
require(!f.contains("asset") || f["asset"].is_string(), "mesh asset must be a string");
require(!f.contains("primitive") || f["primitive"].is_string(),
"mesh primitive must be a string");
}
}
if (physical) {
require(!entity.contains("parent") || entity["parent"].is_null(),
"physics bodies must be root entities in the initial runtime");
for (int i = 0; i < dimension; ++i)
require(std::abs(transform.scale[i]) > 0.00001f, "physics scale must be nonzero");
if (dimension == 2)
require(transform.rotation[0] == 0 && transform.rotation[1] == 0,
"2D physics rotates only around Z");
}
}
Transform interpolate(const Transform& a, const Transform& b, float alpha) {
Transform out;
for (int i = 0; i < 3; ++i) {
out.position[i] = std::lerp(a.position[i], b.position[i], alpha);
out.scale[i] = std::lerp(a.scale[i], b.scale[i], alpha);
}
auto quaternion = [](Vec3 r) {
const float cx = std::cos(r[0] * 0.5f), sx = std::sin(r[0] * 0.5f),
cy = std::cos(r[1] * 0.5f), sy = std::sin(r[1] * 0.5f),
cz = std::cos(r[2] * 0.5f), sz = std::sin(r[2] * 0.5f);
return Vec4{sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz,
cx * cy * sz - sx * sy * cz, cx * cy * cz + sx * sy * sz};
};
auto qa = quaternion(a.rotation), qb = quaternion(b.rotation);
float dot = 0;
for (int i = 0; i < 4; ++i)
dot += qa[i] * qb[i];
if (dot < 0) {
for (auto& q : qb)
q = -q;
dot = -dot;
}
float wa = 1 - alpha, wb = alpha;
if (dot < 0.9995f) {
const float angle = std::acos(std::clamp(dot, -1.0f, 1.0f)), denom = std::sin(angle);
wa = std::sin((1 - alpha) * angle) / denom;
wb = std::sin(alpha * angle) / denom;
}
Vec4 q{};
float length = 0;
for (int i = 0; i < 4; ++i) {
q[i] = wa * qa[i] + wb * qb[i];
length += q[i] * q[i];
}
for (auto& v : q)
v /= std::sqrt(length);
const auto [x, y, z, w] = q;
out.rotation = {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)),
std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))};
return out;
}
}
} // namespace
struct Runtime::Impl {
struct Data { Json document; std::uint64_t generation; };
struct Pose { Transform previous,current,presented; bool changedInUpdate{}; };
struct Data {
Json document;
std::uint64_t generation;
};
struct Pose {
Transform previous, current, presented;
bool changedInUpdate{};
};
enum class Phase { Idle, Initialize, Fixed, Update, Late, Destroy };
enum class Kind { Spawn, Destroy, Add, Remove };
struct Command { Kind kind; EntityHandle handle; Json payload; std::string type; };
struct Command {
Kind kind;
EntityHandle handle;
Json payload;
std::string type;
};
Runtime* owner;
RuntimeConfig config;
entt::registry registry;
std::unordered_map<std::string,entt::entity> ids;
std::unordered_map<std::string,Behavior> behaviors;
std::unordered_map<std::string, entt::entity> ids;
std::unordered_map<std::string, Behavior> behaviors;
std::vector<entt::entity> order;
std::deque<Command> pending;
std::unique_ptr<detail::Physics> physics;
std::vector<CollisionEvent> contacts;
std::vector<std::string> diagnostics;
std::uint64_t session{nextSession.fetch_add(1)};
std::uint64_t generation{},tick{};
std::uint64_t generation{}, tick{};
int dimension{3};
double accumulator{},alpha{};
bool paused{},busy{};
InputState currentInput{},queuedInput{};
double accumulator{}, alpha{};
bool paused{}, busy{};
InputState currentInput{}, queuedInput{};
Phase phase{Phase::Idle};
Impl(Runtime* runtime,RuntimeConfig cfg):owner(runtime),config(cfg){}
EntityHandle handle(entt::entity e) const {return {session,entt::to_integral(e),registry.get<Data>(e).generation};}
bool valid(EntityHandle h)const noexcept { auto e=static_cast<entt::entity>(h.slot);return h.session==session&&registry.valid(e)&&registry.all_of<Data>(e)&&registry.get<Data>(e).generation==h.generation; }
entt::entity entity(EntityHandle h)const {if(!valid(h))throw std::invalid_argument("stale or foreign runtime handle");return static_cast<entt::entity>(h.slot);}
const Json* component(entt::entity e,const std::string& type)const {for(const auto& c:registry.get<Data>(e).document["components"])if(c["type"]==type)return &c;return nullptr;}
void callback(const Behavior::Callback& fn,entt::entity e,double dt) {
if(!fn)return;
try {fn(*owner,handle(e),dt);}catch(const std::exception& ex){diagnostics.push_back("gameplay "+registry.get<Data>(e).document["id"].get<std::string>()+": "+ex.what());}catch(...){diagnostics.push_back("unknown gameplay exception");}
Impl(Runtime* runtime, RuntimeConfig cfg) : owner(runtime), config(cfg) {}
EntityHandle handle(entt::entity e) const {
return {session, entt::to_integral(e), registry.get<Data>(e).generation};
}
void lifecycle(entt::entity e,Behavior::Callback Behavior::* member,double dt) {
const auto components=registry.get<Data>(e).document["components"];
for(const auto& c:components) {auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end())callback(it->second.*member,e,dt);}
bool valid(EntityHandle h) const noexcept {
auto e = static_cast<entt::entity>(h.slot);
return h.session == session && registry.valid(e) && registry.all_of<Data>(e) &&
registry.get<Data>(e).generation == h.generation;
}
entt::entity entity(EntityHandle h) const {
if (!valid(h))
throw std::invalid_argument("stale or foreign runtime handle");
return static_cast<entt::entity>(h.slot);
}
const Json* component(entt::entity e, const std::string& type) const {
for (const auto& c : registry.get<Data>(e).document["components"])
if (c["type"] == type)
return &c;
return nullptr;
}
void callback(const Behavior::Callback& fn, entt::entity e, double dt) {
if (!fn)
return;
try {
fn(*owner, handle(e), dt);
} catch (const std::exception& ex) {
diagnostics.push_back("gameplay " +
registry.get<Data>(e).document["id"].get<std::string>() + ": " +
ex.what());
} catch (...) {
diagnostics.push_back("unknown gameplay exception");
}
}
void lifecycle(entt::entity e, Behavior::Callback Behavior::* member, double dt) {
const auto components = registry.get<Data>(e).document["components"];
for (const auto& c : components) {
auto it = behaviors.find(c["type"].get<std::string>());
if (it != behaviors.end())
callback(it->second.*member, e, dt);
}
}
void all(Behavior::Callback Behavior::* member, double dt) {
for (auto e : order)
if (registry.valid(e))
lifecycle(e, member, dt);
}
void all(Behavior::Callback Behavior::* member,double dt) {for(auto e:order)if(registry.valid(e))lifecycle(e,member,dt);}
void syncVisual(entt::entity e) {
if(auto c=component(e,"faset.sprite")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Sprite>(e,vectorValue<4>(f,"color",{1,1,1,1}),vectorValue<2>(f,"size",{1,1}),f.value("texture",std::string{}),f.value("layer",0));}else registry.remove<Sprite>(e);
if(auto c=component(e,"faset.mesh")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Mesh>(e,f.value("asset",std::string{}),vectorValue<4>(f,"color",{1,1,1,1}),f.value("primitive",std::string("cube")));}else registry.remove<Mesh>(e);
if (auto c = component(e, "faset.sprite")) {
const auto& f = (*c)["fields"];
registry.emplace_or_replace<Sprite>(
e, vectorValue<4>(f, "color", {1, 1, 1, 1}), vectorValue<2>(f, "size", {1, 1}),
f.value("texture", std::string{}), f.value("layer", 0));
} else
registry.remove<Sprite>(e);
if (auto c = component(e, "faset.mesh")) {
const auto& f = (*c)["fields"];
registry.emplace_or_replace<Mesh>(e, f.value("asset", std::string{}),
vectorValue<4>(f, "color", {1, 1, 1, 1}),
f.value("primitive", std::string("cube")));
} else
registry.remove<Mesh>(e);
}
void addPhysics(entt::entity e) {
require(bool(physics),"load a scene before creating physics");
auto c=component(e,dimension==2?body2:body3);if(c)physics->add(entt::to_integral(e),registry.get<Pose>(e).current,settings((*c)["fields"],dimension));
require(bool(physics), "load a scene before creating physics");
auto c = component(e, dimension == 2 ? body2 : body3);
if (c)
physics->add(entt::to_integral(e), registry.get<Pose>(e).current,
settings((*c)["fields"], dimension));
}
entt::entity create(Json document) {
const auto id=document["id"].get<std::string>();require(!ids.contains(id),"duplicate entity id: "+id);
auto e=registry.create();Transform t{};
for(const auto& c:document["components"])if(c["type"]=="faset.transform")t=readTransform(c["fields"]);
registry.emplace<Data>(e,std::move(document),++generation);registry.emplace<Pose>(e,t,t,t,false);ids.emplace(id,e);order.push_back(e);addPhysics(e);syncVisual(e);return e;
const auto id = document["id"].get<std::string>();
require(!ids.contains(id), "duplicate entity id: " + id);
auto e = registry.create();
Transform t{};
for (const auto& c : document["components"])
if (c["type"] == "faset.transform")
t = readTransform(c["fields"]);
registry.emplace<Data>(e, std::move(document), ++generation);
registry.emplace<Pose>(e, t, t, t, false);
ids.emplace(id, e);
order.push_back(e);
addPhysics(e);
syncVisual(e);
return e;
}
void erase(entt::entity e) {
// Authoring hierarchy destruction has the same subtree semantics in runtime.
auto id=registry.get<Data>(e).document["id"].get<std::string>();
auto id = registry.get<Data>(e).document["id"].get<std::string>();
std::vector<entt::entity> children;
for(auto child:order)if(registry.valid(child)&&registry.get<Data>(child).document.value("parent",Json{})==id)children.push_back(child);
for(auto child:children)erase(child);
phase=Phase::Destroy;lifecycle(e,&Behavior::onDestroy,0);
physics->remove(entt::to_integral(e));ids.erase(id);registry.destroy(e);
std::erase(order,e);
for (auto child : order)
if (registry.valid(child) &&
registry.get<Data>(child).document.value("parent", Json{}) == id)
children.push_back(child);
for (auto child : children)
erase(child);
phase = Phase::Destroy;
lifecycle(e, &Behavior::onDestroy, 0);
physics->remove(entt::to_integral(e));
ids.erase(id);
registry.destroy(e);
std::erase(order, e);
}
void commands() {
auto commands=std::move(pending);pending.clear();
for(auto& command:commands)try {
if(command.kind==Kind::Spawn) {
validateEntity(command.payload,dimension);
auto parent=command.payload.value("parent",Json{});require(parent.is_null()||ids.contains(parent.get<std::string>()),"spawn parent is absent");
auto e=create(std::move(command.payload));phase=Phase::Initialize;lifecycle(e,&Behavior::onStart,0);continue;
auto commands = std::move(pending);
pending.clear();
for (auto& command : commands)
try {
if (command.kind == Kind::Spawn) {
validateEntity(command.payload, dimension);
auto parent = command.payload.value("parent", Json{});
require(parent.is_null() || ids.contains(parent.get<std::string>()),
"spawn parent is absent");
auto e = create(std::move(command.payload));
phase = Phase::Initialize;
lifecycle(e, &Behavior::onStart, 0);
continue;
}
if (!valid(command.handle)) {
diagnostics.push_back("ignored structural command for stale handle");
continue;
}
auto e = entity(command.handle);
if (command.kind == Kind::Destroy) {
erase(e);
continue;
}
auto candidate = registry.get<Data>(e).document;
auto& components = candidate["components"];
if (command.kind == Kind::Add) {
components.push_back(command.payload);
validateEntity(candidate, dimension);
} else {
auto it =
std::find_if(components.begin(), components.end(),
[&](const Json& c) { return c["type"] == command.type; });
if (it == components.end())
continue;
if (command.type == "faset.transform" && physics->contains(command.handle.slot))
throw std::invalid_argument("remove physics before removing transform");
auto behavior = behaviors.find(command.type);
if (behavior != behaviors.end()) {
phase = Phase::Destroy;
callback(behavior->second.onDestroy, e, 0);
}
components.erase(it);
}
const std::string changed = command.kind == Kind::Add
? command.payload["type"].get<std::string>()
: command.type;
if ((changed == body2 || changed == body3) && command.kind == Kind::Add) {
const auto& pose = registry.get<Pose>(e).current;
for (int i = 0; i < dimension; ++i)
require(std::abs(pose.scale[i]) > 0.00001f,
"runtime physics scale must be nonzero");
if (dimension == 2)
require(pose.rotation[0] == 0 && pose.rotation[1] == 0,
"2D physics rotates only around Z");
}
registry.get<Data>(e).document = std::move(candidate);
syncVisual(e);
if (changed == body2 || changed == body3) {
if (command.kind == Kind::Add)
addPhysics(e);
else
physics->remove(command.handle.slot);
}
if (changed == "faset.transform") {
auto& d = registry.get<Pose>(e);
d.current = command.kind == Kind::Add ? readTransform(command.payload["fields"])
: Transform{};
d.previous = d.presented = d.current;
}
if (command.kind == Kind::Add) {
auto it = behaviors.find(changed);
if (it != behaviors.end()) {
phase = Phase::Initialize;
callback(it->second.onStart, e, 0);
}
}
} catch (const std::exception& ex) {
diagnostics.push_back(std::string("structural command rejected: ") + ex.what());
}
if(!valid(command.handle)){diagnostics.push_back("ignored structural command for stale handle");continue;}
auto e=entity(command.handle);
if(command.kind==Kind::Destroy){erase(e);continue;}
auto candidate=registry.get<Data>(e).document;
auto& components=candidate["components"];
if(command.kind==Kind::Add) {components.push_back(command.payload);validateEntity(candidate,dimension);}
else { auto it=std::find_if(components.begin(),components.end(),[&](const Json& c){return c["type"]==command.type;});if(it==components.end())continue;
if(command.type=="faset.transform"&&physics->contains(command.handle.slot))throw std::invalid_argument("remove physics before removing transform");
auto behavior=behaviors.find(command.type);if(behavior!=behaviors.end()){phase=Phase::Destroy;callback(behavior->second.onDestroy,e,0);}components.erase(it);
}
const std::string changed=command.kind==Kind::Add?command.payload["type"].get<std::string>():command.type;
if((changed==body2||changed==body3)&&command.kind==Kind::Add) {
const auto& pose=registry.get<Pose>(e).current;
for(int i=0;i<dimension;++i)require(std::abs(pose.scale[i])>0.00001f,"runtime physics scale must be nonzero");
if(dimension==2)require(pose.rotation[0]==0&&pose.rotation[1]==0,"2D physics rotates only around Z");
}
registry.get<Data>(e).document=std::move(candidate);syncVisual(e);
if(changed==body2||changed==body3) {if(command.kind==Kind::Add)addPhysics(e);else physics->remove(command.handle.slot);}
if(changed=="faset.transform") {auto& d=registry.get<Pose>(e);d.current=command.kind==Kind::Add?readTransform(command.payload["fields"]):Transform{};d.previous=d.presented=d.current;}
if(command.kind==Kind::Add){auto it=behaviors.find(changed);if(it!=behaviors.end()){phase=Phase::Initialize;callback(it->second.onStart,e,0);}}
}catch(const std::exception& ex){diagnostics.push_back(std::string("structural command rejected: ")+ex.what());}
}
void fixed() {
commands();phase=Phase::Fixed;
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.previous=d.current;}
currentInput=queuedInput;queuedInput.jumpPressed=false;queuedInput.interactPressed=false;
all(&Behavior::fixedUpdate,config.fixedDelta);
contacts.clear();
if(physics) {
auto events=physics->step(static_cast<float>(config.fixedDelta));
for(auto e:order)if(physics->contains(entt::to_integral(e))) {auto& d=registry.get<Pose>(e);d.current=physics->transform(entt::to_integral(e),d.current);}
for(const auto& event:events) {
auto a=static_cast<entt::entity>(event.first),b=static_cast<entt::entity>(event.second);
if(!registry.valid(a)||!registry.valid(b))continue;
contacts.push_back({handle(a),handle(b),event.began});
}
for(const auto& event:contacts)for(auto h:{event.first,event.second}) {
auto e=entity(h);for(const auto& c:registry.get<Data>(e).document["components"]) {
auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end()&&it->second.onCollision)
try{it->second.onCollision(*owner,h,event);}catch(const std::exception& ex){diagnostics.push_back(std::string("collision callback: ")+ex.what());}catch(...){diagnostics.push_back("unknown collision callback exception");}
}
}
commands();
phase = Phase::Fixed;
for (auto [e, d] : registry.view<Pose>().each()) {
(void)e;
d.previous = d.current;
}
++tick;phase=Phase::Idle;
currentInput = queuedInput;
queuedInput.jumpPressed = false;
queuedInput.interactPressed = false;
all(&Behavior::fixedUpdate, config.fixedDelta);
contacts.clear();
if (physics) {
auto events = physics->step(static_cast<float>(config.fixedDelta));
for (auto e : order)
if (physics->contains(entt::to_integral(e))) {
auto& d = registry.get<Pose>(e);
d.current = physics->transform(entt::to_integral(e), d.current);
}
for (const auto& event : events) {
auto a = static_cast<entt::entity>(event.first),
b = static_cast<entt::entity>(event.second);
if (!registry.valid(a) || !registry.valid(b))
continue;
contacts.push_back({handle(a), handle(b), event.began});
}
for (const auto& event : contacts)
for (auto h : {event.first, event.second}) {
auto e = entity(h);
for (const auto& c : registry.get<Data>(e).document["components"]) {
auto it = behaviors.find(c["type"].get<std::string>());
if (it != behaviors.end() && it->second.onCollision)
try {
it->second.onCollision(*owner, h, event);
} catch (const std::exception& ex) {
diagnostics.push_back(std::string("collision callback: ") +
ex.what());
} catch (...) {
diagnostics.push_back("unknown collision callback exception");
}
}
}
}
++tick;
phase = Phase::Idle;
}
FrameStats frame(double elapsed,InputState input,bool step) {
require(std::isfinite(elapsed)&&elapsed>=0,"elapsed time must be finite and nonnegative");require(!busy,"recursive runtime advance");
require(std::isfinite(input.horizontal)&&std::isfinite(input.vertical),"input axes must be finite");
struct Guard {bool& busy;~Guard(){busy=false;}}guard{busy};busy=true;
FrameStats frame(double elapsed, InputState input, bool step) {
require(std::isfinite(elapsed) && elapsed >= 0,
"elapsed time must be finite and nonnegative");
require(!busy, "recursive runtime advance");
require(std::isfinite(input.horizontal) && std::isfinite(input.vertical),
"input axes must be finite");
struct Guard {
bool& busy;
~Guard() {
busy = false;
}
} guard{busy};
busy = true;
FrameStats stats{};
if(paused&&!step){accumulator=0;currentInput={};queuedInput={};return {0,0,alpha,tick};}
queuedInput.horizontal=input.horizontal;queuedInput.vertical=input.vertical;
queuedInput.jumpPressed=queuedInput.jumpPressed||input.jumpPressed;queuedInput.interactPressed=queuedInput.interactPressed||input.interactPressed;
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.changedInUpdate=false;}
accumulator+=step?config.fixedDelta:elapsed;
while(accumulator+1e-12>=config.fixedDelta&&stats.fixedTicks<(step?1u:config.maxCatchUpTicks)) {fixed();accumulator=std::max(0.0,accumulator-config.fixedDelta);++stats.fixedTicks;}
if(accumulator>=config.fixedDelta){auto remaining=std::fmod(accumulator,config.fixedDelta);stats.droppedTime=accumulator-remaining;accumulator=remaining;diagnostics.push_back("dropped_time="+std::to_string(stats.droppedTime));}
currentInput=input;phase=Phase::Update;all(&Behavior::update,step?config.fixedDelta:elapsed);
alpha=step?1.0:std::clamp(accumulator/config.fixedDelta,0.0,1.0);
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.presented=d.changedInUpdate?d.current:interpolate(d.previous,d.current,static_cast<float>(alpha));}
phase=Phase::Late;all(&Behavior::lateUpdate,step?config.fixedDelta:elapsed);phase=Phase::Idle;
stats.interpolationAlpha=alpha;stats.tick=tick;return stats;
if (paused && !step) {
accumulator = 0;
currentInput = {};
queuedInput = {};
return {0, 0, alpha, tick};
}
queuedInput.horizontal = input.horizontal;
queuedInput.vertical = input.vertical;
queuedInput.jumpPressed = queuedInput.jumpPressed || input.jumpPressed;
queuedInput.interactPressed = queuedInput.interactPressed || input.interactPressed;
for (auto [e, d] : registry.view<Pose>().each()) {
(void)e;
d.changedInUpdate = false;
}
accumulator += step ? config.fixedDelta : elapsed;
while (accumulator + 1e-12 >= config.fixedDelta &&
stats.fixedTicks < (step ? 1u : config.maxCatchUpTicks)) {
fixed();
accumulator = std::max(0.0, accumulator - config.fixedDelta);
++stats.fixedTicks;
}
if (accumulator >= config.fixedDelta) {
auto remaining = std::fmod(accumulator, config.fixedDelta);
stats.droppedTime = accumulator - remaining;
accumulator = remaining;
diagnostics.push_back("dropped_time=" + std::to_string(stats.droppedTime));
}
currentInput = input;
phase = Phase::Update;
all(&Behavior::update, step ? config.fixedDelta : elapsed);
alpha = step ? 1.0 : std::clamp(accumulator / config.fixedDelta, 0.0, 1.0);
for (auto [e, d] : registry.view<Pose>().each()) {
(void)e;
d.presented = d.changedInUpdate
? d.current
: interpolate(d.previous, d.current, static_cast<float>(alpha));
}
phase = Phase::Late;
all(&Behavior::lateUpdate, step ? config.fixedDelta : elapsed);
phase = Phase::Idle;
stats.interpolationAlpha = alpha;
stats.tick = tick;
return stats;
}
};
Runtime::Runtime(RuntimeConfig cfg):impl_(std::make_unique<Impl>(this,cfg)) {
require(std::isfinite(cfg.fixedDelta)&&cfg.fixedDelta>0&&cfg.fixedDelta<=1,"invalid fixed delta");
require(cfg.maxCatchUpTicks>0&&cfg.maxCatchUpTicks<=1024,"invalid catchup limit");require(cfg.physicsSubsteps>0&&cfg.physicsSubsteps<=128,"invalid physics substeps");
for(float value:cfg.gravity)require(std::isfinite(value),"invalid gravity");
Runtime::Runtime(RuntimeConfig cfg) : impl_(std::make_unique<Impl>(this, cfg)) {
require(std::isfinite(cfg.fixedDelta) && cfg.fixedDelta > 0 && cfg.fixedDelta <= 1,
"invalid fixed delta");
require(cfg.maxCatchUpTicks > 0 && cfg.maxCatchUpTicks <= 1024, "invalid catchup limit");
require(cfg.physicsSubsteps > 0 && cfg.physicsSubsteps <= 128, "invalid physics substeps");
for (float value : cfg.gravity)
require(std::isfinite(value), "invalid gravity");
}
Runtime::~Runtime(){try{clear();}catch(...){}}
void Runtime::registerBehavior(std::string type,Behavior behavior) {
require(!impl_->busy&&impl_->ids.empty(),"register gameplay before loading scene");require(!type.empty()&&!impl_->behaviors.contains(type),"duplicate or empty behavior type");impl_->behaviors.emplace(std::move(type),std::move(behavior));
Runtime::~Runtime() {
try {
clear();
} catch (...) {
}
}
void Runtime::registerBehavior(std::string type, Behavior behavior) {
require(!impl_->busy && impl_->ids.empty(), "register gameplay before loading scene");
require(!type.empty() && !impl_->behaviors.contains(type), "duplicate or empty behavior type");
impl_->behaviors.emplace(std::move(type), std::move(behavior));
}
void Runtime::load(const Json& scene) {
require(!impl_->busy,"cannot load scene from gameplay callback");
require(scene.is_object()&&scene.value("format",std::string{})=="faset.scene"&&scene.value("version",0)==1,"unsupported scene format/version");
const int dimension=scene.value("dimension",3);require(dimension==2||dimension==3,"scene dimension must be 2 or 3");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene entities must be an array");
require(!scene.contains("instances")||(scene["instances"].is_array()&&scene["instances"].empty()),"resolve template instances before runtime loading");
std::unordered_map<std::string,Json> entities;
for(const auto& entity:scene["entities"]) {validateEntity(entity,dimension);require(entities.emplace(entity["id"].get<std::string>(),entity).second,"duplicate scene entity id");}
for(const auto& [id,entity]:entities) {
std::set<std::string> visited{id};auto parent=entity.value("parent",Json{});
while(!parent.is_null()){auto key=parent.get<std::string>();require(entities.contains(key),"unknown parent entity");require(visited.insert(key).second,"cyclic parent hierarchy");parent=entities.at(key).value("parent",Json{});}
require(!impl_->busy, "cannot load scene from gameplay callback");
require(scene.is_object() && scene.value("format", std::string{}) == "faset.scene" &&
scene.value("version", 0) == 1,
"unsupported scene format/version");
const int dimension = scene.value("dimension", 3);
require(dimension == 2 || dimension == 3, "scene dimension must be 2 or 3");
require(scene.contains("entities") && scene["entities"].is_array(),
"scene entities must be an array");
require(!scene.contains("instances") ||
(scene["instances"].is_array() && scene["instances"].empty()),
"resolve template instances before runtime loading");
std::unordered_map<std::string, Json> entities;
for (const auto& entity : scene["entities"]) {
validateEntity(entity, dimension);
require(entities.emplace(entity["id"].get<std::string>(), entity).second,
"duplicate scene entity id");
}
auto next=std::make_unique<Impl>(this,impl_->config);next->dimension=dimension;next->behaviors=impl_->behaviors;
next->physics=std::make_unique<detail::Physics>(dimension,next->config.gravity,next->config.physicsSubsteps);
for(const auto& entity:scene["entities"])next->create(entity);
clear();impl_=std::move(next);impl_->busy=true;impl_->phase=Impl::Phase::Initialize;impl_->all(&Behavior::onStart,0);impl_->phase=Impl::Phase::Idle;impl_->busy=false;
for (const auto& [id, entity] : entities) {
std::set<std::string> visited{id};
auto parent = entity.value("parent", Json{});
while (!parent.is_null()) {
auto key = parent.get<std::string>();
require(entities.contains(key), "unknown parent entity");
require(visited.insert(key).second, "cyclic parent hierarchy");
parent = entities.at(key).value("parent", Json{});
}
}
auto next = std::make_unique<Impl>(this, impl_->config);
next->dimension = dimension;
next->behaviors = impl_->behaviors;
next->physics = std::make_unique<detail::Physics>(dimension, next->config.gravity,
next->config.physicsSubsteps);
for (const auto& entity : scene["entities"])
next->create(entity);
clear();
impl_ = std::move(next);
impl_->busy = true;
impl_->phase = Impl::Phase::Initialize;
impl_->all(&Behavior::onStart, 0);
impl_->phase = Impl::Phase::Idle;
impl_->busy = false;
}
void Runtime::clear(){require(!impl_->busy,"cannot clear runtime from gameplay callback");impl_->busy=true;while(!impl_->order.empty())impl_->erase(impl_->order.back());impl_->pending.clear();impl_->contacts.clear();impl_->physics.reset();impl_->accumulator=0;impl_->tick=0;impl_->session=nextSession.fetch_add(1);impl_->busy=false;}
FrameStats Runtime::advance(double dt,InputState input){return impl_->frame(dt,input,false);}
FrameStats Runtime::singleStep(InputState input){return impl_->frame(0,input,true);}
void Runtime::setPaused(bool value){require(!impl_->busy,"pause control belongs outside gameplay callbacks");impl_->paused=value;impl_->accumulator=0;impl_->queuedInput={};impl_->alpha=0;for(auto [e,pose]:impl_->registry.view<Impl::Pose>().each()){(void)e;pose.previous=pose.presented=pose.current;}}
bool Runtime::paused()const noexcept{return impl_->paused;}
EntityHandle Runtime::find(const std::string& id)const{auto it=impl_->ids.find(id);return it==impl_->ids.end()?EntityHandle{}:impl_->handle(it->second);}
bool Runtime::valid(EntityHandle handle)const noexcept{return impl_->valid(handle);}
Transform Runtime::transform(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).current;}
Transform Runtime::presentation(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented;}
Json Runtime::fields(EntityHandle h,const std::string& type)const{auto c=impl_->component(impl_->entity(h),type);if(!c)throw std::invalid_argument("entity has no component: "+type);return (*c)["fields"];}
Vec3 Runtime::velocity(EntityHandle h)const{impl_->entity(h);if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");return impl_->physics->velocity(h.slot);}
InputState Runtime::input()const noexcept{return impl_->currentInput;}
const std::vector<CollisionEvent>& Runtime::collisions()const noexcept{return impl_->contacts;}
void Runtime::setTransform(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);if(impl_->physics&&impl_->physics->contains(h.slot))throw std::invalid_argument("physics transform requires teleport");auto& d=impl_->registry.get<Impl::Pose>(e);d.current=value;if(impl_->phase!=Impl::Phase::Fixed){d.previous=d.presented=value;d.changedInUpdate=true;}}
void Runtime::setPresentation(EntityHandle h,const Transform& value){validateTransform(value);require(impl_->phase==Impl::Phase::Late,"presentation may only be changed during LateUpdate");impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented=value;}
void Runtime::teleport(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);auto& d=impl_->registry.get<Impl::Pose>(e);if(impl_->physics&&impl_->physics->contains(h.slot)){require(d.current.scale==value.scale,"changing collider scale requires remove/add body");if(impl_->dimension==2)require(value.rotation[0]==0&&value.rotation[1]==0,"2D physics rotates only around Z");impl_->physics->teleport(h.slot,value);}d.previous=d.current=d.presented=value;d.changedInUpdate=true;}
void Runtime::setVelocity(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite velocity");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->setVelocity(h.slot,value);}
void Runtime::applyImpulse(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite impulse");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->impulse(h.slot,value);}
void Runtime::spawn(Json entity){require(bool(impl_->physics),"load a scene before spawning");validateEntity(entity,impl_->dimension);impl_->pending.push_back({Impl::Kind::Spawn,{},std::move(entity),{}});}
void Runtime::destroy(EntityHandle h){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Destroy,h,{},{}});}
void Runtime::addComponent(EntityHandle h,Json component){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Add,h,std::move(component),{}});}
void Runtime::removeComponent(EntityHandle h,const std::string& type){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Remove,h,{},type});}
RuntimeSnapshot Runtime::snapshot()const {
RuntimeSnapshot out{impl_->dimension,impl_->tick,impl_->alpha,{}};out.entities.reserve(impl_->order.size());
for(auto e:impl_->order) {const auto& d=impl_->registry.get<Impl::Data>(e);RenderEntity item;item.id=d.document["id"].get<std::string>();item.name=d.document.value("name",item.id);item.transform=impl_->registry.get<Impl::Pose>(e).presented;
if(d.document.contains("parent")&&!d.document["parent"].is_null())item.parent=d.document["parent"].get<std::string>();
if(auto sprite=impl_->registry.try_get<Sprite>(e))item.sprite=*sprite;
if(auto mesh=impl_->registry.try_get<Mesh>(e))item.mesh=*mesh;
void Runtime::clear() {
require(!impl_->busy, "cannot clear runtime from gameplay callback");
impl_->busy = true;
while (!impl_->order.empty())
impl_->erase(impl_->order.back());
impl_->pending.clear();
impl_->contacts.clear();
impl_->physics.reset();
impl_->accumulator = 0;
impl_->tick = 0;
impl_->session = nextSession.fetch_add(1);
impl_->busy = false;
}
FrameStats Runtime::advance(double dt, InputState input) {
return impl_->frame(dt, input, false);
}
FrameStats Runtime::singleStep(InputState input) {
return impl_->frame(0, input, true);
}
void Runtime::setPaused(bool value) {
require(!impl_->busy, "pause control belongs outside gameplay callbacks");
impl_->paused = value;
impl_->accumulator = 0;
impl_->queuedInput = {};
impl_->alpha = 0;
for (auto [e, pose] : impl_->registry.view<Impl::Pose>().each()) {
(void)e;
pose.previous = pose.presented = pose.current;
}
}
bool Runtime::paused() const noexcept {
return impl_->paused;
}
EntityHandle Runtime::find(const std::string& id) const {
auto it = impl_->ids.find(id);
return it == impl_->ids.end() ? EntityHandle{} : impl_->handle(it->second);
}
bool Runtime::valid(EntityHandle handle) const noexcept {
return impl_->valid(handle);
}
Transform Runtime::transform(EntityHandle h) const {
return impl_->registry.get<Impl::Pose>(impl_->entity(h)).current;
}
Transform Runtime::presentation(EntityHandle h) const {
return impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented;
}
Json Runtime::fields(EntityHandle h, const std::string& type) const {
auto c = impl_->component(impl_->entity(h), type);
if (!c)
throw std::invalid_argument("entity has no component: " + type);
return (*c)["fields"];
}
Vec3 Runtime::velocity(EntityHandle h) const {
impl_->entity(h);
if (!impl_->physics || !impl_->physics->contains(h.slot))
throw std::invalid_argument("entity has no physics body");
return impl_->physics->velocity(h.slot);
}
bool Runtime::grounded(EntityHandle h) const {
impl_->entity(h);
if (!impl_->physics || !impl_->physics->contains(h.slot))
throw std::invalid_argument("entity has no physics body");
return impl_->physics->grounded(h.slot);
}
InputState Runtime::input() const noexcept {
return impl_->currentInput;
}
const std::vector<CollisionEvent>& Runtime::collisions() const noexcept {
return impl_->contacts;
}
void Runtime::setTransform(EntityHandle h, const Transform& value) {
validateTransform(value);
auto e = impl_->entity(h);
if (impl_->physics && impl_->physics->contains(h.slot))
throw std::invalid_argument("physics transform requires teleport");
auto& d = impl_->registry.get<Impl::Pose>(e);
d.current = value;
if (impl_->phase != Impl::Phase::Fixed) {
d.previous = d.presented = value;
d.changedInUpdate = true;
}
}
void Runtime::setPresentation(EntityHandle h, const Transform& value) {
validateTransform(value);
require(impl_->phase == Impl::Phase::Late,
"presentation may only be changed during LateUpdate");
impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented = value;
}
void Runtime::teleport(EntityHandle h, const Transform& value) {
validateTransform(value);
auto e = impl_->entity(h);
auto& d = impl_->registry.get<Impl::Pose>(e);
if (impl_->physics && impl_->physics->contains(h.slot)) {
require(d.current.scale == value.scale, "changing collider scale requires remove/add body");
if (impl_->dimension == 2)
require(value.rotation[0] == 0 && value.rotation[1] == 0,
"2D physics rotates only around Z");
impl_->physics->teleport(h.slot, value);
}
d.previous = d.current = d.presented = value;
d.changedInUpdate = true;
}
void Runtime::setVelocity(EntityHandle h, Vec3 value) {
impl_->entity(h);
for (float v : value)
require(std::isfinite(v), "nonfinite velocity");
if (!impl_->physics || !impl_->physics->contains(h.slot))
throw std::invalid_argument("entity has no physics body");
impl_->physics->setVelocity(h.slot, value);
}
void Runtime::applyImpulse(EntityHandle h, Vec3 value) {
impl_->entity(h);
for (float v : value)
require(std::isfinite(v), "nonfinite impulse");
if (!impl_->physics || !impl_->physics->contains(h.slot))
throw std::invalid_argument("entity has no physics body");
impl_->physics->impulse(h.slot, value);
}
void Runtime::spawn(Json entity) {
require(bool(impl_->physics), "load a scene before spawning");
validateEntity(entity, impl_->dimension);
impl_->pending.push_back({Impl::Kind::Spawn, {}, std::move(entity), {}});
}
void Runtime::destroy(EntityHandle h) {
impl_->entity(h);
impl_->pending.push_back({Impl::Kind::Destroy, h, {}, {}});
}
void Runtime::addComponent(EntityHandle h, Json component) {
impl_->entity(h);
impl_->pending.push_back({Impl::Kind::Add, h, std::move(component), {}});
}
void Runtime::removeComponent(EntityHandle h, const std::string& type) {
impl_->entity(h);
impl_->pending.push_back({Impl::Kind::Remove, h, {}, type});
}
RuntimeSnapshot Runtime::snapshot() const {
RuntimeSnapshot out{impl_->dimension, impl_->tick, impl_->alpha, {}};
out.entities.reserve(impl_->order.size());
for (auto e : impl_->order) {
const auto& d = impl_->registry.get<Impl::Data>(e);
RenderEntity item;
item.id = d.document["id"].get<std::string>();
item.name = d.document.value("name", item.id);
item.transform = impl_->registry.get<Impl::Pose>(e).presented;
if (d.document.contains("parent") && !d.document["parent"].is_null())
item.parent = d.document["parent"].get<std::string>();
if (auto sprite = impl_->registry.try_get<Sprite>(e))
item.sprite = *sprite;
if (auto mesh = impl_->registry.try_get<Mesh>(e))
item.mesh = *mesh;
out.entities.push_back(std::move(item));
}return out;
}
return out;
}
Json Runtime::snapshotJson()const{auto value=snapshot();Json entities=Json::array();for(const auto& e:value.entities){Json item{{"id",e.id},{"name",e.name},{"parent",e.parent?Json(*e.parent):Json{}},{"transform",transformJson(e.transform)}};if(e.sprite)item["sprite"]={{"color",e.sprite->color},{"size",e.sprite->size},{"texture",e.sprite->texture},{"layer",e.sprite->layer}};if(e.mesh)item["mesh"]={{"asset",e.mesh->asset},{"color",e.mesh->color},{"primitive",e.mesh->primitive}};const auto entity=impl_->ids.at(e.id);for(const auto& type:{"faset.camera","faset.light"})if(auto c=impl_->component(entity,type))item[type==std::string("faset.camera")?"camera":"light"]=(*c)["fields"];entities.push_back(std::move(item));}return {{"dimension",value.dimension},{"tick",value.tick},{"alpha",value.alpha},{"entities",entities}};}
std::uint64_t Runtime::session()const noexcept{return impl_->session;}
const std::vector<std::string>& Runtime::diagnostics()const noexcept{return impl_->diagnostics;}
Json Runtime::snapshotJson() const {
auto value = snapshot();
Json entities = Json::array();
for (const auto& e : value.entities) {
Json item{{"id", e.id},
{"name", e.name},
{"parent", e.parent ? Json(*e.parent) : Json{}},
{"transform", transformJson(e.transform)}};
if (e.sprite)
item["sprite"] = {{"color", e.sprite->color},
{"size", e.sprite->size},
{"texture", e.sprite->texture},
{"layer", e.sprite->layer}};
if (e.mesh)
item["mesh"] = {{"asset", e.mesh->asset},
{"color", e.mesh->color},
{"primitive", e.mesh->primitive}};
const auto entity = impl_->ids.at(e.id);
for (const auto& type : {"faset.camera", "faset.light"})
if (auto c = impl_->component(entity, type))
item[type == std::string("faset.camera") ? "camera" : "light"] = (*c)["fields"];
entities.push_back(std::move(item));
}
return {{"dimension", value.dimension},
{"tick", value.tick},
{"alpha", value.alpha},
{"entities", entities}};
}
std::uint64_t Runtime::session() const noexcept {
return impl_->session;
}
const std::vector<std::string>& Runtime::diagnostics() const noexcept {
return impl_->diagnostics;
}
} // namespace faset::runtime