新增脚本参数系统:声明式参数 + Inspector 自动控件 + 场景序列化

DeclareParams/ParamBuilder 声明字段,运行时反射读写;
Inspector 按类型生成控件,SceneSerializer 直通存取;
动画系统接入参数化裁剪;新增 OrbitMove 示例脚本
This commit is contained in:
2026-10-08 17:02:19 +08:00
parent f327f44269
commit ff955b9e63
11 changed files with 951 additions and 33 deletions
+46
View File
@@ -12,6 +12,7 @@
#include <Engine/Core/UUID.h>
#include <Engine/Core/AssetRef.h>
#include <Engine/Renderer/Camera.h>
#include <Engine/Scripting/ScriptParam.h>
#include <entt/entt.hpp>
@@ -233,6 +234,10 @@ namespace Engine::ECS {
f32 fadeElapsed = 0.0f; // 已过渡时长
f32 fadeDuration = 0.3f; // 过渡总时长(秒)
// 按名播放的运行时反指(瞬态,不落盘不序列化):AnimationSystem
// 每帧回填(同 RigidBody::bodyValid 模式)。空 = 模型未就绪
const Model* runtimeModel = nullptr;
// ---- 资产引用(序列化落盘)----
// 外部动画资产(编辑器 attach 的动画容器文件,随场景持久化)。
// clips 本体运行时并入 Model 资产(共享);重启后由 Inspector
@@ -253,6 +258,16 @@ namespace Engine::ECS {
std::unordered_map<std::string, bool> boolParams;
std::vector<std::string> pendingTriggers; // 消费即清(瞬态)
// 阻尼参数推进状态(瞬态,不落盘——重启从 floatParams 当前值
// 重新起步):current 逐帧指数逼近 target,写回 floatParams 供
// 状态机条件统一读取
struct DampedParam {
float current = 0.0f;
float target = 0.0f;
float response = 8.0f; // 逼近速率(1/s,越大越快)
};
std::unordered_map<std::string, DampedParam> dampedParams;
// 状态机瞬态(不落盘,重启从 entry state 开始)
std::string currentState; // 空 = 未初始化(System 首帧置 entry)
f32 stateTime = 0.0f; // 当前状态播放头(秒)
@@ -266,6 +281,22 @@ namespace Engine::ECS {
{
floatParams[name] = value;
}
// 阻尼变体(Unity SetFloat(name, target, dampTime) 同构):登记
// 目标值,AnimationSystem Controller 驱动期逐帧指数逼近(帧率
// 无关)。首次登记从 floatParams 当前值起步(无则取目标),
// 避免跳变。与 SetFloat 同名混用 = 直接 set 会被下一帧阻尼值
// 覆盖,别混
void SetFloatDamped(const std::string& name, float target, float response)
{
auto [it, inserted] = dampedParams.try_emplace(name);
it->second.target = target;
it->second.response = glm::max(response, 0.01f);
if (inserted) {
const auto fp = floatParams.find(name);
it->second.current =
fp != floatParams.end() ? fp->second : target;
}
}
void SetBool(const std::string& name, bool value)
{
boolParams[name] = value;
@@ -309,6 +340,13 @@ namespace Engine::ECS {
time = 0.0f;
playing = true;
}
// 按名播放(Unity Animator.Play("Walk") 同形,推荐):clip 名经
// runtimeModel 的 clip 表解析,消掉下标硬耦合(attach 顺序变了
// 脚本不改)。未命中/模型未就绪返回 false。实现住
// AnimationSystem.cpp——需要 Model 完整类型,组件头保持零资产
// 依赖(Model.h 拖 vulkan/assimp,进本头会污染全部包含者)
bool Play(const std::string& clipName, f32 fadeSeconds = 0.3f);
};
// 相机。一个场景中 primary=true 的实体是当前渲染视角。
@@ -430,6 +468,9 @@ namespace Engine::ECS {
f32 mass = 70.0f;
f32 maxSlopeAngle = 45.0f; // 度:可站立的最大坡角
f32 stepHeight = 0.4f; // 台阶自动攀爬高(WalkStairs)
// 重力倍率(UE CharacterMovement.GravityScale 同位):真实 9.81
// 对人形角色偏"棉花糖"(滞空 1.2s+),游戏手感普遍 2~3 倍
f32 gravityScale = 2.0f;
bool operator==(const CharacterControllerComponent&) const = default;
// 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯
@@ -444,6 +485,11 @@ namespace Engine::ECS {
struct Item {
std::string name;
bool active = true; // 禁用 = 实例保留但停 OnUpdate
// 参数(权威数据,随场景序列化)。脚本成员 = 运行时真值,
// 实例化期由 ScriptRuntime 按 schema 灌入:键缺失 → 声明
// 默认,多余键忽略;运行时 SetParam 写穿回这里(PIE 实时
// 反映,Stop 由快照天然回滚)
std::vector<Engine::Scripting::ScriptParam> params;
bool operator==(const Item&) const = default;
};
+119 -2
View File
@@ -6,6 +6,13 @@
// 基类接口——将来接 Lua/C# 时各实现一种 foreign behavior(内部持
// VM 状态),引擎零改动。
//
// 瘦基类(对齐 Unity6/UE5 现役形态):基类只供生命周期 + 参数 +
// 实体访问器,零领域 API——物理住 PhysicsWorld(App().GetPhysics()
// 直调),动画住 AnimationComponent(GetComponent 直调,Animator
// 同形)。DeclareParams = C# 反射缺失下 UPROPERTY 的等价物:Unity
// 里你从不写声明是因为语言替你做了,C++ 里每个引擎都得补这一环
//(UE 让你标 UPROPERTY,这里让你写一行 Declare)。
//
// 组件只存 scriptName 字符串(POD,序列化天然支持:Lua/C# 同样按名
// 解析);实例由 ScriptRuntime 对账式管理(照 PhysicsWorld 模式),
// 不进组件——组件可拷贝/可比较。
@@ -21,10 +28,12 @@
#include <Engine/Core/Types.h>
#include <Engine/ECS/Components.h>
#include <Engine/ECS/Scene.h>
#include <Engine/Scripting/ScriptParam.h>
#include <string>
#include <unordered_map>
#include <vector>
#include <algorithm>
namespace Engine {
@@ -39,11 +48,27 @@ namespace Engine {
// 注册表键(= 组件 scriptName),须与 ENGINE_REGISTER_SCRIPT 名一致
virtual const char* Name() const = 0;
// 生命周期(吃 scaled dt:编辑器暂停 timeScale=0 时自然停摆)
// 生命周期(吃 scaled dt:编辑器暂停 timeScale=0 时自然停摆)。
// 顺序对齐 Unity:OnCreate(= Awake,实例化即调)→ OnEnable
// → OnStart(首次 OnUpdate 前一次)→ OnUpdate* → OnDisable →
// OnDestroy(可能因实体销毁触发,句柄已失效只做自身清理)。
// 跨实体 OnEnable 顺序不保证(实例表无序),与 Unity 一致
virtual void OnCreate() {}
virtual void OnEnable() {}
virtual void OnStart() {}
virtual void OnUpdate(f32 dt) {}
virtual void OnDisable() {}
virtual void OnDestroy() {}
// 参数声明(实例化灌值与注册表 schema 缓存共用入口;外语绑定
// 同键接入)。契约:只写成员指针与常量,禁触场景/应用——
// schema 缓存路径用未 Bind 的临时实例调用
virtual void DeclareParams(ParamBuilder& pb) { (void)pb; }
// 参数灌值后回调(外语侧收值接缝:C++ 成员已直写,VM 绑定
// 在此按名取值)
virtual void OnParamsApplied() {}
protected:
// 便捷访问:绑定实体的 TransformComponent
ECS::TransformComponent& Transform() {
@@ -60,7 +85,8 @@ namespace Engine {
return m_Scene->GetRegistry().try_get<T>(m_Entity);
}
// 绑定实体的 entityBits(物理门面按键值寻址)
// 绑定实体的 entityBits(PhysicsWorld 按此键寻址:直调
// App().GetPhysics() 的各 API 时作第一个参数)
u32 EntityBits() const {
return static_cast<u32>(entt::to_integral(m_Entity));
}
@@ -70,6 +96,66 @@ namespace Engine {
bool Valid() const { return m_Scene != nullptr && m_Entity != entt::null; }
// ---- 组件启停(Unity SetActive / Behaviour.enabled 语义)----
// 自身组件总开关(整个脚本组件停 tick,实例保留)
void SetActive(bool on) {
if (auto* sc = m_Scene->GetRegistry().try_get<ECS::ScriptComponent>(m_Entity)) {
sc->active = on;
}
}
bool IsActive() const {
const auto* sc =
m_Scene->GetRegistry().try_get<ECS::ScriptComponent>(m_Entity);
return sc != nullptr && sc->active;
}
// ---- 自身参数(写成员 + 写穿 Item.params:PIE Inspector
// 实时反映,Stop 由快照天然回滚;首次写新名一次分配)----
// 返回成员直写是否命中(未声明参数 false,存档仍记录——前向
// 兼容)。跨脚本/跨实体访问走 App().GetScripts().SetParam/GetParam
bool SetParam(const std::string& name, const ParamValue& v) {
const bool hit = WriteParam(name, v);
if (auto* sc = Valid()
? m_Scene->GetRegistry().try_get<ECS::ScriptComponent>(m_Entity)
: nullptr) {
const auto item = std::find_if(
sc->items.begin(), sc->items.end(),
[&](const ECS::ScriptComponent::Item& it) {
return it.name == Name();
});
if (item != sc->items.end()) {
const auto pit = std::find_if(
item->params.begin(), item->params.end(),
[&](const ScriptParam& p) { return p.name == name; });
if (pit != item->params.end()) {
pit->value = v;
} else {
item->params.push_back({name, v});
}
}
}
return hit;
}
template<typename T>
bool SetParam(const std::string& name, const T& v) {
return SetParam(name, ParamValue{v});
}
bool GetParam(const std::string& name, ParamValue& out) const {
return ReadParam(name, out);
}
template<typename T>
bool GetParam(const std::string& name, T& out) const {
ParamValue v;
if (!ReadParam(name, v)) {
return false;
}
if (auto* p = std::get_if<T>(&v.v)) {
out = *p;
return true;
}
return false; // 类型不符
}
private:
friend class ScriptRuntime;
void Bind(ECS::Scene& scene, entt::entity e, Application* app) {
@@ -77,10 +163,19 @@ namespace Engine {
m_Entity = e;
m_App = app;
}
// 参数绑定表交接(实例化/RefreshParams 时由运行时填充)
void SetParamBinds(std::vector<ParamBuilder::Bind> binds) {
m_ParamBinds = std::move(binds);
}
// 按名写/读成员(经绑定表;未声明或类型不符返回 false)。
// 写穿 Item.params 由运行时侧完成(需查组件项)
bool WriteParam(const std::string& name, const ParamValue& v);
bool ReadParam(const std::string& name, ParamValue& out) const;
ECS::Scene* m_Scene = nullptr;
entt::entity m_Entity = entt::null;
Application* m_App = nullptr;
std::vector<ParamBuilder::Bind> m_ParamBinds;
};
// ---- 按名注册表:脚本类文件级自注册,运行时按名实例化 ----
@@ -108,6 +203,28 @@ namespace Engine {
return v;
}
// 参数 schema(惰性缓存):临时实例调 DeclareParams 取声明
// ——每脚本类一次性(注册先于 main,加载期必就绪)。未注册
// 脚本 = 空 schema(读端未知键全忽略,弱前向兼容)
static const std::vector<ParamBuilder::Desc>&
Schema(const std::string& name) {
static const std::vector<ParamBuilder::Desc> kEmpty;
static std::unordered_map<std::string,
std::vector<ParamBuilder::Desc>>
cache;
const auto it = cache.find(name);
if (it != cache.end()) {
return it->second;
}
std::vector<ParamBuilder::Desc> descs;
if (auto behavior = Create(name)) {
ParamBuilder pb;
behavior->DeclareParams(pb);
descs = pb.Descs();
}
return cache.emplace(name, std::move(descs)).first->second;
}
private:
static std::unordered_map<std::string, Factory>& Map() {
static std::unordered_map<std::string, Factory> m;
@@ -0,0 +1,215 @@
//
// ScriptParam.h:脚本参数——按名变体值 + 声明式 schema 构建器。
//
// 设计本质(UE/Unity 共识):参数数据按名存在序列化层(组件
// ScriptComponent::Item.params,随场景持久化),C++ 脚本成员只是运行时
// 真值缓存——这条边界正是将来 Lua/C# 外语绑定的接缝:
// - C++ 脚本:DeclareParams 里 Field(name, &成员, 默认值) 双写声明
// (灌值直写成员,零回收代码);
// - 外语脚本:Declare(name, 类型, 默认值) 无指针声明,值经
// OnParamsApplied 回收(VM 侧按名取)。
//
// schema 由注册表按脚本类名惰性缓存(ScriptRegistry::Schema)——描述
// 里不含指针,缓存无悬垂;成员指针(Bind)只随实例存亡,不进缓存。
//
#ifndef HELLOVULKAN_SCRIPT_PARAM_H
#define HELLOVULKAN_SCRIPT_PARAM_H
#include <Engine/Core/Types.h>
#include <glm/glm.hpp>
#include <algorithm>
#include <cassert>
#include <string>
#include <string_view>
#include <variant>
#include <vector>
namespace Engine {
namespace Scripting {
// 参数类型(序列化分支 + Inspector 控件选择)。枚举序 = variant
// 臂序(ParamValue::Type() 直转);新类型 = 枚举尾插 + 追加臂,
// 旧档位前向兼容
enum class ParamType : u8 { F32, I32, Bool, String, Vec2, Vec3 };
// 类型 → 枚举映射(Field 重载与消费端共用)
template<typename T> struct ParamTypeOf;
template<> struct ParamTypeOf<f32> { static constexpr ParamType value = ParamType::F32; };
template<> struct ParamTypeOf<i32> { static constexpr ParamType value = ParamType::I32; };
template<> struct ParamTypeOf<bool> { static constexpr ParamType value = ParamType::Bool; };
template<> struct ParamTypeOf<std::string> { static constexpr ParamType value = ParamType::String; };
template<> struct ParamTypeOf<glm::vec2> { static constexpr ParamType value = ParamType::Vec2; };
template<> struct ParamTypeOf<glm::vec3> { static constexpr ParamType value = ParamType::Vec3; };
// 单参数的存储形态(类型标签由 variant 臂序自持)
struct ParamValue {
std::variant<f32, i32, bool, std::string, glm::vec2, glm::vec3>
v{f32{0.0f}};
ParamType Type() const { return static_cast<ParamType>(v.index()); }
template<typename T>
static constexpr ParamType TypeOf() { return ParamTypeOf<T>::value; }
bool operator==(const ParamValue&) const = default;
};
// 单参数(组件 Item.params 的元素,随场景序列化)
struct ScriptParam {
std::string name;
ParamValue value;
bool operator==(const ScriptParam&) const = default;
};
// 参数声明构建器:DeclareParams(pb) 里逐项 Field/Declare,产出
// 描述列表(schema,持久)+ 绑定表(成员指针,瞬态)。
// Field 即把声明默认写进成员——未灌值前成员就是默认值
class ParamBuilder {
public:
// ---- C++ 侧:声明 + 绑定成员(灌值直写)----
void Field(std::string_view name, f32* dst, f32 def) {
*dst = def;
AddDesc(name, ParamType::F32, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::F32, dst});
}
void Field(std::string_view name, i32* dst, i32 def) {
*dst = def;
AddDesc(name, ParamType::I32, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::I32, dst});
}
void Field(std::string_view name, bool* dst, bool def) {
*dst = def;
AddDesc(name, ParamType::Bool, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::Bool, dst});
}
void Field(std::string_view name, std::string* dst,
const std::string& def) {
*dst = def;
AddDesc(name, ParamType::String, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::String, dst});
}
void Field(std::string_view name, glm::vec2* dst,
const glm::vec2& def) {
*dst = def;
AddDesc(name, ParamType::Vec2, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::Vec2, dst});
}
void Field(std::string_view name, glm::vec3* dst,
const glm::vec3& def) {
*dst = def;
AddDesc(name, ParamType::Vec3, ParamValue{def});
m_Binds.push_back({std::string(name), ParamType::Vec3, dst});
}
// ---- 外语侧:仅声明不绑定(值经 OnParamsApplied 回收)----
void Declare(std::string_view name, ParamType t, ParamValue def) {
AddDesc(name, t, std::move(def));
}
// 声明描述(持久——进注册表 schema 缓存,无指针无悬垂)
struct Desc {
std::string name;
ParamType type;
ParamValue def;
};
// 成员绑定(瞬态——指针随声明实例存亡,只进 ScriptBehavior)
struct Bind {
std::string name;
ParamType type;
void* ptr;
};
const std::vector<Desc>& Descs() const { return m_Descs; }
std::vector<Bind> TakeBinds() { return std::move(m_Binds); }
// 单绑定写值:类型不符返回 false(SetParam/跨脚本写共用)
static bool WriteBind(const Bind& b, const ParamValue& v) {
if (b.type != v.Type()) {
return false;
}
switch (b.type) {
case ParamType::F32:
*static_cast<f32*>(b.ptr) = std::get<f32>(v.v);
return true;
case ParamType::I32:
*static_cast<i32*>(b.ptr) = std::get<i32>(v.v);
return true;
case ParamType::Bool:
*static_cast<bool*>(b.ptr) = std::get<bool>(v.v);
return true;
case ParamType::String:
*static_cast<std::string*>(b.ptr) =
std::get<std::string>(v.v);
return true;
case ParamType::Vec2:
*static_cast<glm::vec2*>(b.ptr) = std::get<glm::vec2>(v.v);
return true;
case ParamType::Vec3:
*static_cast<glm::vec3*>(b.ptr) = std::get<glm::vec3>(v.v);
return true;
}
return false;
}
// 单绑定读值(按绑定类型出值;GetParam/跨脚本读共用)
static bool ReadBind(const Bind& b, ParamValue& out) {
switch (b.type) {
case ParamType::F32:
out = ParamValue{*static_cast<f32*>(b.ptr)};
return true;
case ParamType::I32:
out = ParamValue{*static_cast<i32*>(b.ptr)};
return true;
case ParamType::Bool:
out = ParamValue{*static_cast<bool*>(b.ptr)};
return true;
case ParamType::String:
out = ParamValue{*static_cast<std::string*>(b.ptr)};
return true;
case ParamType::Vec2:
out = ParamValue{*static_cast<glm::vec2*>(b.ptr)};
return true;
case ParamType::Vec3:
out = ParamValue{*static_cast<glm::vec3*>(b.ptr)};
return true;
}
return false;
}
// 按名灌值进绑定成员:命中→直写;未绑定(外语声明)→跳过;
// 类型不符/多余键→忽略(读端已校正,这里兜底)。
// 缺失键不写——Field 已把声明默认写进成员
void Apply(const std::vector<ScriptParam>& params) const {
for (const auto& p : params) {
const auto b = std::find_if(
m_Binds.begin(), m_Binds.end(),
[&](const Bind& it) { return it.name == p.name; });
if (b != m_Binds.end()) {
WriteBind(*b, p.value);
}
}
}
private:
void AddDesc(std::string_view name, ParamType t, ParamValue def) {
assert(!name.empty() && "参数名不能为空");
assert(std::none_of(m_Descs.begin(), m_Descs.end(),
[&](const Desc& d) {
return d.name == name;
}) &&
"参数重名");
m_Descs.push_back({std::string(name), t, std::move(def)});
}
std::vector<Desc> m_Descs;
std::vector<Bind> m_Binds;
};
} // namespace Scripting
} // namespace Engine
#endif //HELLOVULKAN_SCRIPT_PARAM_H
@@ -1,7 +1,11 @@
//
// ScriptRuntime.h:脚本生命周期驱动。对账式管理(组件项加/删/改名 ↔
// 实例建/毁),照 PhysicsWorld.SyncScene 模式——每帧对账,编辑器操作
// 即时生效,场景加载/实体销毁自动消化。
// 实例建/毁),照 PhysicsWorld.SyncScene 模式——Running 中每帧对账,
// PIE 中组件增删/改名即时生效,场景加载/实体销毁自动消化。
//
// Play Mode 语义(Unity/UE PIE 对齐):编辑态不实例化任何脚本——
// 实例生命周期 = 一次 PIE 局,Play 后首次 Update 对账重建,Stop 时
// EndPlay 统一终局,成员状态不跨局泄漏。
//
// 多脚本:一个实体可挂任意多个脚本项(ScriptComponent::items),实例
// 按名字与 items 对账——重命名/删除走 OnDestroy 清理,未动的实例不重建。
@@ -23,10 +27,13 @@ namespace Engine {
namespace Scripting {
// 实例簿记:行为对象 + OnCreate 是否已调
// 实例簿记:行为对象 + 生命周期位(created = OnCreate 已调;
// enabled = 当前启停态,翻转检测基准;started = OnStart 已发)
struct ScriptInstance {
Scope<ScriptBehavior> behavior;
bool created = false;
bool enabled = false;
bool started = false;
};
class ScriptRuntime {
@@ -34,9 +41,29 @@ namespace Engine {
explicit ScriptRuntime(ECS::Scene& scene, Application* app = nullptr)
: m_Scene(scene), m_App(app) {}
// 每帧调用(吃 scaled dt):对账 → OnCreate(新实例)→ OnUpdate
// 每帧调用(吃 scaled dt):对账 → OnCreate(新实例)→
// 启停翻转(OnEnable/OnDisable)→ OnStart(首次)→ OnUpdate。
// 仅 Running 态被调(Application 门控)
void Update(f32 dt);
// 终局(GameFlow →Stopped 广播):全部实例 OnDisable →
// OnDestroy 并清空簿记。Unity 退出 Play 语义——成员状态
// (动量/边沿/当前 clip)不跨局泄漏,下次 Play 对账重建
void EndPlay();
// 参数热刷新(编辑期一次性):实体全部实例重新 DeclareParams
// + 按组件存档灌值(Inspector 编辑参数后调)
void RefreshParams(u32 entityBits);
// 跨脚本按名参数访问(本实体兄弟项或跨实体):SetParam 写
// 目标实例成员 + 写穿 Item.params(PIE Inspector 实时反映);
// GetParam 读目标实例成员。实体无组件/项不存在/实例未建
// 返回 false
bool SetParam(u32 entityBits, const std::string& scriptName,
const std::string& param, const ParamValue& value);
bool GetParam(u32 entityBits, const std::string& scriptName,
const std::string& param, ParamValue& out) const;
private:
void Sync();