@@ -177,19 +177,29 @@ func add_property_track(params: Dictionary) -> Dictionary:
177177 return anim_resolved
178178 var anim : Animation = anim_resolved .animation
179179
180- # Validate keyframe structure before mutating.
180+ # Validate + pre-coerce keyframes before mutating. Coercion errors
181+ # surface as INVALID_PARAMS rather than silently inserting garbage keys.
182+ var coerced_keyframes : Array = []
181183 for kf in keyframes :
182184 if typeof (kf ) != TYPE_DICTIONARY :
183185 return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , "Each keyframe must be a dictionary" )
184186 if not "time" in kf :
185187 return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , "Each keyframe must have a 'time' field" )
186188 if not "value" in kf :
187189 return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , "Each keyframe must have a 'value' field" )
190+ var coerce_result := _coerce_value_for_track (kf .get ("value" ), track_path , player )
191+ if coerce_result .has ("error" ):
192+ return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , coerce_result .error )
193+ coerced_keyframes .append ({
194+ "time" : kf .get ("time" ),
195+ "value" : coerce_result .ok ,
196+ "transition" : kf .get ("transition" , "linear" ),
197+ })
188198
189199 var baseline := anim .get_track_count ()
190200
191201 _undo_redo .create_action ("MCP: Add property track %s to %s " % [track_path , anim_name ])
192- _undo_redo .add_do_method (self , "_do_add_property_track" , anim , track_path , interp_str , keyframes , player )
202+ _undo_redo .add_do_method (self , "_do_add_property_track" , anim , track_path , interp_str , coerced_keyframes )
193203 _undo_redo .add_undo_method (anim , "remove_track" , baseline )
194204 _undo_redo .commit_action ()
195205
@@ -206,22 +216,23 @@ func add_property_track(params: Dictionary) -> Dictionary:
206216 }
207217
208218
219+ ## Insert a pre-coerced track into the animation. Callers must coerce
220+ ## values against the target property before calling this (see
221+ ## _coerce_value_for_track) — this method runs inside the undo do-method
222+ ## path where error propagation isn't possible.
209223func _do_add_property_track (
210224 anim : Animation ,
211225 track_path : String ,
212226 interp_str : String ,
213227 keyframes : Array ,
214- player : AnimationPlayer ,
215228) -> void :
216229 var idx := anim .add_track (Animation .TYPE_VALUE )
217230 anim .track_set_path (idx , NodePath (track_path))
218231 anim .track_set_interpolation_type (idx , _INTERP_MODES .get (interp_str , Animation .INTERPOLATION_LINEAR ))
219232 for kf in keyframes :
220233 var t : float = float (kf .get ("time" , 0.0 ))
221- var raw_value = kf .get ("value" )
222234 var trans : float = _parse_transition (kf .get ("transition" , "linear" ))
223- var coerced = _coerce_value_for_track (raw_value , track_path , player )
224- anim .track_insert_key (idx , t , coerced , trans )
235+ anim .track_insert_key (idx , t , kf .get ("value" ), trans )
225236
226237
227238# ============================================================================
@@ -554,30 +565,37 @@ func create_simple(params: Dictionary) -> Dictionary:
554565 if computed_length <= 0.0 :
555566 computed_length = 1.0
556567
557- # Build the animation fully in memory before touching the undo stack.
558- var anim := Animation .new ()
559- anim .length = computed_length
560- anim .loop_mode = _LOOP_MODES [loop_mode_str ]
561-
568+ # Pre-coerce all tween values before touching the anim — coercion errors
569+ # surface as INVALID_PARAMS, not silent garbage keyframes.
570+ var per_track_keyframes : Array = []
562571 for spec in tweens :
563572 var target : String = str (spec .get ("target" , "" ))
564573 var property : String = str (spec .get ("property" , "" ))
565- var from_val = spec .get ("from" )
566- var to_val = spec .get ("to" )
574+ var track_path : String = target + ":" + property
567575 var duration : float = float (spec .get ("duration" , 1.0 ))
568576 var delay : float = float (spec .get ("delay" , 0.0 ))
569577 var trans_str = spec .get ("transition" , "linear" )
570- var trans : float = _parse_transition (trans_str )
578+ var from_result := _coerce_value_for_track (spec .get ("from" ), track_path , player )
579+ if from_result .has ("error" ):
580+ return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , "tween '%s ': %s " % [track_path , from_result .error ])
581+ var to_result := _coerce_value_for_track (spec .get ("to" ), track_path , player )
582+ if to_result .has ("error" ):
583+ return McpErrorCodes .make (McpErrorCodes .INVALID_PARAMS , "tween '%s ': %s " % [track_path , to_result .error ])
584+ per_track_keyframes .append ({
585+ "track_path" : track_path ,
586+ "keyframes" : [
587+ {"time" : delay , "value" : from_result .ok , "transition" : trans_str },
588+ {"time" : delay + duration , "value" : to_result .ok , "transition" : trans_str },
589+ ],
590+ })
571591
572- var track_path : String = target + ":" + property
573- var idx := anim . add_track ( Animation .TYPE_VALUE )
574- anim .track_set_path ( idx , NodePath (track_path))
575- anim .track_set_interpolation_type ( idx , Animation . INTERPOLATION_LINEAR )
592+ # Build the animation fully in memory before touching the undo stack.
593+ var anim := Animation .new ( )
594+ anim .length = computed_length
595+ anim .loop_mode = _LOOP_MODES [ loop_mode_str ]
576596
577- var coerced_from = _coerce_value_for_track (from_val , track_path , player )
578- var coerced_to = _coerce_value_for_track (to_val , track_path , player )
579- anim .track_insert_key (idx , delay , coerced_from , trans )
580- anim .track_insert_key (idx , delay + duration , coerced_to , trans )
597+ for entry in per_track_keyframes :
598+ _do_add_property_track (anim , entry .track_path , "linear" , entry .keyframes )
581599
582600 # One atomic undo action.
583601 _undo_redo .create_action ("MCP: Create animation %s (%d tracks)" % [anim_name , anim .get_track_count ()])
@@ -675,18 +693,19 @@ func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionar
675693# ============================================================================
676694
677695## Coerce a JSON value to match the expected Godot type for the given
678- ## track_path. Resolves the target node and property type via reflection.
679- ## Falls back to raw value if the target cannot be found at author time.
680- static func _coerce_value_for_track (value : Variant , track_path : String , player : AnimationPlayer ) -> Variant :
681- # Parse track_path "NodeName:property" or ".:property"
696+ ## track_path. Returns {"ok": value} or {"error": msg}.
697+ ## Passes the raw value through when the target node isn't in the scene
698+ ## yet (authoring-time path). Errors when the target exists but the
699+ ## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
700+ ## clearly fails — better to reject than silently store garbage.
701+ static func _coerce_value_for_track (value : Variant , track_path : String , player : AnimationPlayer ) -> Dictionary :
682702 var colon := track_path .rfind (":" )
683703 if colon < 0 :
684- return value
704+ return { "ok" : value }
685705
686706 var node_part := track_path .substr (0 , colon )
687707 var prop_part := track_path .substr (colon + 1 )
688708
689- # Resolve node relative to AnimationPlayer's root (defaults to parent).
690709 var root_node : Node = null
691710 if player .is_inside_tree ():
692711 var rn := player .root_node
@@ -695,56 +714,62 @@ static func _coerce_value_for_track(value: Variant, track_path: String, player:
695714 if root_node == null :
696715 root_node = player .get_parent ()
697716 if root_node == null :
698- return value
717+ return { "ok" : value }
699718
700719 var target : Node = root_node .get_node_or_null (node_part )
701720 if target == null :
702- return value # Target not in scene yet; pass through raw value.
721+ return { "ok" : value }
703722
704- # Inspect target property type.
705723 for p in target .get_property_list ():
706724 if p .name == prop_part :
707- return _coerce_for_type (value , p .get ("type" , TYPE_NIL ))
725+ return _coerce_for_type (value , p .get ("type" , TYPE_NIL ), prop_part )
708726
709- return value
727+ # Property not found on current target — pass through. The caller may
728+ # plan to retarget the AnimationPlayer (set root_node) before playback.
729+ return {"ok" : value }
710730
711731
712- ## Coerce a single value to the given Godot variant type.
713- static func _coerce_for_type (value : Variant , prop_type : int ) -> Variant :
732+ ## Coerce a single value to the given Godot variant type. Returns
733+ ## {"ok": coerced} or {"error": msg}. Unknown types pass through.
734+ static func _coerce_for_type (value : Variant , prop_type : int , prop_name : String ) -> Dictionary :
714735 match prop_type :
715736 TYPE_COLOR :
716737 if value is Color :
717- return value
738+ return { "ok" : value }
718739 if value is String :
719- var a := Color .from_string (value , Color (0 , 0 , 0 , 0 ))
720- var b := Color .from_string (value , Color (1 , 1 , 1 , 1 ))
740+ var s := value as String
741+ var a := Color .from_string (s , Color (0 , 0 , 0 , 0 ))
742+ var b := Color .from_string (s , Color (1 , 1 , 1 , 1 ))
721743 if a == b :
722- return a
723- return value # Unparseable — pass through, fail at play time.
744+ return { "ok" : a }
745+ return { "error" : "Cannot parse ' %s ' as Color for property ' %s '" % [ s , prop_name ]}
724746 if value is Dictionary and value .has ("r" ) and value .has ("g" ) and value .has ("b" ):
725- return Color (float (value .r ), float (value .g ), float (value .b ), float (value .get ("a" , 1.0 )))
747+ return {"ok" : Color (float (value .r ), float (value .g ), float (value .b ), float (value .get ("a" , 1.0 )))}
748+ return {"error" : "Cannot coerce value to Color for property '%s ' (expected string, {r,g,b}, or Color)" % prop_name }
726749 TYPE_VECTOR2 :
727750 if value is Vector2 :
728- return value
751+ return { "ok" : value }
729752 if value is Dictionary and value .has ("x" ) and value .has ("y" ):
730- return Vector2 (float (value .x ), float (value .y ))
753+ return { "ok" : Vector2 (float (value .x ), float (value .y ))}
731754 if value is Array and value .size () >= 2 :
732- return Vector2 (float (value [0 ]), float (value [1 ]))
755+ return {"ok" : Vector2 (float (value [0 ]), float (value [1 ]))}
756+ return {"error" : "Cannot coerce value to Vector2 for property '%s ' (expected {x,y}, [x,y], or Vector2)" % prop_name }
733757 TYPE_VECTOR3 :
734758 if value is Vector3 :
735- return value
759+ return { "ok" : value }
736760 if value is Dictionary and value .has ("x" ) and value .has ("y" ) and value .has ("z" ):
737- return Vector3 (float (value .x ), float (value .y ), float (value .z ))
761+ return {"ok" : Vector3 (float (value .x ), float (value .y ), float (value .z ))}
762+ return {"error" : "Cannot coerce value to Vector3 for property '%s ' (expected {x,y,z} or Vector3)" % prop_name }
738763 TYPE_FLOAT :
739764 if value is int or value is float :
740- return float (value )
765+ return { "ok" : float (value )}
741766 TYPE_INT :
742767 if value is float or value is int :
743- return int (value )
768+ return { "ok" : int (value )}
744769 TYPE_BOOL :
745- if value is int or value is float :
746- return bool (value )
747- return value
770+ if value is int or value is float or value is bool :
771+ return { "ok" : bool (value )}
772+ return { "ok" : value }
748773
749774
750775# ============================================================================
0 commit comments