11use bobbin_runtime:: { HostState , Runtime , Value , VariableStorage } ;
22use godot:: classes:: {
33 Engine , FileAccess , IResourceFormatLoader , IResourceFormatSaver , IScriptExtension ,
4- IScriptLanguageExtension , Resource , ResourceFormatLoader , ResourceFormatSaver , ResourceLoader ,
5- ResourceSaver , Script , ScriptExtension , ScriptLanguage , ScriptLanguageExtension ,
6- file_access:: ModeFlags , script_language:: ScriptNameCasing ,
4+ IScriptLanguageExtension , Os , Resource , ResourceFormatLoader , ResourceFormatSaver ,
5+ ResourceLoader , ResourceSaver , Script , ScriptExtension , ScriptLanguage ,
6+ ScriptLanguageExtension , SceneTree , Timer ,
7+ file_access:: ModeFlags , resource_loader:: CacheMode , script_language:: ScriptNameCasing ,
78} ;
89use godot:: meta:: RawPtr ;
910use godot:: prelude:: * ;
@@ -755,6 +756,11 @@ pub struct BobbinRuntime {
755756 storage : Arc < MemoryStorage > ,
756757 host : Arc < VarDictionaryHostState > ,
757758 inner : Runtime ,
759+
760+ // Hot reload support (debug builds only)
761+ source_path : Option < GString > , // None if created via from_string()
762+ last_modified : u64 , // File modification timestamp
763+ poll_timer : Option < Gd < Timer > > , // Self-managed polling timer
758764}
759765
760766#[ godot_api]
@@ -766,7 +772,7 @@ impl BobbinRuntime {
766772 Self :: from_string_with_host ( content, VarDictionary :: new ( ) )
767773 }
768774
769- /// Create runtime with host state VarDictionary .
775+ /// Create runtime with host state Dictionary .
770776 #[ func]
771777 fn from_string_with_host ( content : GString , host_state : VarDictionary ) -> Option < Gd < Self > > {
772778 let storage = Arc :: new ( MemoryStorage :: new ( ) ) ;
@@ -781,6 +787,9 @@ impl BobbinRuntime {
781787 storage,
782788 host,
783789 inner : runtime,
790+ source_path : None ,
791+ last_modified : 0 ,
792+ poll_timer : None ,
784793 } ) ) ,
785794 Err ( e) => {
786795 godot_error ! (
@@ -792,6 +801,213 @@ impl BobbinRuntime {
792801 }
793802 }
794803
804+ /// Create runtime from a .bobbin file path.
805+ #[ func]
806+ fn from_file ( path : GString ) -> Option < Gd < Self > > {
807+ Self :: from_file_with_host ( path, VarDictionary :: new ( ) )
808+ }
809+
810+ /// Create runtime from a .bobbin file path with host state.
811+ #[ func]
812+ fn from_file_with_host ( path : GString , host_state : VarDictionary ) -> Option < Gd < Self > > {
813+ // Load BobbinScript resource
814+ let Some ( resource) = ResourceLoader :: singleton ( )
815+ . load_ex ( & path)
816+ . type_hint ( "BobbinScript" )
817+ . done ( )
818+ else {
819+ godot_error ! ( "BobbinRuntime::from_file: Failed to load {}" , path) ;
820+ return None ;
821+ } ;
822+
823+ let Ok ( script) = resource. try_cast :: < BobbinScript > ( ) else {
824+ godot_error ! ( "BobbinRuntime::from_file: {} is not a BobbinScript" , path) ;
825+ return None ;
826+ } ;
827+
828+ let source = script. bind ( ) . get_source_code ( ) . to_string ( ) ;
829+ let storage = Arc :: new ( MemoryStorage :: new ( ) ) ;
830+ let host = Arc :: new ( VarDictionaryHostState :: from_dictionary ( & host_state) ) ;
831+
832+ let storage_dyn: Arc < dyn VariableStorage > = storage. clone ( ) ;
833+ let host_dyn: Arc < dyn HostState > = host. clone ( ) ;
834+
835+ match Runtime :: new ( & source, storage_dyn, host_dyn) {
836+ Ok ( runtime) => {
837+ // Get initial modification time and setup hot reload (debug builds only)
838+ let ( source_path, last_modified) = if Os :: singleton ( ) . is_debug_build ( ) {
839+ let modified = FileAccess :: get_modified_time ( & path) ;
840+ ( Some ( path) , modified)
841+ } else {
842+ ( None , 0 )
843+ } ;
844+
845+ let mut instance = Gd :: from_init_fn ( |base| Self {
846+ base,
847+ storage,
848+ host,
849+ inner : runtime,
850+ source_path,
851+ last_modified,
852+ poll_timer : None ,
853+ } ) ;
854+
855+ // Start hot reload polling (debug only, requires scene tree)
856+ instance. bind_mut ( ) . start_hot_reload ( ) ;
857+
858+ Some ( instance)
859+ }
860+ Err ( e) => {
861+ godot_error ! (
862+ "Failed to create runtime:\n {}" ,
863+ e. render( & path. to_string( ) , & source)
864+ ) ;
865+ None
866+ }
867+ }
868+ }
869+
870+ // =========================================================================
871+ // Hot Reload
872+ // =========================================================================
873+
874+ #[ signal]
875+ fn reloaded ( ) ;
876+
877+ #[ signal]
878+ fn reload_failed ( error_message : GString ) ;
879+
880+ /// Reload with new source code. Preserves save variables.
881+ #[ func]
882+ fn reload ( & mut self , new_source : GString ) -> bool {
883+ let source_str = new_source. to_string ( ) ;
884+ let path_str = self
885+ . source_path
886+ . as_ref ( )
887+ . map ( |p| p. to_string ( ) )
888+ . unwrap_or_else ( || "<script>" . to_string ( ) ) ;
889+
890+ let storage_dyn: Arc < dyn VariableStorage > = self . storage . clone ( ) ;
891+ let host_dyn: Arc < dyn HostState > = self . host . clone ( ) ;
892+
893+ match Runtime :: new ( & source_str, storage_dyn, host_dyn) {
894+ Ok ( new_runtime) => {
895+ self . inner = new_runtime;
896+ self . base_mut ( )
897+ . emit_signal ( & StringName :: from ( "reloaded" ) , & [ ] ) ;
898+ true
899+ }
900+ Err ( e) => {
901+ let error_msg = e. render ( & path_str, & source_str) ;
902+ godot_error ! ( "Hot reload failed:\n {}" , error_msg) ;
903+ self . base_mut ( ) . emit_signal (
904+ & StringName :: from ( "reload_failed" ) ,
905+ & [ Variant :: from ( GString :: from ( error_msg. as_str ( ) ) ) ] ,
906+ ) ;
907+ false
908+ }
909+ }
910+ }
911+
912+ /// Check if source file changed and reload if needed.
913+ /// Called automatically by the internal Timer. Can also be called manually.
914+ #[ func]
915+ fn check_for_reload ( & mut self ) {
916+ // Skip in release builds
917+ if !Os :: singleton ( ) . is_debug_build ( ) {
918+ return ;
919+ }
920+
921+ // Skip if no source path (created via from_string)
922+ let Some ( path) = & self . source_path else {
923+ return ;
924+ } ;
925+
926+ // Check modification time
927+ let current_modified = FileAccess :: get_modified_time ( path) ;
928+ if current_modified == self . last_modified {
929+ return ; // No change
930+ }
931+
932+ // File changed - reload
933+ self . last_modified = current_modified;
934+
935+ // Load fresh BobbinScript via ResourceLoader (bypasses cache)
936+ let Some ( resource) = ResourceLoader :: singleton ( )
937+ . load_ex ( path)
938+ . type_hint ( "BobbinScript" )
939+ . cache_mode ( CacheMode :: REPLACE )
940+ . done ( )
941+ else {
942+ godot_error ! ( "Hot reload: Failed to load {}" , path) ;
943+ return ;
944+ } ;
945+
946+ let Ok ( script) = resource. try_cast :: < BobbinScript > ( ) else {
947+ godot_error ! ( "Hot reload: {} is not a BobbinScript" , path) ;
948+ return ;
949+ } ;
950+
951+ // Reload with new source
952+ let new_source = script. bind ( ) . get_source_code ( ) ;
953+ godot_print ! ( "Hot reload: Reloading {}" , path) ;
954+ self . reload ( new_source) ;
955+ }
956+
957+ /// Start the hot reload polling timer (debug builds only).
958+ /// Called automatically by from_file(). No-op if already started or in release.
959+ #[ func]
960+ fn start_hot_reload ( & mut self ) {
961+ // Skip in release builds or if no source path
962+ if !Os :: singleton ( ) . is_debug_build ( ) || self . source_path . is_none ( ) {
963+ return ;
964+ }
965+
966+ // Skip if timer already exists
967+ if self . poll_timer . is_some ( ) {
968+ return ;
969+ }
970+
971+ // Create and configure timer
972+ let mut timer = Timer :: new_alloc ( ) ;
973+ timer. set_wait_time ( 0.5 ) ;
974+ timer. set_one_shot ( false ) ;
975+ timer. set_autostart ( true ) ; // Start automatically when added to tree
976+
977+ // Connect timeout signal to our polling method
978+ let callable = self . base ( ) . callable ( & StringName :: from ( "check_for_reload" ) ) ;
979+ timer. connect ( & StringName :: from ( "timeout" ) , & callable) ;
980+
981+ // Add timer to scene tree so it can tick
982+ // Use call_deferred to avoid "busy setting up children" error during _ready()
983+ if let Some ( tree) = Engine :: singleton ( )
984+ . get_main_loop ( )
985+ . and_then ( |ml| ml. try_cast :: < SceneTree > ( ) . ok ( ) )
986+ {
987+ if let Some ( mut root) = tree. get_root ( ) {
988+ root. call_deferred ( "add_child" , & [ timer. to_variant ( ) ] ) ;
989+ self . poll_timer = Some ( timer) ;
990+ } else {
991+ godot_warn ! ( "Hot reload: Could not access scene root, polling disabled" ) ;
992+ timer. free ( ) ;
993+ }
994+ } else {
995+ godot_warn ! ( "Hot reload: Could not access scene tree, polling disabled" ) ;
996+ timer. free ( ) ;
997+ }
998+ }
999+
1000+ /// Stop hot reload polling and clean up timer.
1001+ #[ func]
1002+ fn stop_hot_reload ( & mut self ) {
1003+ if let Some ( mut timer) = self . poll_timer . take ( ) {
1004+ timer. stop ( ) ;
1005+ if timer. is_inside_tree ( ) {
1006+ timer. queue_free ( ) ;
1007+ }
1008+ }
1009+ }
1010+
7951011 #[ func]
7961012 fn advance ( & mut self ) {
7971013 if let Err ( e) = self . inner . advance ( ) {
0 commit comments