Foreign Classes / Interfaces
TitanNano opened this issue · comments
I have recently been toying around with some nicer way for interfacing with Godot classes / types that are defined outside of the engine or the rust gdextension, i.e. in GDScript C# or another extension.
Currently, we can use Object::call(...)
but it can be quite error-prone as we have to repeat and duplicate the calls to the same functions across all call sites. It would be much nicer if we could define the expected interface once and then re-use everywhere without having to resort to string-based method names and remembering the right argument orders.
Idea
My idea was to allow the user to define traits for the foreign interfaces they expect and then provide a proc-macro that generates a duck-type implementation against a Gd<T>
.
// ------------- types and traits provided by gdext -------------
/// Wrapper around Gd<T> on which traits will be implemented. This type cannot be constructed or
/// obtained by the consumer.
pub struct ForeignClass(Gd<Object>);
impl ForeignClass {
/// runtime validation of function signatures. could also take a full MethodInfo.
pub fn has_method(&self, name: StringName, args: &[VariantType], ret: VariantType) -> bool {
let method = self.0.get_method_list().iter_shared().find(|method| {
method
.get("name")
.map(|method| method.to::<StringName>() == name)
.unwrap_or(false)
});
let Some(method_args) = method.as_ref().and_then(|method| method.get("args")) else {
return false;
};
let method_args = method_args.to::<Array<Dictionary>>();
let matches = args
.iter()
.enumerate()
.map(|(index, arg)| {
method_args
.try_get(index)
.map(|m_arg| &m_arg.get_or_nil("type").to::<VariantType>() == arg)
.unwrap_or(false)
})
.all(|item| item);
let return_matches = method
.and_then(|method| method.get("return"))
.map(|r| r.to::<VariantType>() == ret)
.unwrap_or(false);
matches && return_matches
}
/// pass through to Object::call
pub fn call(&mut self, name: StringName, args: &[Variant]) -> Variant {
self.0.call(name, args)
}
}
/// helper trait for casting the foreign class into a trait object.
pub trait ForeignClassObject {
type Base: GodotClass;
fn from_foreign(fc: Box<ForeignClass>) -> Result<Box<Self>, ForeignClassError>;
}
/// extension trait for Gd, can be merged into the Gd impl.
pub trait GdExt<T: GodotClass> {
fn try_to_foreign<O>(&self) -> Result<Box<O>, ForeignClassError>
where
O: ForeignClassObject + ?Sized,
T: Inherits<O::Base> + Inherits<Object>;
}
impl<T: GodotClass> GdExt<T> for Gd<T> {
/// cast into a duck-typed trait object. The compatibility is checked at runtime.
/// This is the only way to get an instance of ForeignClass.
fn try_to_foreign<O>(&self) -> Result<Box<O>, ForeignClassError>
where
O: ForeignClassObject + ?Sized,
T: Inherits<O::Base> + Inherits<Object>,
{
let obj = self.clone().upcast();
let foreign = Box::new(ForeignClass(obj));
/// compatebility is currently checked inside this function but could be moved into a separate call.
O::from_foreign(foreign)
}
}
#[derive(Debug)]
pub enum ForeignClassError {
MissingMethod(StringName, Vec<VariantType>, VariantType),
}
// ------------- trait and impls inside the consumer gdextension -------------
/// user declared foreign interface
trait ITestScript {
fn health(&mut self) -> u8;
fn hit_enemy(&mut self, enemy: Gd<Node3D>);
}
/// proc-macro generates an implementation of the trait for ForeignClass by calling its methods via Object::class
impl ITestScript for ForeignClass {
fn health(&mut self) -> u8 {
self.call(StringName::from("health"), &[]).to()
}
fn hit_enemy(&mut self, enemy: Gd<Node3D>) {
self.call(StringName::from("hit_enemy"), &[enemy.to_variant()])
.to()
}
}
/// implementation of the helper trait to cast a Box<ForeignClass> into the correct trait object.
impl ForeignClassObject for dyn ITestScript {
type Base = Node3D;
fn from_foreign(fc: Box<ForeignClass>) -> Result<Box<Self>, ForeignClassError> {
// validate health method exists and is correct
if !fc.has_method("health".into(), &[], VariantType::Int) {
return Err(ForeignClassError::MissingMethod(
"health".into(),
vec![],
VariantType::Int,
));
}
// validate hit_enemy method exists and is correct
if !fc.has_method("hit_enemy".into(), &[VariantType::Object], VariantType::Nil) {
return Err(ForeignClassError::MissingMethod(
"hit_enemy".into(),
vec![VariantType::Object],
VariantType::Nil,
));
}
// cast once everything has been verified.
Ok(fc as Box<Self>)
}
}
Why not Gd<ITestScript>
?
Making traits work with Gd<T>
would be quite hard, if not impossible, I think.
Using a concrete type, on the other hand, should be doable as it would be very similar to the generated engine classes but calls Object::call
for all methods instead (from what I have seen so far).
The struct would though have to be either generated for the defined trait or defined instead of the trait in combination with an impl
that contains body-less method declarations. Both sounds more cumbersome to me than defining a trait, which feels much more native to rust.
I'll close this as duplicate of #372 to keep things in one place, but feel free to copy/mention your ideas there! 🙂