Repository: endlessm/threadbare
Pull request #238: Animated sprites with configurable textures
Author: login: JuanFdS
Created: 2025-04-17T14:11:27Z | Merged: 2025-05-06T13:41:52Z

## Proposed workflow for learner making their own spritesheet

There are actually 2 valid ways of creating a new spritesheet:
- Duplicating it from the editor's filesystem.
- In the inspector of a selected node that exposes a SpriteFrames, click _Make Unique_.


Then, once the new spritesheet is created, it's just either dragging a texture to the one that the user wants to swap.


https://github.com/user-attachments/assets/732af1f1-a11d-4dd1-9340-da68926a93a5





Diff:
diff --git a/REUSE.toml b/REUSE.toml
index 99dd84d897..1c6783a32b 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -14,6 +14,7 @@ SPDX-License-Identifier = "CC0-1.0"
 # when saving changes to these files.
 [[annotations]]
 path = [
+    "addons/sprite_frames_exported_textures/**",
     "scenes/*.tscn",
     "scenes/**/*.tscn",
     "scenes/**/*.tres",
diff --git a/addons/sprite_frames_exported_textures/plugin.cfg b/addons/sprite_frames_exported_textures/plugin.cfg
new file mode 100644
index 0000000000..37dfea5620
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="SpriteFrames exported textures"
+description="A plugin that exposes the textures used in a SpriteFrames resource and makes it possible to change them without manually editing the animations."
+author="Threadbare Authors"
+version="0.1.0"
+script="sprite_frames_exported_textures.gd"
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd b/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd
new file mode 100644
index 0000000000..4c2f824588
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd
@@ -0,0 +1,154 @@
+extends EditorProperty
+
+const SpriteFramesHelper = preload(
+	"res://addons/sprite_frames_exported_textures/sprite_frames_helper.gd"
+)
+
+var texture: Texture2D:
+	set(new_value):
+		texture = new_value
+		if texture_preview:
+			texture_preview.texture = texture
+		if texture_picker:
+			texture_picker.edited_resource = texture
+		label = texture.resource_path.get_file()
+var sprite_frames: SpriteFrames
+var texture_picker: EditorResourcePicker
+var texture_preview: TextureRect
+var editor_undo_redo_manager: EditorUndoRedoManager
+
+
+func _init(the_texture: Texture2D, the_sprite_frames: SpriteFrames) -> void:
+	texture = the_texture
+
+	sprite_frames = the_sprite_frames
+
+	texture_picker = EditorResourcePicker.new()
+	texture_picker.edited_resource = texture
+	texture_picker.base_type = "Texture2D"
+
+	texture_preview = TextureRect.new()
+	texture_preview.texture = texture
+	texture_preview.expand_mode = TextureRect.EXPAND_FIT_HEIGHT_PROPORTIONAL
+	texture_preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+	texture_preview.visible = false
+
+	editor_undo_redo_manager = EditorPlugin.new().get_undo_redo()
+
+	add_child(texture_picker)
+	add_child(texture_preview)
+
+	texture_picker.resource_changed.connect(self.on_resource_changed)
+
+
+func _ready() -> void:
+	focus_mode = Control.FOCUS_ALL
+	get_viewport().gui_focus_changed.connect(self.on_gui_focus_changed)
+	label = texture.resource_path.get_file()
+
+
+func on_gui_focus_changed(new_focus_owner: Node):
+	if self.is_ancestor_of(new_focus_owner) or self == new_focus_owner:
+		select()
+		texture_preview.visible = true
+		set_bottom_editor(texture_preview)
+	else:
+		texture_preview.visible = false
+		set_bottom_editor(null)
+
+
+func on_resource_changed(new_texture: Texture2D) -> void:
+	select()
+
+	if texture == new_texture:
+		return
+
+	if not new_texture:
+		_push_error_toast(
+			(
+				"Texture %s cannot be cleared from the inspector because that would break the animations."
+				% texture.resource_path.get_file()
+			)
+		)
+		texture_picker.edited_resource = texture
+		return
+
+	if new_texture and texture.get_size() != new_texture.get_size():
+		_push_error_toast(
+			(
+				"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+				% [
+					new_texture.get_width(),
+					new_texture.get_height(),
+					texture.get_width(),
+					texture.get_height(),
+				]
+			)
+		)
+		texture_picker.edited_resource = texture
+		return
+
+	if new_texture in SpriteFramesHelper.base_textures_used(sprite_frames):
+		_push_error_toast(
+			(
+				"Texture %s was already being used in the SpriteFrames"
+				% new_texture.resource_path.get_file()
+			)
+		)
+		texture_picker.edited_resource = texture
+		return
+
+	editor_undo_redo_manager.create_action(
+		"Replace texture", UndoRedo.MergeMode.MERGE_DISABLE, sprite_frames
+	)
+	editor_undo_redo_manager.add_do_method(
+		self, "replace_texture", texture, new_texture, sprite_frames
+	)
+	editor_undo_redo_manager.add_do_property(self, "texture", new_texture)
+	editor_undo_redo_manager.add_undo_method(
+		self, "replace_texture", new_texture, texture, sprite_frames
+	)
+	editor_undo_redo_manager.add_undo_property(self, "texture", texture)
+	editor_undo_redo_manager.commit_action()
+
+
+func replace_texture(texture, new_texture, sprite_frames) -> void:
+	SpriteFramesHelper.replace_texture(texture, new_texture, sprite_frames)
+	_push_notificiation_toast(
+		(
+			"Texture %s successfully changed for %s"
+			% [texture.resource_path.get_file(), new_texture.resource_path.get_file()]
+		)
+	)
+	refresh_sprite_frames_editor()
+
+
+## This is a bit of a hack that forces to redraw the SpriteFramesEditor, so the
+## changes can in the animation can be seen immediately.
+func refresh_sprite_frames_editor() -> void:
+	var sprite_frames_editor = (
+		EditorInterface
+		. get_base_control()
+		. find_children("", "SpriteFramesEditor", true, false)
+		. front()
+	)
+	if not sprite_frames_editor:
+		return
+
+	var animations_tree: Tree = sprite_frames_editor.find_children("", "Tree", true, false).front()
+	if not animations_tree:
+		return
+
+	var tree_item: TreeItem = animations_tree.get_selected()
+	if not tree_item:
+		return
+
+	tree_item.select(0)
+
+
+func _push_error_toast(message: String) -> void:
+	EditorInterface.get_editor_toaster().push_toast(message, EditorToaster.SEVERITY_ERROR)
+
+
+func _push_notificiation_toast(message: String) -> void:
+	EditorInterface.get_editor_toaster().push_toast(message, EditorToaster.SEVERITY_INFO)
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd.uid b/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd.uid
new file mode 100644
index 0000000000..91bcb73e4d
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd.uid
@@ -0,0 +1 @@
+uid://bvdul7xr0hl6a
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd b/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd
new file mode 100644
index 0000000000..822601b6cc
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd
@@ -0,0 +1,19 @@
+@tool
+extends EditorPlugin
+
+const SpriteFramesInspectorPlugin = preload(
+	"res://addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd"
+)
+
+var sprite_frames_inspector_plugin: EditorInspectorPlugin
+
+
+func _enter_tree() -> void:
+	sprite_frames_inspector_plugin = SpriteFramesInspectorPlugin.new()
+	add_inspector_plugin(sprite_frames_inspector_plugin)
+
+
+func _exit_tree() -> void:
+	if sprite_frames_inspector_plugin:
+		remove_inspector_plugin(sprite_frames_inspector_plugin)
+		sprite_frames_inspector_plugin = null
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd.uid b/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd.uid
new file mode 100644
index 0000000000..6a744bc59f
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_exported_textures.gd.uid
@@ -0,0 +1 @@
+uid://lkyr2whx152c
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_helper.gd b/addons/sprite_frames_exported_textures/sprite_frames_helper.gd
new file mode 100644
index 0000000000..189975a7de
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_helper.gd
@@ -0,0 +1,71 @@
+extends Node
+
+
+static func base_textures_used(sprite_frames: SpriteFrames) -> Array:
+	var textures = []
+	for animation_name in sprite_frames.get_animation_names():
+		var sprite_frame_count = sprite_frames.get_frame_count(animation_name)
+
+		for sprite_frame_idx in sprite_frame_count:
+			var texture: Texture2D = sprite_frames.get_frame_texture(
+				animation_name, sprite_frame_idx
+			)
+			var base_texture: Texture2D = _get_base_texture(sprite_frames, texture)
+
+			if base_texture and not base_texture in textures:
+				textures.push_back(base_texture)
+
+	return textures
+
+
+static func _get_base_texture(frames: SpriteFrames, texture: Texture2D) -> Texture2D:
+	var is_included_in_other_resource: bool = not FileAccess.file_exists(texture.resource_path)
+
+	if not is_included_in_other_resource:
+		return texture
+
+	if texture is AtlasTexture:
+		return _get_base_texture(frames, texture.atlas)
+
+	return null
+
+
+static func replace_texture(
+	old_base_texture: Texture2D, new_texture: Texture2D, sprite_frames: SpriteFrames
+) -> void:
+	for animation_name in sprite_frames.get_animation_names():
+		var sprite_frame_count = sprite_frames.get_frame_count(animation_name)
+
+		for sprite_frame_idx in sprite_frame_count:
+			var texture: Texture2D = sprite_frames.get_frame_texture(
+				animation_name, sprite_frame_idx
+			)
+			if old_base_texture == _get_base_texture(sprite_frames, texture):
+				_replace_base_texture(
+					sprite_frames, animation_name, sprite_frame_idx, texture, new_texture
+				)
+
+
+static func _replace_base_texture(
+	frames: SpriteFrames,
+	anim_name: StringName,
+	sprite_frame_idx: int,
+	old_texture: Texture2D,
+	new_texture: Texture2D
+) -> void:
+	var is_included_in_other_resource: bool = not FileAccess.file_exists(old_texture.resource_path)
+	var old_base_texture = _get_base_texture(frames, old_texture)
+
+	if not is_included_in_other_resource:
+		var frame_duration = frames.get_frame_duration(anim_name, sprite_frame_idx)
+		frames.set_frame(anim_name, sprite_frame_idx, new_texture, frame_duration)
+		return
+
+	if old_texture is AtlasTexture:
+		var new_atlas_texture = old_texture.duplicate(true)
+		new_atlas_texture.atlas = new_texture
+		var frame_duration = frames.get_frame_duration(anim_name, sprite_frame_idx)
+		frames.set_frame(anim_name, sprite_frame_idx, new_atlas_texture, frame_duration)
+
+	if frames.resource_path:
+		ResourceSaver.save(frames)
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_helper.gd.uid b/addons/sprite_frames_exported_textures/sprite_frames_helper.gd.uid
new file mode 100644
index 0000000000..3079ddf94d
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_helper.gd.uid
@@ -0,0 +1 @@
+uid://ccmly7jn1b1od
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd b/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd
new file mode 100644
index 0000000000..b347bad23a
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd
@@ -0,0 +1,22 @@
+extends EditorInspectorPlugin
+
+const SpriteFramesEditorProperty = preload(
+	"res://addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd"
+)
+const SpriteFramesHelper = preload(
+	"res://addons/sprite_frames_exported_textures/sprite_frames_helper.gd"
+)
+
+
+func _can_handle(object: Object) -> bool:
+	return object is SpriteFrames
+
+
+func _parse_begin(object: Object) -> void:
+	if object is SpriteFrames:
+		var sprite_frames: SpriteFrames = object
+		for base_texture: Texture2D in SpriteFramesHelper.base_textures_used(sprite_frames):
+			var sprite_frames_editor_property = SpriteFramesEditorProperty.new(
+				base_texture, sprite_frames
+			)
+			add_property_editor(base_texture.resource_path, sprite_frames_editor_property, true)
diff --git a/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd.uid b/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd.uid
new file mode 100644
index 0000000000..7b5f5ae429
--- /dev/null
+++ b/addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd.uid
@@ -0,0 +1 @@
+uid://csc7x8sjq0tk8
diff --git a/project.godot b/project.godot
index 2c77f8bf9c..32b8cc70d3 100644
--- a/project.godot
+++ b/project.godot
@@ -48,7 +48,7 @@ window/stretch/mode="viewport"
 
 [editor_plugins]
 
