@@ -6,7 +6,7 @@ extends Control
66## actual engine singletons.
77var js_bridge_wrapper : JavaScriptBridgeWrapper = JavaScriptBridgeWrapper .new ()
88var os_wrapper : OSWrapper = OSWrapper .new ()
9- var js_window : JavaScriptObject
9+ var js_window : Variant
1010var _change_difficulty_cb : JavaScriptObject
1111var _gameplay_back_button_pressed_cb : JavaScriptObject
1212var _gameplay_reset_cb : JavaScriptObject
@@ -27,22 +27,39 @@ func _ready() -> void:
2727 # Configure for web overlays (invisible but positioned)
2828 process_mode = Node .PROCESS_MODE_ALWAYS # Ignore pause
2929
30- difficulty_slider .value_changed .connect (_on_difficulty_value_changed )
31- # Set initial difficulty label (sync with global)
32- difficulty_slider .value = Globals .settings .difficulty
33- difficulty_label .text = "{" + str (Globals .settings .difficulty ) + "}"
30+ var settings_res := Globals .settings if is_instance_valid (Globals ) else null
31+
32+ # ADD GUARDS HERE:
33+ if not difficulty_slider .value_changed .is_connected (_on_difficulty_value_changed ):
34+ difficulty_slider .value_changed .connect (_on_difficulty_value_changed )
35+
36+ # Set initial difficulty label (sync with global if available)
37+ # FIX: Use the local reference for consistency
38+ if is_instance_valid (settings_res ):
39+ difficulty_slider .value = settings_res .difficulty
40+ difficulty_label .text = "{" + str (settings_res .difficulty ) + "}"
41+ else :
42+ difficulty_slider .value = _default_difficulty
43+ difficulty_label .text = "{" + str (_default_difficulty ) + "}"
44+
3445 # Back button
3546 if not gameplay_back_button .pressed .is_connected (_on_gameplay_back_button_pressed ):
3647 gameplay_back_button .pressed .connect (_on_gameplay_back_button_pressed )
3748 # Reset button listener
3849 if not gameplay_reset_button .pressed .is_connected (_on_gameplay_reset_button_pressed ):
3950 gameplay_reset_button .pressed .connect (_on_gameplay_reset_button_pressed )
4051 # NEW: Attach tree_exited for unexpected removal cleanup (like other settings scripts)
41- tree_exited .connect (_on_tree_exited )
52+ if not tree_exited .is_connected (_on_tree_exited ):
53+ tree_exited .connect (_on_tree_exited )
4254
4355 # NEW: The UI now observes the resource for external changes
44- if not Globals .settings .setting_changed .is_connected (_on_external_setting_changed ):
45- Globals .settings .setting_changed .connect (_on_external_setting_changed )
56+ # if not Globals.settings.setting_changed.is_connected(_on_external_setting_changed):
57+ # Globals.settings.setting_changed.connect(_on_external_setting_changed)
58+ if (
59+ is_instance_valid (settings_res )
60+ and not settings_res .setting_changed .is_connected (_on_external_setting_changed )
61+ ):
62+ settings_res .setting_changed .connect (_on_external_setting_changed )
4663
4764 if os_wrapper .has_feature ("web" ):
4865 # Toggle overlays...
@@ -85,10 +102,15 @@ func _ready() -> void:
85102
86103
87104func _on_external_setting_changed (setting_name : String , new_value : Variant ) -> void :
105+ ## SYNC UI ONLY:
106+ ## This observer reacts to changes from the resource.
107+ ## We must ensure the UI nodes are still valid before updating them.
88108 if setting_name == "difficulty" :
89- # SYNC UI ONLY:
90- # The resource has already been updated, so we only need to update the UI components.
91- # Use set_value_no_signal to prevent re-triggering the local _on_difficulty_value_changed handler.
109+ # FIX: Guard against 'previously freed' errors during teardown/unit tests
110+ if not is_instance_valid (difficulty_slider ) or not is_instance_valid (difficulty_label ):
111+ return
112+
113+ # Use set_value_no_signal to prevent re-triggering local handlers
92114 difficulty_slider .set_value_no_signal (float (new_value ))
93115 difficulty_label .text = "{" + str (new_value ) + "}"
94116
@@ -97,29 +119,34 @@ func _on_tree_exited() -> void:
97119 ## Cleanup on unexpected tree exit (e.g. parent removed without calling back button).
98120 ## Disconnects signals, restores previous menu if not intentional, clears JS/DOM state.
99121 ## :rtype: void
100- Globals . log_message ( "Gameplay Settings _on_tree_exited called." , Globals . LogLevel . DEBUG )
122+ ## Cleanup on unexpected tree exit.
101123
102- # Disconnect the global resource observer to prevent stale references
103- # GUARD: Ensure Globals and the settings resource are still valid before disconnecting
104- # Use a local variable to safely check and access the settings resource
105- var settings_res := Globals .settings if is_instance_valid (Globals ) else null
124+ # FIX: Guard the initial log message against a torn-down Globals singleton
125+ if is_instance_valid (Globals ):
126+ Globals .log_message ("Gameplay Settings _on_tree_exited called." , Globals .LogLevel .DEBUG )
106127
128+ # 1. Safe Global Resource Disconnection
129+ var settings_res := Globals .settings if is_instance_valid (Globals ) else null
107130 if is_instance_valid (settings_res ):
108131 if settings_res .setting_changed .is_connected (_on_external_setting_changed ):
109132 settings_res .setting_changed .disconnect (_on_external_setting_changed )
110133
111- # Disconnect Godot signals if still connected
112- if difficulty_slider .value_changed .is_connected (_on_difficulty_value_changed ):
113- difficulty_slider .value_changed .disconnect (_on_difficulty_value_changed )
114- if gameplay_back_button .pressed .is_connected (_on_gameplay_back_button_pressed ):
115- gameplay_back_button .pressed .disconnect (_on_gameplay_back_button_pressed )
116- if gameplay_reset_button .pressed .is_connected (_on_gameplay_reset_button_pressed ):
117- gameplay_reset_button .pressed .disconnect (_on_gameplay_reset_button_pressed )
134+ # 2. FIX: Guarded Local Disconnections
135+ # We must check if the nodes still exist before accessing 'value_changed' or 'pressed'
136+ if is_instance_valid (difficulty_slider ):
137+ if difficulty_slider .value_changed .is_connected (_on_difficulty_value_changed ):
138+ difficulty_slider .value_changed .disconnect (_on_difficulty_value_changed )
118139
119- # Clean up JS callbacks on window object
120- _unset_gameplay_settings_window_callbacks ()
140+ if is_instance_valid (gameplay_back_button ):
141+ if gameplay_back_button .pressed .is_connected (_on_gameplay_back_button_pressed ):
142+ gameplay_back_button .pressed .disconnect (_on_gameplay_back_button_pressed )
143+
144+ if is_instance_valid (gameplay_reset_button ):
145+ if gameplay_reset_button .pressed .is_connected (_on_gameplay_reset_button_pressed ):
146+ gameplay_reset_button .pressed .disconnect (_on_gameplay_reset_button_pressed )
121147
122- # Null out stored callback references
148+ # 3. Clean up JS/Web state
149+ _unset_gameplay_settings_window_callbacks ()
123150 _change_difficulty_cb = null
124151 _gameplay_back_button_pressed_cb = null
125152 _gameplay_reset_cb = null
@@ -133,7 +160,12 @@ func _on_tree_exited() -> void:
133160 document.getElementById('gameplay-reset-button').style.display = 'none';
134161 """
135162
136- if not _intentional_exit and not Globals .hidden_menus .is_empty ():
163+ # FIX: Guard the hidden_menus array check against a torn-down Globals singleton
164+ if (
165+ not _intentional_exit
166+ and is_instance_valid (Globals )
167+ and not Globals .hidden_menus .is_empty ()
168+ ):
137169 # Unexpected exit → restore previous menu and options overlays
138170 var prev_menu : Node = Globals .hidden_menus .pop_back ()
139171 if is_instance_valid (prev_menu ):
@@ -261,75 +293,132 @@ func _on_difficulty_value_changed(value: float) -> void:
261293 ## :type value: float
262294 ## :rtype: void
263295 # Update the resource first (this triggers clamping in the setter)
264- Globals .settings .difficulty = value
296+ # Globals.settings.difficulty = value
265297 # Update the UI components using the ALREADY CLAMPED value from the resource
266- difficulty_slider .value = Globals .settings .difficulty
267- difficulty_label .text = "{" + str (Globals .settings .difficulty ) + "}"
298+ # difficulty_slider.value = Globals.settings.difficulty
299+ # difficulty_label.text = "{" + str(Globals.settings.difficulty) + "}"
300+ var settings_res := Globals .settings if is_instance_valid (Globals ) else null
301+
302+ # FIX: Use the local reference exclusively
303+ if not is_instance_valid (settings_res ):
304+ Globals .log_message (
305+ "Gameplay Settings: settings_res unavailable; skipping difficulty update." ,
306+ Globals .LogLevel .WARNING
307+ )
308+ return
309+
310+ settings_res .difficulty = value
311+ if is_instance_valid (difficulty_slider ):
312+ difficulty_slider .set_value_no_signal (settings_res .difficulty )
313+ if is_instance_valid (difficulty_label ):
314+ difficulty_label .text = "{" + str (settings_res .difficulty ) + "}"
268315
269316
270317# New: JS-specific callback (exactly one Array arg, no default)
271318func _on_change_difficulty_js (args : Array ) -> void :
272319 ## JS callback for changing difficulty.
273320 ##
274- ## Routes to the signal handler.
321+ ## Routes to the signal handler after performing strict type and
322+ ## bounds validation to prevent engine crashes on malformed JS input.
275323 ##
276324 ## :param args: Array containing the value (from JS).
277325 ## :type args: Array
278326 ## :rtype: void
279- if args . is_empty ():
280- Globals . log_message (
281- "JS difficulty callback received empty args—skipping." , Globals . LogLevel . WARNING
282- )
327+
328+ var potential_value : Variant = _extract_js_difficulty ( args )
329+
330+ if potential_value == null :
283331 return
284332
285- var first_arg : Variant = args [ 0 ]
333+ # GS-JS-12/15/22: Validate that the extracted value is a convertible type
286334 if (
287- first_arg is not JavaScriptObject
288- and typeof (first_arg ) != TYPE_ARRAY
289- and first_arg .size () == 0
290- and first_arg .is_empty ()
335+ typeof (potential_value ) != TYPE_INT
336+ and typeof (potential_value ) != TYPE_FLOAT
337+ and typeof (potential_value ) != TYPE_STRING
291338 ):
292339 Globals .log_message (
293- (
294- "JS difficulty callback received invalid first arg (not a non-empty array): "
295- + str (args )
296- ),
340+ "JS difficulty callback received non-convertible value: " + str (potential_value ),
297341 Globals .LogLevel .WARNING
298342 )
299343 return
300344
301- var potential_value : Variant = first_arg [0 ]
302- if (
303- typeof (potential_value ) != TYPE_INT
304- and typeof (potential_value ) != TYPE_FLOAT
305- and typeof (potential_value ) != TYPE_STRING
306- ):
345+ # GS-JS-03: Coerce to float (e.g., "1.5" becomes 1.5)
346+ # FIX: Ensure strings are numeric before conversion to prevent 0.0/clamping reset
347+ if typeof (potential_value ) == TYPE_STRING and not potential_value .is_valid_float ():
307348 Globals .log_message (
308- "JS difficulty callback received non-convertible value : " + str (args ),
349+ "JS difficulty callback: Rejected non-numeric string : " + str (potential_value ),
309350 Globals .LogLevel .WARNING
310351 )
311352 return
312353
313354 var value : float = float (potential_value )
355+
356+ # GS-JS-30: Guard against missing UI nodes during callback
357+ if not is_instance_valid (difficulty_slider ):
358+ Globals .log_message (
359+ "JS difficulty callback: Slider node is invalid/freed." , Globals .LogLevel .WARNING
360+ )
361+
362+ # FIX: Safely check for Globals and Settings before falling back
363+ var settings_res := Globals .settings if is_instance_valid (Globals ) else null
364+ if is_instance_valid (settings_res ):
365+ settings_res .difficulty = value # Update resource even if UI is gone
366+ return
367+
368+ # GS-JS-04/05: Validate bounds against the UI constraints
314369 if value < difficulty_slider .min_value or value > difficulty_slider .max_value :
315370 Globals .log_message (
316- (
317- "JS difficulty callback received out-of-bounds value: "
318- + str (value )
319- + " (args: "
320- + str (args )
321- + ")"
322- ),
371+ "JS difficulty callback received out-of-bounds value: " + str (value ),
323372 Globals .LogLevel .WARNING
324373 )
325- return
326374
327375 Globals .log_message (
328376 "JS difficulty callback called with valid value: " + str (value ), Globals .LogLevel .DEBUG
329377 )
378+
379+ # Pass the validated value to the standard handler
330380 _on_difficulty_value_changed (value )
331381
332382
383+ ## GS-JS: Helper to extract a potential value from diverse JS bridge payloads.
384+ ## Isolates branching logic for standard Arrays, JavaScriptObjects, and scalars.
385+ func _extract_js_difficulty (args : Array ) -> Variant :
386+ # GS-JS-10: Guard against entirely empty arguments from the bridge
387+ if args .is_empty ():
388+ Globals .log_message (
389+ "JS difficulty callback received empty args—skipping." , Globals .LogLevel .WARNING
390+ )
391+ return null
392+
393+ var first_arg : Variant = args [0 ]
394+
395+ # GS-JS-20/21: Branch logic to handle TYPE_ARRAY and JavaScriptObject separately
396+ if typeof (first_arg ) == TYPE_ARRAY :
397+ # Safe to use .size() and indexing on standard GDScript Arrays
398+ if first_arg .size () > 0 :
399+ return first_arg [0 ]
400+
401+ Globals .log_message ("JS callback: Array is empty." , Globals .LogLevel .WARNING )
402+ return null
403+
404+ if first_arg is JavaScriptObject :
405+ # BUG RISK FIX: Validate the 'length' property exists and is numeric
406+ # before treating the object as an array.
407+ # Note: Must use dot notation, as .get() attempts to call a JS method.
408+ var js_length : Variant = first_arg .length
409+
410+ if js_length != null and typeof (js_length ) in [TYPE_INT , TYPE_FLOAT ] and js_length > 0 :
411+ # JS-FIX: If we receive a JS Object (like from Playwright),
412+ # we must index it to get the raw value before the type check.
413+ return first_arg [0 ]
414+
415+ # It is a generic JS object or a non-array; treat as a scalar reference
416+ return first_arg
417+
418+ # Handle scalar values (e.g., [1.5]) directly
419+ return first_arg
420+
421+
333422## Grabs initial focus on the difficulty slider using the global helper.
334423## Ensures the slider is focused when the menu opens.
335424## Falls back to other controls if needed.
0 commit comments