Skip to content

Commit 75e9c7a

Browse files
committed
refactor(api)!: remove Default Runtime pattern from public API
The Default Runtime created hidden state (Bobbin._default) that doesn't integrate well with signals, can persist unexpectedly between scene changes, and creates duplicate APIs. Replace with factory pattern only (Bobbin.create/create_with_host). Changes: - Remove Default Runtime section from bobbin.gd (~80 lines) - Keep only factory methods: create() and create_with_host() - Update test scene to use explicit _runtime variable - Update README with new usage examples - Fix hot reload timer initialization to use call_deferred() to avoid "busy setting up children" error during scene tree setup BREAKING CHANGE: Bobbin.start(), Bobbin.advance(), Bobbin.current_line(), Bobbin.has_more(), Bobbin.is_waiting_for_choice(), Bobbin.current_choices(), Bobbin.select_choice(), Bobbin.get_variable(), Bobbin.set_variable(), Bobbin.get_all_variables(), Bobbin.update_host_variable() are removed. Use explicit runtime: var runtime = Bobbin.create_with_host(...) and call methods on runtime instead.
1 parent 15ea705 commit 75e9c7a

4 files changed

Lines changed: 264 additions & 121 deletions

File tree

bindings/godot/addons/bobbin/README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,23 @@ Hello, {player_name}!
2727
## Using in Godot
2828

2929
```gdscript
30-
# Simple API (single dialogue)
31-
Bobbin.start("res://dialogue/intro.bobbin")
30+
# Create a runtime
31+
var runtime = Bobbin.create("res://dialogue/intro.bobbin")
3232
33-
while Bobbin.has_more():
34-
if Bobbin.is_waiting_for_choice():
35-
var choices = Bobbin.current_choices()
33+
while runtime.has_more():
34+
if runtime.is_waiting_for_choice():
35+
var choices = runtime.current_choices()
3636
# Show choices to player, get their selection...
37-
Bobbin.select_choice(selection)
37+
runtime.select_choice(selection)
3838
else:
39-
print(Bobbin.current_line())
40-
Bobbin.advance()
39+
print(runtime.current_line())
40+
runtime.advance()
4141
4242
# With host state (pass game variables to dialogue)
43-
Bobbin.start_with_host("res://dialogue/intro.bobbin", {
43+
var runtime = Bobbin.create_with_host("res://dialogue/intro.bobbin", {
4444
"player_name": "Hero",
4545
"gold": 100
4646
})
47-
48-
# Create independent runtimes for multiple concurrent dialogues
49-
var runtime = Bobbin.create("res://dialogue/npc.bobbin")
5047
```
5148

5249
## Editor Settings
Lines changed: 3 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
class_name Bobbin
22

33

4-
# =============================================================================
5-
# Factory - Create independent runtime instances
6-
# =============================================================================
7-
84
## Create a new BobbinRuntime instance from a script path.
95
## Use this when you need multiple concurrent dialogs.
106
static func create(path: String) -> BobbinRuntime:
@@ -13,90 +9,8 @@ static func create(path: String) -> BobbinRuntime:
139

1410
## Create a new BobbinRuntime instance with host state.
1511
## Host state provides extern variables that the dialogue can read.
12+
## Hot reload is enabled automatically in debug builds.
1613
static func create_with_host(path: String, host_state: Dictionary) -> BobbinRuntime:
17-
var script: BobbinScript = ResourceLoader.load(path, "BobbinScript")
18-
assert(script != null, "Bobbin.create_with_host() failed to load: " + path)
19-
if script == null:
20-
return null
21-
var runtime = BobbinRuntime.from_string_with_host(script.source_code, host_state)
22-
assert(runtime != null, "Bobbin.create_with_host() failed to parse: " + path)
14+
var runtime = BobbinRuntime.from_file_with_host(path, host_state)
15+
assert(runtime != null, "Bobbin.create_with_host() failed: " + path)
2316
return runtime
24-
25-
26-
# =============================================================================
27-
# Default Runtime - Simple API for single-dialog games
28-
# =============================================================================
29-
30-
static var _default: BobbinRuntime = null
31-
32-
33-
# --- Commands (change state, return nothing) ---
34-
35-
## Start a dialog using the default runtime.
36-
## For multiple concurrent dialogs, use create() instead.
37-
static func start(path: String) -> void:
38-
_default = create(path)
39-
40-
41-
## Start a dialog with host state using the default runtime.
42-
## Host state provides extern variables that the dialogue can read.
43-
static func start_with_host(path: String, host_state: Dictionary) -> void:
44-
_default = create_with_host(path, host_state)
45-
46-
47-
static func advance() -> void:
48-
_default.advance()
49-
50-
51-
static func select_choice(index: int) -> void:
52-
_default.select_choice(index)
53-
54-
55-
# --- Queries (return data, don't change state) ---
56-
57-
static func current_line() -> String:
58-
return _default.current_line()
59-
60-
61-
static func has_more() -> bool:
62-
return _default.has_more()
63-
64-
65-
static func is_waiting_for_choice() -> bool:
66-
return _default.is_waiting_for_choice()
67-
68-
69-
static func current_choices() -> PackedStringArray:
70-
return _default.current_choices()
71-
72-
73-
# =============================================================================
74-
# Variable Access - Read/write save variables and host state
75-
# =============================================================================
76-
77-
## Get a save variable value from the default runtime.
78-
## Returns null if the variable doesn't exist.
79-
static func get_variable(name: String) -> Variant:
80-
if _default == null:
81-
return null
82-
return _default.get_variable(name)
83-
84-
85-
## Set a save variable value on the default runtime.
86-
static func set_variable(name: String, value: Variant) -> void:
87-
if _default != null:
88-
_default.set_variable(name, value)
89-
90-
91-
## Get all save variables as a Dictionary.
92-
static func get_all_variables() -> Dictionary:
93-
if _default == null:
94-
return {}
95-
return _default.get_all_variables()
96-
97-
98-
## Update a host variable value on the default runtime.
99-
## Use this when game state changes that dialogue needs to see.
100-
static func update_host_variable(name: String, value: Variant) -> void:
101-
if _default != null:
102-
_default.update_host_variable(name, value)

bindings/godot/src/lib.rs

Lines changed: 220 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use bobbin_runtime::{HostState, Runtime, Value, VariableStorage};
22
use 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
};
89
use godot::meta::RawPtr;
910
use 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

Comments
 (0)