-enabled=PackedStringArray("res://addons/dialogue_manager/plugin.cfg")
+enabled=PackedStringArray("res://addons/dialogue_manager/plugin.cfg", "res://addons/sprite_frames_exported_textures/plugin.cfg")
 
 [global_group]
 


Reviews:
user: login: pablitar
body: I think this is great. We should merge it and test it thoroughly with real world cases!

Feel free to address the comments I made!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T23:37:42Z
user: login: manuq
body: @JuanFdS this is working like a charm! I think it's a great addition that works around a current limitation of the Godot editor: unlike TileSet, the SpriteFrames doesn't expose the used textures. Also requiring that the replaced texture is of the same size is a nice touch.

Can you make it work with SpriteFrames that are saved in the scene too? At first glance I don't see what is limiting the addon to only work with SpriteFrames resources saved in its own separate files.

Can you run the linter and other checks in it? In `.pre-commit-config.yaml` we have the checks disabled for all `addons/` because the only addon is `dialogue_manager` which we copy and patch. I have opened https://github.com/endlessm/threadbare/pull/464 for this. If I add that commit on top of this PR and then run `pre-commit run --all-files` I get a number of issues.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-05T17:35:44Z
user: login: JuanFdS
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:23:55Z

Review comments:
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: Is this a debug button that? Maybe we want to remove this?
created_at: 2025-04-21T15:55:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
+
 @onready var player: Player = owner
 
 
+func _get_property_list() -> Array:
+	var textures_category = {
+		"name": "Textures",
+		"usage": PROPERTY_USAGE_GROUP,
+		"type": TYPE_NIL,
+		"hint_string": "_textures"
+	}
+	var textures_properties = base_textures_used().map(
+		func(texture: Texture2D):
+			return {
+				"name": "_textures_" + texture.resource_path.get_file().get_basename(),
+				"type": TYPE_OBJECT,
+				"hint_string": "Texture2D"
+			}
+	)
+	return [textures_category] + textures_properties
+
+
+func _get(property: StringName):
+	if property.begins_with("_textures_"):
+		var texture_filename = property.trim_prefix("_textures_")
+		for texture in base_textures_used():
+			if texture.resource_path.get_file().get_basename() == texture_filename:
+				return texture
+	return null
+
+
+func _set(property: StringName, value: Variant):
+	if property.begins_with("_textures_"):
+		var old_base_texture: Texture2D = get(property)
+		var new_texture := value as Texture2D
+		if old_base_texture and old_base_texture.get_size() != new_texture.get_size():
+			if old_base_texture.get_size() != new_texture.get_size():
+				var error_message = (
+					"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+					% [
+						#new_texture.resource_path,
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: Mabye remove commented code?
created_at: 2025-04-21T15:56:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
+
 @onready var player: Player = owner
 
 
+func _get_property_list() -> Array:
+	var textures_category = {
+		"name": "Textures",
+		"usage": PROPERTY_USAGE_GROUP,
+		"type": TYPE_NIL,
+		"hint_string": "_textures"
+	}
+	var textures_properties = base_textures_used().map(
+		func(texture: Texture2D):
+			return {
+				"name": "_textures_" + texture.resource_path.get_file().get_basename(),
+				"type": TYPE_OBJECT,
+				"hint_string": "Texture2D"
+			}
+	)
+	return [textures_category] + textures_properties
+
+
+func _get(property: StringName):
+	if property.begins_with("_textures_"):
+		var texture_filename = property.trim_prefix("_textures_")
+		for texture in base_textures_used():
+			if texture.resource_path.get_file().get_basename() == texture_filename:
+				return texture
+	return null
+
+
+func _set(property: StringName, value: Variant):
+	if property.begins_with("_textures_"):
+		var old_base_texture: Texture2D = get(property)
+		var new_texture := value as Texture2D
+		if old_base_texture and old_base_texture.get_size() != new_texture.get_size():
+			if old_base_texture.get_size() != new_texture.get_size():
+				var error_message = (
+					"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+					% [
+						#new_texture.resource_path,
+						new_texture.get_width(),
+						new_texture.get_height(),
+						#old_base_texture.resource_path,
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: Maybe remove commented code?
created_at: 2025-04-21T15:56:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
+
 @onready var player: Player = owner
 
 
+func _get_property_list() -> Array:
+	var textures_category = {
+		"name": "Textures",
+		"usage": PROPERTY_USAGE_GROUP,
+		"type": TYPE_NIL,
+		"hint_string": "_textures"
+	}
+	var textures_properties = base_textures_used().map(
+		func(texture: Texture2D):
+			return {
+				"name": "_textures_" + texture.resource_path.get_file().get_basename(),
+				"type": TYPE_OBJECT,
+				"hint_string": "Texture2D"
+			}
+	)
+	return [textures_category] + textures_properties
+
+
+func _get(property: StringName):
+	if property.begins_with("_textures_"):
+		var texture_filename = property.trim_prefix("_textures_")
+		for texture in base_textures_used():
+			if texture.resource_path.get_file().get_basename() == texture_filename:
+				return texture
+	return null
+
+
+func _set(property: StringName, value: Variant):
+	if property.begins_with("_textures_"):
+		var old_base_texture: Texture2D = get(property)
+		var new_texture := value as Texture2D
+		if old_base_texture and old_base_texture.get_size() != new_texture.get_size():
+			if old_base_texture.get_size() != new_texture.get_size():
+				var error_message = (
+					"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+					% [
+						#new_texture.resource_path,
+						new_texture.get_width(),
+						new_texture.get_height(),
+						#old_base_texture.resource_path,
+						old_base_texture.get_width(),
+						old_base_texture.get_height(),
+					]
+				)
+				EditorInterface.get_editor_toaster().push_toast(
+					error_message, EditorToaster.SEVERITY_ERROR
+				)
+				#push_error(error_message)
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: Maybe remove commented code?
created_at: 2025-04-21T15:56:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
+
 @onready var player: Player = owner
 
 
+func _get_property_list() -> Array:
+	var textures_category = {
+		"name": "Textures",
+		"usage": PROPERTY_USAGE_GROUP,
+		"type": TYPE_NIL,
+		"hint_string": "_textures"
+	}
+	var textures_properties = base_textures_used().map(
+		func(texture: Texture2D):
+			return {
+				"name": "_textures_" + texture.resource_path.get_file().get_basename(),
+				"type": TYPE_OBJECT,
+				"hint_string": "Texture2D"
+			}
+	)
+	return [textures_category] + textures_properties
+
+
+func _get(property: StringName):
+	if property.begins_with("_textures_"):
+		var texture_filename = property.trim_prefix("_textures_")
+		for texture in base_textures_used():
+			if texture.resource_path.get_file().get_basename() == texture_filename:
+				return texture
+	return null
+
+
+func _set(property: StringName, value: Variant):
+	if property.begins_with("_textures_"):
+		var old_base_texture: Texture2D = get(property)
+		var new_texture := value as Texture2D
+		if old_base_texture and old_base_texture.get_size() != new_texture.get_size():
+			if old_base_texture.get_size() != new_texture.get_size():
+				var error_message = (
+					"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+					% [
+						#new_texture.resource_path,
+						new_texture.get_width(),
+						new_texture.get_height(),
+						#old_base_texture.resource_path,
+						old_base_texture.get_width(),
+						old_base_texture.get_height(),
+					]
+				)
+				EditorInterface.get_editor_toaster().push_toast(
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: This may fail on export?
created_at: 2025-04-21T15:58:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,11 +1,149 @@
 # SPDX-FileCopyrightText: The Threadbare Authors
 # SPDX-License-Identifier: MPL-2.0
+@tool
 extends AnimatedSprite2D
 
+@export_tool_button("Print Textures") var _textures_used = print_base_textures_used
+
 @onready var player: Player = owner
 
 
+func _get_property_list() -> Array:
+	var textures_category = {
+		"name": "Textures",
+		"usage": PROPERTY_USAGE_GROUP,
+		"type": TYPE_NIL,
+		"hint_string": "_textures"
+	}
+	var textures_properties = base_textures_used().map(
+		func(texture: Texture2D):
+			return {
+				"name": "_textures_" + texture.resource_path.get_file().get_basename(),
+				"type": TYPE_OBJECT,
+				"hint_string": "Texture2D"
+			}
+	)
+	return [textures_category] + textures_properties
+
+
+func _get(property: StringName):
+	if property.begins_with("_textures_"):
+		var texture_filename = property.trim_prefix("_textures_")
+		for texture in base_textures_used():
+			if texture.resource_path.get_file().get_basename() == texture_filename:
+				return texture
+	return null
+
+
+func _set(property: StringName, value: Variant):
+	if property.begins_with("_textures_"):
+		var old_base_texture: Texture2D = get(property)
+		var new_texture := value as Texture2D
+		if old_base_texture and old_base_texture.get_size() != new_texture.get_size():
+			if old_base_texture.get_size() != new_texture.get_size():
+				var error_message = (
+					"New texture's size (%dx%d) doesn't match old texture's size (%dx%d)"
+					% [
+						#new_texture.resource_path,
+						new_texture.get_width(),
+						new_texture.get_height(),
+						#old_base_texture.resource_path,
+						old_base_texture.get_width(),
+						old_base_texture.get_height(),
+					]
+				)
+				EditorInterface.get_editor_toaster().push_toast(
+					error_message, EditorToaster.SEVERITY_ERROR
+				)
+				#push_error(error_message)
+				return
+		for animation_name in sprite_frames.get_animation_names():
+			var sprite_frame_count = sprite_frames.get_frame_count(animation_name)
+
+			for sprite_frame_idx in sprite_frame_count:
+				var texture: Texture2D = sprite_frames.get_frame_texture(
+					animation_name, sprite_frame_idx
+				)
+				if old_base_texture == get_base_texture(sprite_frames, texture):
+					replace_base_texture(
+						sprite_frames, animation_name, sprite_frame_idx, texture, value
+					)
+
+		var sprite_frames_editor = (
+			Engine
+			. get_singleton("EditorInterface")
path: scenes/game_elements/characters/player/components/player_sprite.gd
user: login: pablitar
body: Won't this fail on runtime?
created_at: 2025-04-21T15:58:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+extends EditorInspectorPlugin
+
+const SpriteFramesEditorProperty = preload("res://addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd")
+const SpriteFramesHelper = preload("res://addons/sprite_frames_exported_textures/sprite_frames_helper.gd")
+
+func _can_handle(object: Object) -> bool:
+	return object is SpriteFrames
+
+func _parse_begin(object: Object) -> void:
+		if object is SpriteFrames:
+			var sprite_frames: SpriteFrames = object
+			if FileAccess.file_exists(sprite_frames.resource_path):
+				for base_texture: Texture2D in SpriteFramesHelper.base_textures_used(sprite_frames):
+					var sprite_frames_editor_property = SpriteFramesEditorProperty.new(
+						base_texture, sprite_frames
+					)
+					add_property_editor(base_texture.resource_path, sprite_frames_editor_property, true)
+			else:
+				var message_label := Label.new()
+				message_label.autowrap_mode = TextServer.AUTOWRAP_WORD
+				message_label.text = "⚠️ SpriteFrames must be saved to a file in order to edit its textures"
path: addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd
user: login: manuq
body: I don't understand this limitation. Can you explain why the SpriteFrames resource has to be saved to a file?
created_at: 2025-05-05T17:19:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,48 @@
+extends EditorInspectorPlugin
+
+const SpriteFramesEditorProperty = preload("res://addons/sprite_frames_exported_textures/sprite_frames_editor_property.gd")
+const SpriteFramesHelper = preload("res://addons/sprite_frames_exported_textures/sprite_frames_helper.gd")
+
+func _can_handle(object: Object) -> bool:
+	return object is SpriteFrames
+
+func _parse_begin(object: Object) -> void:
+		if object is SpriteFrames:
+			var sprite_frames: SpriteFrames = object
+			if FileAccess.file_exists(sprite_frames.resource_path):
+				for base_texture: Texture2D in SpriteFramesHelper.base_textures_used(sprite_frames):
+					var sprite_frames_editor_property = SpriteFramesEditorProperty.new(
+						base_texture, sprite_frames
+					)
+					add_property_editor(base_texture.resource_path, sprite_frames_editor_property, true)
+			else:
+				var message_label := Label.new()
+				message_label.autowrap_mode = TextServer.AUTOWRAP_WORD
+				message_label.text = "⚠️ SpriteFrames must be saved to a file in order to edit its textures"
path: addons/sprite_frames_exported_textures/sprite_frames_inspector_plugin.gd
user: login: JuanFdS
body: it seems it can!, in a previous version, making the file unique and modifying it was causing issues, but it seems it doesn't now, so this limitation isn't needed, thanks for pointing that out!
created_at: 2025-05-06T13:23:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: manuq
created_at: 2025-05-02T15:21:30Z
body: @JuanFdS did a demo of the current state. It looks very promising!
author_association: COLLABORATOR
user: login: JuanFdS
created_at: 2025-05-02T19:19:46Z
body: It's working but there are still some quirks I'm trying to fix:
- Sometimes, I get this error in the terminal:
![image](https://github.com/user-attachments/assets/600042d5-4937-4062-82c2-143eb8d0c36c)
Which is related with this code:
```gdscript
			EditorInterface.get_inspector().property_edited.connect(func(property_name: StringName):
				object.notify_property_list_changed(),
				CONNECT_ONE_SHOT
			)
```
- If I make a spriteframe unique, save it and then edit its textures, it seems like if the original spriteframes also had its textures modified, but it isn't the case!, if the project is reloaded, this is fixed. It's probably because there's a shared resource internally that is not being persisted (the AtlasTexture maybe?).
author_association: CONTRIBUTOR