Repository: Goutte/godot-addon-animated-shape-2d Branch: main Commit: 4ab90a80b815 Files: 28 Total size: 80.6 KB Directory structure: gitextract_dzyrmz1o/ ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md └── addons/ └── goutte.animated_shape_2d/ ├── README.md ├── animated_shape_2d.gd ├── animated_shape_2d.svg.import ├── editor/ │ ├── icons/ │ │ ├── copy.png.import │ │ ├── edit.png.import │ │ ├── link.png.import │ │ ├── new.png.import │ │ ├── paste.png.import │ │ ├── remove.png.import │ │ ├── shift_left.png.import │ │ ├── shift_right.png.import │ │ ├── zoom_less.png.import │ │ ├── zoom_more.png.import │ │ └── zoom_reset.png.import │ ├── linked_frames_feedback.gd │ ├── shape_frame_editor.gd │ ├── shape_frame_editor.tscn │ ├── shape_frames_bottom_panel_control.gd │ ├── shape_frames_bottom_panel_control.tscn │ └── shape_preview.gd ├── plugin.cfg ├── plugin.gd ├── shape_frame_2d.gd └── shape_frames_2d.gd ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Normalize line endings for all files that Git considers text files. * text=auto eol=lf # Exclude irrelevant files when downloading from the Asset Library. /README.md export-ignore /LICENSE export-ignore /.gitignore export-ignore /.gitattributes export-ignore /addons/goutte.animated_shape_2d/extras export-ignore ================================================ FILE: .gitignore ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Friends of Godette Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Animated Shape 2D Addon for Godot --------------------------------- [![MIT](https://img.shields.io/github/license/Goutte/godot-addon-animated-shape-2d.svg?style=for-the-badge)](https://github.com/Goutte/godot-addon-animated-shape-2d) [![Release](https://img.shields.io/github/release/Goutte/godot-addon-animated-shape-2d.svg?style=for-the-badge)](https://github.com/Goutte/godot-addon-animated-shape-2d/releases) [![FeedStarvingDev](https://img.shields.io/liberapay/patrons/Goutte.svg?style=for-the-badge&logo=liberapay)](https://liberapay.com/Goutte/) A [Godot](https://godotengine.org/) `4.x` addon that adds an `AnimatedShape2D` that can provide a custom shape for each frame of each animation of an `AnimatedSprite2D`. It is useful to make custom hitboxes, hurtboxes, and hardboxes for each pose of your character, if you animated it using `AnimatedSprite2D`. It comes with an Editor GUI to preview and edit your shapes, in the fashion of the `SpriteFrames` bottom panel. ![A screenshot of the GUI showing a custom "Animated Shape" bottom panel in Godot](./addons/goutte.animated_shape_2d/extras/screenshot_01.png) Features -------- - customize a shape for each frame of your animations - configurable fallbacks - editor GUI, updated in real time - supports undo & redo where it matters - extensible Install ------- The installation is as usual, through the `Assets Library` within Godot, look for [_AnimatedShape2D_](https://godotengine.org/asset-library/asset/2484). You can also simply copy the files of this project into yours, it should work. Then, **enable the plugin** in `Scene > Project Settings > Plugins`. Usage ----- Please see the [addons' README](./addons/goutte.animated_shape_2d/README.md). ----- > 🦊 _Feedback and contributions are welcome!_ ================================================ FILE: addons/goutte.animated_shape_2d/README.md ================================================ Animated Shape 2D Addon for Godot --------------------------------- [![MIT](https://img.shields.io/github/license/Goutte/godot-addon-animated-shape-2d.svg?style=for-the-badge)](https://github.com/Goutte/godot-addon-animated-shape-2d) [![Release](https://img.shields.io/github/release/Goutte/godot-addon-animated-shape-2d.svg?style=for-the-badge)](https://github.com/Goutte/godot-addon-animated-shape-2d/releases) [![FeedStarvingDev](https://img.shields.io/liberapay/patrons/Goutte.svg?style=for-the-badge&logo=liberapay)](https://liberapay.com/Goutte/) A [Godot](https://godotengine.org/) `^4.2` addon that adds an `AnimatedShape2D` node that can customize a `CollisionShape2D` for each frame of each animation of an `AnimatedSprite2D`. It is useful to make custom hitboxes, hurtboxes, and hardboxes for each pose of your character, if you animated it using `AnimatedSprite2D`. It comes with an Editor GUI to preview your shapes, in the fashion of the `SpriteFrames` bottom panel. You can also use it to "tag" specific animation frames with custom metadata. Features -------- - customize a shape for each frame of your animations - store metadata for each frame of your animations - configurable fallbacks - editor GUI, updated in real time - copy & pasting, with either shallow of deep copies - supports undo & redo where it matters - dogfed - extensible Install ------- The installation is as usual, through the `Assets Library` within Godot, look for [_AnimatedShape2D_](https://godotengine.org/asset-library/asset/2484). You can also simply copy the files of this project into yours, it should work. Then, enable the plugin in `Scene > Project Settings > Plugins`. Usage ----- 1. Add a `AnimatedShape2D` anywhere in your scene and inspect it. 2. Target a `AnimatedSprite2D` to read frames from. 3. Target a `CollisionShape2D` to write to. 4. Make a new empty `ShapeFrames2D` to store the customization data into. 5. Add shape customizations to specific frames using the bottom panel. 6. Star this repository if you are happy ; share the love! > You can only target one `CollisionShape2D` per `AnimatedShape2D`. > Make one `AnimatedShape2D` per type of box you want to customize. _(hitbox, hurtbox, etc.)_ How it Works ------------ `AnimatedShape2D` stores enough data in a `ShapeFrames2D` resource to fully configure a `CollisionShape2D` for each frame of each animation of an `AnimatedSprite2D`. It listens to the `AnimatedSprite2D` frame|animation changes, and updates its target `CollisionShape2D` accordingly. _That's it._ ----- > 🦊 _Feedback and contributions are welcome!_ > https://github.com/Goutte/godot-addon-animated-shape-2d ================================================ FILE: addons/goutte.animated_shape_2d/animated_shape_2d.gd ================================================ @tool @icon("./animated_shape_2d.svg") extends Node class_name AnimatedShape2D #class_name AnimatedCollisionShape2D #class_name AnimatedSprite2DCollisions #class_name CollisionShape2DFramer ## Customizes a CollisionShape2D for each frame of an AnimatedSprite2D. # Usage: # 1. Add this node anywhere in your scene # 2. Target an input AnimatedSprite2D # 3. Target an output CollisionShape2D # 4. Load or Create a ShapeFrames2D (it's our database) # # Notes: # - You can put this pretty much anywhere you want in your scene. # - This _could_ be a script on a CollisionShape2D, but this way your are free # to have your own script on your collision shape if you want to. # - This is quite experimental ; contributions are welcome. # https://github.com/Goutte/godot-addon-animated-shape-2d ## Animated sprite we're going to watch to figure out which shape we want. ## We're reading the animation name and frame from it. @export var animated_sprite: AnimatedSprite2D ## Target collision shape whose shape we're going to write to. ## We're also going to configure this CollisionShape2D (position, disabled) ## for each frame of the AnimatedSprite2D above. @export var collision_shape: CollisionShape2D ## Shape data for each animation and frame of the animated sprite. ## This holds enough data to configure the collision shape for each frame ## of the animated sprite: shape, position, disabled… @export var shape_frames: ShapeFrames2D ## If [code]true[/code], use the initial shape in the target CollisionShape2D ## as fallback when the shape is not defined in the ShapeFrames2D. ## If [code]false[/code], do not use fallback and therefore disable the shape. ## This has lower priority than use_previous_as_fallback. @export var use_initial_as_fallback := true ## If [code]true[/code], use the previous shape in the target CollisionShape2D ## as fallback when the shape is not defined in the ShapeFrames2D. ## If [code]false[/code], do not use fallback and therefore disable the shape. ## This has higher priority than use_initial_as_fallback. ## This is handy if for example all your frames use the same shape, ## and shapes only change per animation. @export var use_previous_as_fallback := false ## If [code]true[/code], use call_deferred() to set CollisionShape2D properties. @export var use_deferred_calls := true ## Flip horizontally the collision shapes when the animated sprite is flipped, ## by inverting the scale of their parent Area2D. Only works on collision ## shapes that are children of Area2D, to avoid weird behaviors with physics. @export var handle_flip_h := true ## Maximum amount of shape size and position change per physics frame. ## Only used in the [code]INTERPOLATE[/code] mode. @export var interpolation_step := 3.0 enum SHAPE_UPDATE_MODE { ## Update the existing shape resource properties in the CollisionShape2D, ## but only if shape types are compatible. UPDATE, ## Works like [code]UPDATE[/code], but interpolates values instead of setting them. ## This helps when sudden, big changes in a collision shape make the physics ## engine glitch and your character starts clipping through the environment. ## Use with [code]interpolation_step[/code]. INTERPOLATE, ## Always replace the existing shape resource in the CollisionShape2D. ## This may trigger additional [code]entered[/code] signals. REPLACE, } ## How the Shape2D resource of the CollisionShape2D is updated between frames. ## Weird things will happen if you change this at runtime. @export var update_shape_mode := SHAPE_UPDATE_MODE.UPDATE var fallback_shape: Shape2D var fallback_position: Vector2 var fallback_disabled: bool var initial_scale: Vector2 var collision_shape_parent: Node2D var is_tweening_collision_shape_position := false var target_collision_shape_position := Vector2.ZERO var is_tweening_collision_shape_shape := false var target_collision_shape_shape: Shape2D func _ready(): if not Engine.is_editor_hint(): setup() update_shape() else: set_physics_process(false) func _physics_process(_delta: float): if self.is_tweening_collision_shape_position: tween_collision_shape_position() if self.is_tweening_collision_shape_shape: tween_collision_shape_shape() func _get_configuration_warnings() -> PackedStringArray: var warnings := PackedStringArray() if self.animated_sprite == null: warnings.append("This node requires a target AnimatedSprite2D to read frames from.") if self.collision_shape == null: warnings.append("This node requires a target CollisionShape2D to write customizations to.") if self.shape_frames == null: warnings.append("This node requires a ShapeFrames2D to store data. Make a new one?") return warnings func setup(): if self.collision_shape == null: return if self.shape_frames == null: return # We might update the original collision shape's shape, so we duplicate if self.collision_shape.shape: self.fallback_shape = self.collision_shape.shape.duplicate(true) self.fallback_position = self.collision_shape.position self.fallback_disabled = self.collision_shape.disabled self.collision_shape_parent = self.collision_shape.get_parent() if self.collision_shape_parent != null: self.initial_scale = self.collision_shape_parent.scale self.animated_sprite.animation_changed.connect(update_shape) self.animated_sprite.frame_changed.connect(update_shape) set_physics_process(self.update_shape_mode == SHAPE_UPDATE_MODE.INTERPOLATE) func get_current_shape_frame() -> ShapeFrame2D: var animation_name := self.animated_sprite.get_animation() var frame := self.animated_sprite.get_frame() return self.shape_frames.get_shape_frame(animation_name, frame) func update_shape(): if self.shape_frames == null: return var shape_frame := get_current_shape_frame() var shape: Shape2D = null if shape_frame != null: shape = shape_frame.get_shape() var position := Vector2.ZERO var disabled := false if shape_frame != null: position = shape_frame.position disabled = shape_frame.disabled if shape == null and self.use_previous_as_fallback: # Improvement idea: allow flipping in this case as well return if shape == null and self.use_initial_as_fallback: shape = self.fallback_shape position = self.fallback_position disabled = self.fallback_disabled update_collision_shape_shape(shape) update_collision_shape_position(position) update_collision_shape_disabled(disabled) if self.handle_flip_h and is_collision_shape_parent_flippable(): # Improvement idea: flip the CollisionBody2D itself and mirror its x pos if self.animated_sprite.flip_h: self.collision_shape_parent.scale.x = -self.initial_scale.x else: self.collision_shape_parent.scale.x = self.initial_scale.x func update_collision_shape_disabled(disabled: bool): if self.use_deferred_calls: self.collision_shape.set_deferred(&"disabled", disabled) else: self.collision_shape.disabled = disabled func update_collision_shape_position(new_position: Vector2): if new_position == self.collision_shape.position: return if self.update_shape_mode == SHAPE_UPDATE_MODE.INTERPOLATE: self.is_tweening_collision_shape_position = true self.target_collision_shape_position = new_position else: self.collision_shape.position = new_position func update_collision_shape_shape(new_shape: Shape2D): if new_shape == self.collision_shape.shape: return if ( self.update_shape_mode == SHAPE_UPDATE_MODE.INTERPOLATE and self.collision_shape.shape != null and new_shape != null ): if ( (self.collision_shape.shape.get_class() == new_shape.get_class()) ): self.is_tweening_collision_shape_shape = true self.target_collision_shape_shape = new_shape return if ( self.update_shape_mode == SHAPE_UPDATE_MODE.UPDATE and self.collision_shape.shape != null and new_shape != null ): if ( (self.collision_shape.shape is RectangleShape2D) and (new_shape is RectangleShape2D) ): self.collision_shape.shape.size = new_shape.size return if ( (self.collision_shape.shape is CircleShape2D) and (new_shape is CircleShape2D) ): self.collision_shape.shape.radius = new_shape.radius return if ( (self.collision_shape.shape is CapsuleShape2D) and (new_shape is CapsuleShape2D) ): self.collision_shape.shape.height = new_shape.height self.collision_shape.shape.radius = new_shape.radius return if ( (self.collision_shape.shape is SegmentShape2D) and (new_shape is SegmentShape2D) ): self.collision_shape.shape.a = new_shape.a self.collision_shape.shape.b = new_shape.b return if ( (self.collision_shape.shape is WorldBoundaryShape2D) and (new_shape is WorldBoundaryShape2D) ): self.collision_shape.shape.distance = new_shape.distance self.collision_shape.shape.normal = new_shape.normal return # If the update cannot be done, we want a duplicate of the shape # because we might update it later on. if use_deferred_calls: self.collision_shape.set_deferred(&"shape", new_shape.duplicate(true)) else: self.collision_shape.shape = new_shape.duplicate(true) return # Or perhaps just simply REPLACE the shape. # This triggers (possibly unwanted) extra area_entered signals. if use_deferred_calls: self.collision_shape.set_deferred(&"shape", new_shape) else: self.collision_shape.shape = new_shape # Make the shape properties go towards their target, but not by more than # the configured interpolation step, to keep things smooth. # This method is insanely verbose, but not very complicated. # I did not want to use reflection for shorter code but worse perfs. func tween_collision_shape_shape(): if not self.is_tweening_collision_shape_shape: return if ( self.collision_shape.shape == null or self.target_collision_shape_shape == null ): return if ( (self.collision_shape.shape is RectangleShape2D) and (self.target_collision_shape_shape is RectangleShape2D) ): self.collision_shape.shape.size.x += clampf( self.target_collision_shape_shape.size.x - self.collision_shape.shape.size.x, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.shape.size.y += clampf( self.target_collision_shape_shape.size.y - self.collision_shape.shape.size.y, -self.interpolation_step, self.interpolation_step, ) if self.collision_shape.shape.size == self.target_collision_shape_shape.size: self.is_tweening_collision_shape_shape = false return if ( (self.collision_shape.shape is CircleShape2D) and (self.target_collision_shape_shape is CircleShape2D) ): self.collision_shape.shape.radius += clampf( self.target_collision_shape_shape.radius - self.collision_shape.shape.radius, -self.interpolation_step, self.interpolation_step, ) if self.collision_shape.shape.radius == target_collision_shape_shape.radius: self.is_tweening_collision_shape_shape = false return if ( (self.collision_shape.shape is CapsuleShape2D) and (self.target_collision_shape_shape is CapsuleShape2D) ): self.collision_shape.shape.height += clampf( self.target_collision_shape_shape.height - self.collision_shape.shape.height, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.shape.radius += clampf( self.target_collision_shape_shape.radius - self.collision_shape.shape.radius, -self.interpolation_step, self.interpolation_step, ) if ( self.collision_shape.shape.radius == target_collision_shape_shape.radius and self.collision_shape.shape.height == target_collision_shape_shape.height ): self.is_tweening_collision_shape_shape = false return if ( (self.collision_shape.shape is SegmentShape2D) and (self.target_collision_shape_shape is SegmentShape2D) ): self.collision_shape.shape.a.x += clampf( self.target_collision_shape_shape.a.x - self.collision_shape.shape.a.x, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.shape.a.y += clampf( self.target_collision_shape_shape.a.y - self.collision_shape.shape.a.y, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.shape.b.x += clampf( self.target_collision_shape_shape.b.x - self.collision_shape.shape.b.x, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.shape.b.y += clampf( self.target_collision_shape_shape.b.y - self.collision_shape.shape.b.y, -self.interpolation_step, self.interpolation_step, ) if ( self.collision_shape.shape.a == target_collision_shape_shape.a and self.collision_shape.shape.b == target_collision_shape_shape.b ): self.is_tweening_collision_shape_shape = false return # If shape types are incompatible or not supported, cancel the interpolation # and simply replace the shape, with a duplicate because we might update it. self.is_tweening_collision_shape_shape = false self.collision_shape.shape = target_collision_shape_shape.duplicate(true) func tween_collision_shape_position(): if not self.is_tweening_collision_shape_position: return self.collision_shape.position.x += clampf( self.target_collision_shape_position.x - self.collision_shape.position.x, -self.interpolation_step, self.interpolation_step, ) self.collision_shape.position.y += clampf( self.target_collision_shape_position.y - self.collision_shape.position.y, -self.interpolation_step, self.interpolation_step, ) if self.collision_shape.position == self.target_collision_shape_position: self.is_tweening_collision_shape_position = false ## We don't want to flip PhysicsBodies because it creates odd behaviors. ## Override this method if that's what you want for some reason. func is_collision_shape_parent_flippable() -> bool: return ( self.collision_shape_parent != null and not (self.collision_shape_parent is PhysicsBody2D) ) ================================================ FILE: addons/goutte.animated_shape_2d/animated_shape_2d.svg.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://cyjyxgm3by1ae" path="res://.godot/imported/animated_shape_2d.svg-8a2942c665c36f113bd6a54b476fbb9b.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/animated_shape_2d.svg" dest_files=["res://.godot/imported/animated_shape_2d.svg-8a2942c665c36f113bd6a54b476fbb9b.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 svg/scale=1.0 editor/scale_with_editor_scale=false editor/convert_colors_with_editor_theme=false ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/copy.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://085a76nwyjqf" path="res://.godot/imported/copy.png-2a519d62cfcdeffa08a3d01a8cd47952.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/copy.png" dest_files=["res://.godot/imported/copy.png-2a519d62cfcdeffa08a3d01a8cd47952.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/edit.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://be0cxufi44ytn" path="res://.godot/imported/edit.png-96c9f593918026c095b8077c91767aa5.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/edit.png" dest_files=["res://.godot/imported/edit.png-96c9f593918026c095b8077c91767aa5.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/link.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://chl5rhpr6ngqp" path="res://.godot/imported/link.png-e080a774f28ccd8863f5e23555455903.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/link.png" dest_files=["res://.godot/imported/link.png-e080a774f28ccd8863f5e23555455903.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/new.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://cbgoa2ilmautt" path="res://.godot/imported/new.png-f21616bfd9c8d333fd8a398de8c672bc.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/new.png" dest_files=["res://.godot/imported/new.png-f21616bfd9c8d333fd8a398de8c672bc.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/paste.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://c5mpguq347mtg" path="res://.godot/imported/paste.png-02c243ab26b4f6897dc226741e89794a.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/paste.png" dest_files=["res://.godot/imported/paste.png-02c243ab26b4f6897dc226741e89794a.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/remove.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://hsf5o8ys0vo3" path="res://.godot/imported/remove.png-b830910b67fe242c64c7a538aae1b97e.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/remove.png" dest_files=["res://.godot/imported/remove.png-b830910b67fe242c64c7a538aae1b97e.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/shift_left.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://btv6bx8bipqrm" path="res://.godot/imported/shift_left.png-2904efcb7f02a4fa2b265a34ae875c4f.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/shift_left.png" dest_files=["res://.godot/imported/shift_left.png-2904efcb7f02a4fa2b265a34ae875c4f.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/shift_right.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://q30fj4bowepc" path="res://.godot/imported/shift_right.png-de4924e407dbbfc32773a9a30221b8a0.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/shift_right.png" dest_files=["res://.godot/imported/shift_right.png-de4924e407dbbfc32773a9a30221b8a0.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/zoom_less.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://cwgak166wa6hw" path="res://.godot/imported/zoom_less.png-ea172f3b8537da096481d713108fafea.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/zoom_less.png" dest_files=["res://.godot/imported/zoom_less.png-ea172f3b8537da096481d713108fafea.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/zoom_more.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://b64sqrouwimqs" path="res://.godot/imported/zoom_more.png-dcace68e63f154adcf3f1343a64aab3f.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/zoom_more.png" dest_files=["res://.godot/imported/zoom_more.png-dcace68e63f154adcf3f1343a64aab3f.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/icons/zoom_reset.png.import ================================================ [remap] importer="texture" type="CompressedTexture2D" uid="uid://2vmlyocy0aeu" path="res://.godot/imported/zoom_reset.png-81fb038c23d931244978fb7781d3ccb4.ctex" metadata={ "vram_texture": false } [deps] source_file="res://addons/goutte.animated_shape_2d/editor/icons/zoom_reset.png" dest_files=["res://.godot/imported/zoom_reset.png-81fb038c23d931244978fb7781d3ccb4.ctex"] [params] compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 ================================================ FILE: addons/goutte.animated_shape_2d/editor/linked_frames_feedback.gd ================================================ @tool extends Node @export var editor: ShapeFramesBottomPanelControl func update_for(animation_name: StringName, frame_index: int): assert(editor.currently_selected_animation_name == animation_name) # 1. Grab the sprite frame resource of the selected frame var frame_editor := editor.get_frame_at(frame_index) var shape_frame := frame_editor.get_shape_frame() # 2. Iterate over all frames to find linked frames var linked_frames_editors: Array[ShapeFrameEditor]= [] for some_frame_editor in editor.frames_list: if shape_frame == null: continue if some_frame_editor.get_shape_frame() != shape_frame: continue linked_frames_editors.append(some_frame_editor) # 3. Hide the link marker everywhere for some_frame_editor in editor.frames_list: some_frame_editor.hide_link_marker() # 4. Show the link marker where appropriate if linked_frames_editors.size() > 1: for linked_frame_editor in linked_frames_editors: linked_frame_editor.show_link_marker() func _on_shape_frames_bottom_panel_control_frame_selected(animation_name: StringName, frame_index: int): update_for(animation_name, frame_index) func _on_shape_frames_bottom_panel_control_frame_changed(animation_name, frame_index): var selected_frame_editor := editor.get_selected_frame() update_for(selected_frame_editor.animation_name, selected_frame_editor.frame_index) ================================================ FILE: addons/goutte.animated_shape_2d/editor/shape_frame_editor.gd ================================================ @tool extends Control class_name ShapeFrameEditor ## Editor GUI for a single ShapeFrame2D. ## Shows a preview of the sprite and the shape, as well as action buttons. const SHAPE_PREVIEW_SCRIPT := preload("./shape_preview.gd") ## Animated shape resource we are editing. ## This holds enough data to configure a CollisionShape2D for a specific frame. var animated_shape: AnimatedShape2D ## Animation name of the AnimatedSprite2D we're targeting. var animation_name: String ## Frame of the above animation we are targeting. var frame_index: int ## Zoom level of the preview. Only integers are supported in there for now. var zoom_level := 1.0 ## Bakground color of the preview. var background_color := Color.WEB_GRAY var undo_redo: EditorUndoRedoManager signal frame_selected signal frame_deselected signal changed ## Mandatory dependency injection, since it's best to leave _init() alone. func configure( animated_shape: AnimatedShape2D, animation_name: String, frame_index: int, ): self.animated_shape = animated_shape self.animation_name = animation_name self.frame_index = frame_index ## Optional dependency injection func set_undo_redo(undo_redo: EditorUndoRedoManager): self.undo_redo = undo_redo func set_zoom_level(new_zoom_level: float): zoom_level = new_zoom_level func set_background_color(new_background_color: Color): background_color = new_background_color func _enter_tree(): connect_to_shape_frame() func _exit_tree(): disconnect_from_shape_frame() remove_preview_of_shape_frame() if is_selected(): frame_deselected.emit() func build(button_group: ButtonGroup): update() # I had to set the ButtonGroup procedurally, a resource file won't work. %SpriteButton.button_group = button_group func get_shape_frame() -> ShapeFrame2D: if self.animated_shape == null: return null if self.animated_shape.shape_frames == null: return null return self.animated_shape.shape_frames.get_shape_frame( self.animation_name, self.frame_index, ) func set_shape_frame(value: ShapeFrame2D): if self.animated_shape == null: return if self.animated_shape.shape_frames == null: return disconnect_from_shape_frame() self.animated_shape.shape_frames.set_shape_frame( self.animation_name, self.frame_index, value, ) connect_to_shape_frame() update() emit_changed() ## Connect to the edited Resource, in order to update the GUI in real time. func connect_to_shape_frame(): var shape_frame := get_shape_frame() if shape_frame != null: shape_frame.changed.connect(on_shape_frame_changed) ## Disconnect from the edited Resource, to not leave connections hanging. func disconnect_from_shape_frame(): var shape_frame := get_shape_frame() if shape_frame != null: if shape_frame.changed.is_connected(on_shape_frame_changed): shape_frame.changed.disconnect(on_shape_frame_changed) func is_selected() -> bool: return %SpriteButton.button_pressed func select(): %SpriteButton.button_pressed = true func show_link_marker(): %LinkMarker.show() func hide_link_marker(): %LinkMarker.hide() ## The crux of the matter ; update the scene according to the data. func update(): if self.animated_shape == null: return if self.animated_shape.animated_sprite == null: return if self.animated_shape.animated_sprite.sprite_frames == null: return # I. The actual sprite from the SpriteFrames, for this frame. %SpriteFrameTexture.texture = self.animated_shape.animated_sprite.sprite_frames.get_frame_texture(self.animation_name, self.frame_index) %SpriteFrameTexture.custom_minimum_size = self.zoom_level * %SpriteFrameTexture.texture.get_size() %SpriteFrameTexture.texture_filter = self.animated_shape.animated_sprite.texture_filter if %SpriteFrameTexture.texture_filter == TEXTURE_FILTER_PARENT_NODE: %SpriteFrameTexture.texture_filter = TEXTURE_FILTER_NEAREST %SpriteFrameTexture.texture_repeat = self.animated_shape.animated_sprite.texture_repeat if %SpriteFrameTexture.texture_repeat == TEXTURE_REPEAT_PARENT_NODE: %SpriteFrameTexture.texture_repeat = TEXTURE_REPEAT_DISABLED # II. Origin (0, 0) of the parent of the collision shape, # relative to the sprite, to help positioning our collision shape # at the right spot relative to the sprite in this preview. var collision_shape_parent_transform := Transform2D() if self.animated_shape.collision_shape.get_parent() is Node2D: collision_shape_parent_transform = self.animated_shape.collision_shape.get_parent().global_transform %Origin.transform = ( self.animated_shape.animated_sprite.global_transform.affine_inverse() * collision_shape_parent_transform ) if self.animated_shape.animated_sprite.centered: %Origin.position += ( self.animated_shape.animated_sprite.sprite_frames.get_frame_texture( self.animation_name, self.frame_index, ).get_size() * 0.5 ) %Origin.position -= ( self.animated_shape.animated_sprite.offset ) # III. Display the preview of the collision shape. if self.animated_shape.shape_frames == null: return var shape_frame := get_shape_frame() if shape_frame == null: %ShapeHolder.shape = null else: %ShapeHolder.shape = shape_frame.get_shape() %ShapeHolder.position = shape_frame.position %ShapeHolder.disabled = shape_frame.disabled %ShapeHolder.debug_color = self.animated_shape.collision_shape.debug_color if shape_frame.debug_color != Color.BLACK: %ShapeHolder.debug_color = shape_frame.debug_color # IV. Adjust the preview to the zoom level %ZoomAdjuster.scale = Vector2.ONE * self.zoom_level # V. Background clear color. %BackgroundColor.color = self.background_color # VI. Tooltip on the main sprite button %SpriteButton.tooltip_text = "%s/%d" % [self.animation_name, self.frame_index] if shape_frame != null: %SpriteButton.tooltip_text += " %s" % [shape_frame] %SpriteButton.tooltip_text += "\nClick to edit." # X. Action button: Create if shape_frame == null: %CreateButton.visible = true %CreateButton.disabled = false else: %CreateButton.visible = false %CreateButton.disabled = true # XI. Action button: Edit #if shape_frame == null: #%EditButton.visible = false #%EditButton.disabled = true #else: #%EditButton.visible = true #%EditButton.disabled = false # XII. Action button: Copy if shape_frame == null: %CopyButton.visible = false %CopyButton.disabled = true else: %CopyButton.visible = true %CopyButton.disabled = false # XIII. Action button: Delete if shape_frame == null: %DeleteButton.visible = false %DeleteButton.disabled = true else: %DeleteButton.visible = true %DeleteButton.disabled = false # L. 2D View Preview / Mouse GUI Editor if is_preview_showing(): preview_shape_frame() func inspect_shape_frame(): var shape_frame := get_shape_frame() if shape_frame == null: if self.animated_shape: EditorInterface.edit_node(self.animated_shape) return EditorInterface.edit_resource(shape_frame) ## The UndoRedo does not like when we use different objects, so we wrap this method here. #func set_shape_frame(animation_name: StringName, frame_index: int): #self.animated_shape.shape_frames.set_(animation_name, frame_index) ## The UndoRedo does not like when we use different objects, so we wrap this method here. func remove_shape_frame(): self.animated_shape.shape_frames.remove_shape_frame(self.animation_name, self.frame_index) # _____ _ # | __ \ (_) # | |__) | __ _____ ___ _____ __ # | ___/ '__/ _ \ \ / / |/ _ \ \ /\ / / # | | | | | __/\ V /| | __/\ V V / # |_| |_| \___| \_/ |_|\___| \_/\_/ # # The big one shown in the 2D Editor when we select this shape frame. # This is actually more than a preview since we can *edit* the shape with it. var sprite_preview: AnimatedSprite2D var preview_background: ColorRect var preview_shape: CollisionShape2D func is_preview_showing() -> bool: return is_instance_valid(self.sprite_preview) func remove_preview_of_shape_frame(): if is_instance_valid(preview_shape): preview_shape.queue_free() preview_shape = null if is_instance_valid(preview_background): preview_background.queue_free() preview_background = null if is_instance_valid(sprite_preview): sprite_preview.queue_free() sprite_preview = null func preview_shape_frame(): if not is_instance_valid(sprite_preview): sprite_preview = animated_shape.animated_sprite.duplicate() sprite_preview.name = "PreviewAnimatedSprite2D" sprite_preview.owner = null sprite_preview.animation = self.animation_name sprite_preview.frame = self.frame_index if not is_instance_valid(preview_background): preview_background = ColorRect.new() preview_background.name = "PreviewBackgroundColorRect" preview_background.owner = null preview_background.show_behind_parent = true preview_background.set_anchors_preset(PRESET_FULL_RECT) if sprite_preview.centered: var s := sprite_preview.sprite_frames.get_frame_texture(sprite_preview.animation, sprite_preview.frame).get_size() preview_background.offset_left -= s.x * 0.5 preview_background.offset_right -= s.x * 0.5 preview_background.offset_top -= s.y * 0.5 preview_background.offset_bottom -= s.y * 0.5 # TODO: handle sprite offset too, probably preview_background.color = self.background_color if preview_background.get_parent() != sprite_preview: if preview_background.get_parent() != null: preview_background.get_parent().remove_child(preview_background) sprite_preview.add_child(preview_background) var shape_frame := get_shape_frame() if shape_frame != null: if not is_instance_valid(preview_shape): preview_shape = CollisionShape2D.new() preview_shape.name = "PreviewCollisionShape2D" preview_shape.set_script(SHAPE_PREVIEW_SCRIPT) preview_shape.rectangle_changed.connect(on_preview_shape_rectangle_changed) preview_shape.item_rect_changed.connect(on_preview_shape_rect_changed) self.animated_shape.collision_shape.add_sibling(preview_shape) preview_shape.shape = shape_frame.shape preview_shape.position = shape_frame.position preview_shape.disabled = shape_frame.disabled preview_shape.debug_color = self.animated_shape.collision_shape.debug_color if shape_frame.debug_color != Color.BLACK: preview_shape.debug_color = shape_frame.debug_color else: if is_instance_valid(preview_shape): preview_shape.queue_free() preview_shape = null if sprite_preview.get_parent() == null: self.animated_shape.animated_sprite.add_sibling(sprite_preview) var selection := EditorInterface.get_selection().get_selected_nodes() var already_selected := not selection.is_empty() if already_selected: already_selected = (selection[0] == preview_shape) if is_instance_valid(preview_shape) and not already_selected: EditorInterface.get_selection().clear() EditorInterface.get_selection().add_node(preview_shape) # Whatever the doc says, the node already IS inspected. Might change. # Anyway we don't even WANT to inspect this node, and we have to hack # around this unwanted inspection, see _on_sprite_button_toggled(). #EditorInterface.edit_node(preview_shape) # This path was created using the infamous Editor Debugger with a tweak. # We are not using the raw index in parent, but index by class in parent # because it will be a little more resilient to changes in the tree. # This is a hack, and may not play nice with third party plugins. # If you know of another way to enable the mouse move mode, plz share! # We need to use the mouse move mode because the selection mode does # not like non-owned nodes, even if they are _already selected_. var path := [ # [ class, index_by_class_in_parent ] ["VBoxContainer", 0], ["HSplitContainer", 0], ["HSplitContainer", 0], ["HSplitContainer", 0], ["VBoxContainer", 0], ["VSplitContainer", 0], ["VSplitContainer", 0], ["VBoxContainer", 0], ["PanelContainer", 0], ["VBoxContainer", 0], ["CanvasItemEditor", 0], ["MarginContainer", 0], ["HFlowContainer", 0], ["HBoxContainer", 0], ["Button", 1], ] var mouse_move_button := get_editor_node_from_path(path) as Button if mouse_move_button != null: mouse_move_button.pressed.emit() else: # Ouch, the hack above broke, as expected. Best ignore this. # Just make sure you use the Mouse Move Mode when editing the 2D # preview, and not the Select Mode (we can't reposition with it). push_warning("Mouse Move Button of 2D View was not found.") func on_preview_shape_rectangle_changed(): update_from_preview_shape() func on_preview_shape_rect_changed(): # I wanna know when this starts working. print("Oh, now item_rect_changed signal works. Used to not.") # Enable this when it works, and remove workaraound? #update_from_preview_shape() func update_from_preview_shape(): var shape_frame := get_shape_frame() if shape_frame == null: return if not is_instance_valid(self.preview_shape): return shape_frame.position = self.preview_shape.position ## Tool (could be static) to fetch a node from a weird path of [type, index], ## where the index is only amongst nodes of the specified type, ## to be more resilient to changes in the tree that will break the path. ## This path is for the Editor only and starts in the base editor control. ## Use the (modded) "editor_debug" addon to get the path (copy typed path). F10 func get_editor_node_from_path(path: Array) -> Node: var node := EditorInterface.get_base_control() for datum in path: var node_class: String = datum[0] var node_index: int = datum[1] var current_index := 0 var found := false for child in node.get_children(): if child.get_class() != node_class: continue if current_index == node_index: node = child found = true break current_index += 1 if not found: return null return node # _ _ _ # | | (_) | | # | | _ ___| |_ ___ _ __ ___ _ __ ___ # | | | / __| __/ _ \ '_ \ / _ \ '__/ __| # | |____| \__ \ || __/ | | | __/ | \__ \ # |______|_|___/\__\___|_| |_|\___|_| |___/ # ## UndoRedo won't accept calling methods on signals, so we'll call this instead. func emit_changed(): self.changed.emit() func on_shape_frame_changed(): update() emit_changed() func _on_sprite_button_toggled(toggled_on: bool): if toggled_on: self.frame_selected.emit() preview_shape_frame() #inspect_shape_frame() # nope, the preview has priority somehow #inspect_shape_frame.call_deferred() # nope too # So, we use this horrendous await that will create bugs: get_tree().create_timer(0.064).timeout.connect( func(): inspect_shape_frame() ) else: self.frame_deselected.emit() remove_preview_of_shape_frame() func _on_create_button_pressed(): var shape_frame := get_shape_frame() if shape_frame != null: return # We could also use the UndoRedo here, but… Hassle > Gain shape_frame = ShapeFrame2D.new() shape_frame.disabled = self.animated_shape.collision_shape.disabled shape_frame.position = self.animated_shape.collision_shape.position if self.animated_shape.collision_shape.shape: shape_frame.shape = self.animated_shape.collision_shape.shape.duplicate(true) else: shape_frame.shape = RectangleShape2D.new() self.animated_shape.shape_frames.set_shape_frame( self.animation_name, self.frame_index, shape_frame, ) update() connect_to_shape_frame() inspect_shape_frame() emit_changed() func _on_edit_button_pressed(): inspect_shape_frame() func _on_copy_button_pressed(): var shape_frame := get_shape_frame() if shape_frame == null: return DisplayServer.clipboard_set(var_to_str(shape_frame.get_instance_id())) func _on_paste_button_pressed(): var previous_shape_frame := get_shape_frame() var copied_instance_id: int = str_to_var(DisplayServer.clipboard_get()) if copied_instance_id == null: return var pasted_shape_frame: ShapeFrame2D = instance_from_id(copied_instance_id) if pasted_shape_frame == null: return if not (pasted_shape_frame is ShapeFrame2D): return if Input.is_key_pressed(KEY_CTRL) or Input.is_key_pressed(KEY_META): pasted_shape_frame = pasted_shape_frame.duplicate(true) if self.undo_redo != null: self.undo_redo.create_action( tr("Paste Shape Frame"), UndoRedo.MERGE_DISABLE, self, ) self.undo_redo.add_do_method( self, &"disconnect_from_shape_frame", ) self.undo_redo.add_do_method( self, &"set_shape_frame", pasted_shape_frame, ) #self.undo_redo.add_do_method( #self, &"connect_to_shape_frame", #) #self.undo_redo.add_do_method( #self, &"update", #) #self.undo_redo.add_do_method( #self, &"inspect_shape_frame", #) #self.undo_redo.add_do_method( #self, &"emit_changed", #) self.undo_redo.add_undo_method( self, &"disconnect_from_shape_frame", ) self.undo_redo.add_undo_method( self, &"set_shape_frame", previous_shape_frame, ) #self.undo_redo.add_undo_method( #self, &"connect_to_shape_frame", #) #self.undo_redo.add_undo_method( #self, &"update", #) #self.undo_redo.add_undo_method( #self, &"inspect_shape_frame", #) #self.undo_redo.add_undo_method( #self, &"emit_changed", #) self.undo_redo.commit_action() else: # Same as above, without the UndoRedo shenanigans. disconnect_from_shape_frame() self.animated_shape.shape_frames.set_shape_frame( self.animation_name, self.frame_index, pasted_shape_frame, ) connect_to_shape_frame() update() emit_changed() func _on_delete_button_pressed(): var shape_frame := get_shape_frame() if shape_frame == null: return if self.undo_redo != null: self.undo_redo.create_action( tr("Delete Shape Frame"), UndoRedo.MERGE_DISABLE, self, ) self.undo_redo.add_do_method( self, &"disconnect_from_shape_frame", ) self.undo_redo.add_do_method( self, &"remove_shape_frame", ) self.undo_redo.add_do_method( self, &"update", ) self.undo_redo.add_do_method( self, &"inspect_shape_frame", ) self.undo_redo.add_do_method( self, &"emit_changed", ) self.undo_redo.add_undo_method( self, &"set_shape_frame", shape_frame, ) #self.undo_redo.add_undo_method( #self, &"connect_to_shape_frame", #) self.undo_redo.add_undo_method( self, &"update", ) self.undo_redo.add_undo_method( self, &"inspect_shape_frame", ) self.undo_redo.add_undo_method( self, &"emit_changed", ) self.undo_redo.commit_action() else: # Same as above, but without the UndoRedo shenanigans disconnect_from_shape_frame() remove_shape_frame() update() emit_changed() ================================================ FILE: addons/goutte.animated_shape_2d/editor/shape_frame_editor.tscn ================================================ [gd_scene load_steps=10 format=3 uid="uid://c6fdijn2r2lcs"] [ext_resource type="Script" path="res://addons/goutte.animated_shape_2d/editor/shape_frame_editor.gd" id="1_pmp4u"] [ext_resource type="Texture2D" uid="uid://chl5rhpr6ngqp" path="res://addons/goutte.animated_shape_2d/editor/icons/link.png" id="2_3bsnw"] [ext_resource type="Texture2D" uid="uid://hsf5o8ys0vo3" path="res://addons/goutte.animated_shape_2d/editor/icons/remove.png" id="3_bm3kb"] [ext_resource type="Texture2D" uid="uid://cbgoa2ilmautt" path="res://addons/goutte.animated_shape_2d/editor/icons/new.png" id="3_j1q7m"] [ext_resource type="Texture2D" uid="uid://085a76nwyjqf" path="res://addons/goutte.animated_shape_2d/editor/icons/copy.png" id="4_tt4qt"] [ext_resource type="Texture2D" uid="uid://be0cxufi44ytn" path="res://addons/goutte.animated_shape_2d/editor/icons/edit.png" id="5_q0gfy"] [ext_resource type="Texture2D" uid="uid://c5mpguq347mtg" path="res://addons/goutte.animated_shape_2d/editor/icons/paste.png" id="5_tdv8b"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ahkyv"] bg_color = Color(1, 1, 1, 1) corner_radius_top_left = 4 corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 corner_detail = 3 [sub_resource type="PlaceholderTexture2D" id="PlaceholderTexture2D_qm6te"] size = Vector2(256, 256) [node name="ShapeFrameEditor" type="MarginContainer"] light_mask = 0 offset_right = 72.0 offset_bottom = 72.0 auto_translate = false localize_numeral_system = false theme_override_constants/margin_left = 4 theme_override_constants/margin_top = 4 theme_override_constants/margin_right = 4 theme_override_constants/margin_bottom = 4 script = ExtResource("1_pmp4u") [node name="VBoxContainer" type="VBoxContainer" parent="."] layout_mode = 2 [node name="MarginContainer" type="MarginContainer" parent="VBoxContainer"] layout_mode = 2 [node name="SpriteButton" type="Button" parent="VBoxContainer/MarginContainer"] unique_name_in_owner = true editor_description = "Button Group is set procedurally because setting it here did not work as expected." clip_contents = true layout_mode = 2 mouse_default_cursor_shape = 2 theme_override_styles/pressed = SubResource("StyleBoxFlat_ahkyv") toggle_mode = true [node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/MarginContainer/SpriteButton"] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 theme_override_constants/margin_left = 3 theme_override_constants/margin_top = 3 theme_override_constants/margin_right = 3 theme_override_constants/margin_bottom = 3 [node name="BackgroundColor" type="ColorRect" parent="VBoxContainer/MarginContainer/SpriteButton/MarginContainer"] unique_name_in_owner = true layout_mode = 2 mouse_filter = 2 color = Color(0.341176, 0.341176, 0.341176, 1) [node name="SpriteFrameTexture" type="TextureRect" parent="VBoxContainer/MarginContainer"] unique_name_in_owner = true light_mask = 0 layout_mode = 2 auto_translate = false localize_numeral_system = false mouse_filter = 2 texture = SubResource("PlaceholderTexture2D_qm6te") stretch_mode = 5 [node name="ZoomAdjuster" type="Node2D" parent="VBoxContainer/MarginContainer/SpriteFrameTexture"] unique_name_in_owner = true [node name="Origin" type="Marker2D" parent="VBoxContainer/MarginContainer/SpriteFrameTexture/ZoomAdjuster"] unique_name_in_owner = true gizmo_extents = 6.0 [node name="ShapeHolder" type="CollisionShape2D" parent="VBoxContainer/MarginContainer/SpriteFrameTexture/ZoomAdjuster/Origin"] unique_name_in_owner = true [node name="Control" type="Control" parent="VBoxContainer/MarginContainer"] layout_mode = 2 mouse_filter = 2 [node name="LinkMarker" type="TextureRect" parent="VBoxContainer/MarginContainer/Control"] unique_name_in_owner = true visible = false self_modulate = Color(1, 1, 0, 1) layout_mode = 1 anchors_preset = -1 anchor_left = 1.0 anchor_right = 1.0 offset_top = 4.0 offset_right = -4.0 grow_horizontal = 0 tooltip_text = "All the frames marked with this icon are linked together ; that is, if you change one, they will all change. To paste without linking, maintain CTRL when pressing the Paste button, and it will make a deep copy instead of a shallow (linked) one." texture = ExtResource("2_3bsnw") metadata/_edit_lock_ = true [node name="ActionsContainer" type="HBoxContainer" parent="VBoxContainer"] layout_mode = 2 alignment = 1 [node name="CreateButton" type="Button" parent="VBoxContainer/ActionsContainer"] unique_name_in_owner = true texture_filter = 1 texture_repeat = 1 layout_mode = 2 tooltip_text = "Create a custom collision shape configuration (ShapeFrame2D) for this frame. It will appear in the Inspector." focus_mode = 0 mouse_default_cursor_shape = 2 icon = ExtResource("3_j1q7m") flat = true icon_alignment = 1 [node name="EditButton" type="Button" parent="VBoxContainer/ActionsContainer"] unique_name_in_owner = true visible = false texture_filter = 1 texture_repeat = 1 layout_mode = 2 tooltip_text = "Edit the ShapeFrame2D in the inspector." focus_mode = 0 mouse_default_cursor_shape = 2 icon = ExtResource("5_q0gfy") flat = true icon_alignment = 1 [node name="CopyButton" type="Button" parent="VBoxContainer/ActionsContainer"] unique_name_in_owner = true texture_filter = 1 texture_repeat = 1 layout_mode = 2 tooltip_text = "Copy the collision shape customization of this frame into the clipboard." focus_mode = 0 mouse_default_cursor_shape = 2 icon = ExtResource("4_tt4qt") flat = true icon_alignment = 1 [node name="PasteButton" type="Button" parent="VBoxContainer/ActionsContainer"] unique_name_in_owner = true texture_filter = 1 texture_repeat = 1 layout_mode = 2 tooltip_text = "Paste the copied collision shape customization from the clipboard into this frame. Beware, this pastes a shallow copy and therefore the two frames will now be edited together. Use CTRL+Click here to paste a deep copy and decouple the frames." focus_mode = 0 mouse_default_cursor_shape = 2 icon = ExtResource("5_tdv8b") flat = true icon_alignment = 1 [node name="DeleteButton" type="Button" parent="VBoxContainer/ActionsContainer"] unique_name_in_owner = true texture_filter = 1 texture_repeat = 1 layout_mode = 2 tooltip_text = "Delete the collision shape customization for this frame." focus_mode = 0 mouse_default_cursor_shape = 2 icon = ExtResource("3_bm3kb") flat = true icon_alignment = 1 [connection signal="toggled" from="VBoxContainer/MarginContainer/SpriteButton" to="." method="_on_sprite_button_toggled"] [connection signal="pressed" from="VBoxContainer/ActionsContainer/CreateButton" to="." method="_on_create_button_pressed"] [connection signal="pressed" from="VBoxContainer/ActionsContainer/EditButton" to="." method="_on_edit_button_pressed"] [connection signal="pressed" from="VBoxContainer/ActionsContainer/CopyButton" to="." method="_on_copy_button_pressed"] [connection signal="pressed" from="VBoxContainer/ActionsContainer/PasteButton" to="." method="_on_paste_button_pressed"] [connection signal="pressed" from="VBoxContainer/ActionsContainer/DeleteButton" to="." method="_on_delete_button_pressed"] ================================================ FILE: addons/goutte.animated_shape_2d/editor/shape_frames_bottom_panel_control.gd ================================================ @tool extends Control class_name ShapeFramesBottomPanelControl #class_name ShapeFramesEditor ## Bottom panel for the Editor, shown along with Output, Debugger, etc. ## Dedicated to editing a single AnimatedShape2D. ## Will show a list of animation names, and frames for each animation. const FRAME_SCENE := preload("./shape_frame_editor.tscn") @onready var animation_names_item_list: ItemList = %AnimationNamesItemList @onready var frames_container := %FramesContainer ## The thing we are previewing and editing. var animated_shape: AnimatedShape2D ## Used to access Editor things like UndoRedo. ## Someday we will not need this anymore, hopefully. var editor_plugin: EditorPlugin ## Zoom level of the button previews ; only integers for now. Contribs welcome. var zoom_level := 1.0: set = set_zoom_level ## Customizable background color of previews. var background_color := Color.WEB_GRAY ## Button Group for the various frames, so that only one is selected at a time. ## We assign this procedurally because assigning it in the scene did not work. var frames_button_group: ButtonGroup var currently_selected_animation_name: StringName ## Array of ShapeFrameEditor currently shown, for the selected animation. ## These are the children of frames_container, except when config is missing. var frames_list: Array[ShapeFrameEditor] = [] # of ShapeFrameEditor signal frame_selected(animation_name: String, frame_index: int) signal frame_deselected(animation_name: String, frame_index: int) signal frame_changed(animation_name: String, frame_index: int) func configure( editor_plugin: EditorPlugin, ): self.editor_plugin = editor_plugin func clear(): self.animation_names_item_list.deselect_all() self.animation_names_item_list.clear() self.animated_shape = null clear_shape_frames() func clear_shape_frames(): # We iterate the container node and not the frames_list to also remove the # initial label helpers that appear when the AnimatedShape2D lacks config. for child in self.frames_container.get_children(): child.queue_free() self.frames_list.clear() func rebuild_gui( for_animated_shape: AnimatedShape2D, force := false, ): if (self.animated_shape == for_animated_shape) and not force: return clear() self.animated_shape = for_animated_shape #%BackgroundColorPicker.color = … # from editor settings ? self.background_color = %BackgroundColorPicker.color var missing_requirements := false if not is_instance_valid(self.animated_shape.animated_sprite): var label := Label.new() label.text = tr("Please assign an input animated sprite to this AnimatedShape2D.") self.frames_container.add_child(label) missing_requirements = true if not is_instance_valid(self.animated_shape.collision_shape): var label := Label.new() label.text = tr("Please assign a target collision shape to this AnimatedShape2D.") self.frames_container.add_child(label) missing_requirements = true if not is_instance_valid(self.animated_shape.shape_frames): var label := Label.new() label.text = tr("Please create or load shape frames data for this AnimatedShape2D.") self.frames_container.add_child(label) missing_requirements = true var inspector := EditorInterface.get_inspector() if inspector.property_edited.is_connected(on_change_do_reload): inspector.property_edited.disconnect(on_change_do_reload) if missing_requirements: inspector.property_edited.connect(on_change_do_reload) return var animation_name := &"default" if is_instance_valid(self.animated_shape.animated_sprite): animation_name = self.animated_shape.animated_sprite.animation rebuild_animation_names_item_list(self.animated_shape, animation_name) func rebuild_animation_names_item_list( animated_shape: AnimatedShape2D, selected_animation_name: String, ): if animated_shape == null: print("AnimatedShape2D: no animated shape is configured.") return if animated_shape.animated_sprite == null: print("AnimatedShape2D: no animated sprite is configured.") return if animated_shape.animated_sprite.sprite_frames == null: print("AnimatedShape2D: no sprite frames is configured.") return var index := 0 var selected_index := 0 for animation_name in animated_shape.animated_sprite.sprite_frames.get_animation_names(): self.animation_names_item_list.add_item(animation_name) if animation_name == selected_animation_name: selected_index = index index += 1 self.animation_names_item_list.select(selected_index) self.animation_names_item_list.item_selected.emit(selected_index) func rebuild_view_of_animation(animation_name: String): clear_shape_frames() self.currently_selected_animation_name = animation_name self.frames_button_group = ButtonGroup.new() var frames_count := self.animated_shape.animated_sprite.sprite_frames.get_frame_count(animation_name) for frame_index in frames_count: var frame_scene := FRAME_SCENE.instantiate() as ShapeFrameEditor frame_scene.configure(self.animated_shape, animation_name, frame_index) frame_scene.set_undo_redo(self.editor_plugin.get_undo_redo()) frame_scene.set_zoom_level(self.zoom_level) frame_scene.set_background_color(self.background_color) frame_scene.build(self.frames_button_group) self.frames_container.add_child(frame_scene) self.frames_list.append(frame_scene) for frame_scene: ShapeFrameEditor in self.frames_list: if frame_scene == null: continue frame_scene.frame_selected.connect(on_frame_selected.bind(frame_scene.animation_name, frame_scene.frame_index)) frame_scene.frame_deselected.connect(on_frame_deselected.bind(frame_scene.animation_name, frame_scene.frame_index)) frame_scene.changed.connect(on_frame_changed.bind(frame_scene.animation_name, frame_scene.frame_index)) func rebuild_view_of_animation_by_index(item_index: int): var animation_name := self.animation_names_item_list.get_item_text(item_index) rebuild_view_of_animation(animation_name) func rebuild_view_of_selected_animation(): var selected_animations_indices := self.animation_names_item_list.get_selected_items() if not selected_animations_indices.is_empty(): rebuild_view_of_animation_by_index(selected_animations_indices[0]) func set_zoom_level(new_zoom_level: float): if new_zoom_level == zoom_level: return zoom_level = new_zoom_level rebuild_view_of_selected_animation() func get_selected_frame() -> ShapeFrameEditor: for frame_editor: ShapeFrameEditor in self.frames_list: if frame_editor == null: continue if frame_editor.is_selected(): return frame_editor return null func get_frame_at(frame_index: int) -> ShapeFrameEditor: if frame_index < 0: return null if frame_index > self.frames_list.size() - 1: return null return self.frames_list[frame_index] func shift_frames_from_selected(cursor_direction: int) -> Error: var frame_editor := get_selected_frame() if not is_instance_valid(frame_editor): return ERR_CANT_ACQUIRE_RESOURCE if frame_editor.get_shape_frame() == null: return ERR_DOES_NOT_EXIST var shifted := shift_frames(cursor_direction, frame_editor.frame_index) if shifted != OK: return shifted var new_selected := get_frame_at(frame_editor.frame_index + cursor_direction) if is_instance_valid(new_selected): new_selected.select() return OK ## Shift the frames from frame index, one slot in the specified direction. func shift_frames(cursor_direction: int, from_frame_index: int) -> Error: if (cursor_direction != 1) and (cursor_direction != -1): push_warning("AnimatedShape2D: unsupported value for cursor direction.") return ERR_INVALID_PARAMETER # Collect to frame(s) to shift var frames_to_shift := Array() var cursor_index := from_frame_index var minimum_index := 0 var maximum_index := self.frames_list.size() - 1 var ok := false while true: if cursor_index < minimum_index: break if cursor_index > maximum_index: break var current_frame_editor: ShapeFrameEditor = self.frames_list[cursor_index] if current_frame_editor.get_shape_frame() == null: ok = true break frames_to_shift.append(current_frame_editor) cursor_index += cursor_direction if not ok: print("AnimatedShape2D: cancelling shift because there is no room.") return ERR_ALREADY_EXISTS # Now we can do the actual shifting for i in frames_to_shift.size(): var j := cursor_index - i * cursor_direction self.frames_list[j].set_shape_frame(self.frames_list[j-cursor_direction].get_shape_frame()) self.frames_list[cursor_index - frames_to_shift.size() * cursor_direction].set_shape_frame(null) return OK func on_frame_selected(animation_name: String, frame_index: int): frame_selected.emit(animation_name, frame_index) # Below could be a subscriber to the above signal? var shape_frame := animated_shape.shape_frames.get_shape_frame( animation_name, frame_index, ) if shape_frame != null: %ShiftLeftButton.disabled = false %ShiftRightButton.disabled = false func on_frame_deselected(animation_name: String, frame_index: int): frame_deselected.emit(animation_name, frame_index) # Below could be a subscriber to the above signal? %ShiftLeftButton.disabled = true %ShiftRightButton.disabled = true func on_frame_changed(animation_name: String, frame_index: int): frame_changed.emit(animation_name, frame_index) ## Updates the bottom panel as the user fills the required properties. ## This listener is only connected when something is missing. func on_change_do_reload(_property: String): var inspector := EditorInterface.get_inspector() if not (inspector.get_edited_object() is AnimatedShape2D): return if inspector.property_edited.is_connected(on_change_do_reload): inspector.property_edited.disconnect(on_change_do_reload) rebuild_gui(self.animated_shape, true) func _on_animation_names_item_list_item_selected(index: int): rebuild_view_of_animation_by_index(index) func _on_zoom_less_button_pressed(): var new_zoom_level := self.zoom_level - 1.0 # Note: current zoom logic in TextureRect won't allow going below 1 #if self.zoom_level <= 1.0: #new_zoom_level = self.zoom_level * 0.5 #new_zoom_level = max(0.125, new_zoom_level) new_zoom_level = max(1.0, new_zoom_level) set_zoom_level(new_zoom_level) func _on_zoom_reset_button_pressed(): set_zoom_level(1.0) func _on_zoom_more_button_pressed(): var new_zoom_level := self.zoom_level + 1.0 # Note: current zoom logic in TextureRect won't allow going below 1 #if self.zoom_level <= 1.0: #new_zoom_level = self.zoom_level * 2.0 #new_zoom_level = max(0.125, new_zoom_level) new_zoom_level = max(1.0, new_zoom_level) new_zoom_level = min(10.0, new_zoom_level) set_zoom_level(new_zoom_level) func _on_background_color_picker_color_changed(color: Color): self.background_color = color rebuild_view_of_selected_animation() func _on_shift_left_button_pressed(): shift_frames_from_selected(-1) func _on_shift_right_button_pressed(): shift_frames_from_selected(1) ================================================ FILE: addons/goutte.animated_shape_2d/editor/shape_frames_bottom_panel_control.tscn ================================================ [gd_scene load_steps=8 format=3 uid="uid://fh5kcvadxlh3"] [ext_resource type="Script" path="res://addons/goutte.animated_shape_2d/editor/shape_frames_bottom_panel_control.gd" id="1_5xwm0"] [ext_resource type="Script" path="res://addons/goutte.animated_shape_2d/editor/linked_frames_feedback.gd" id="2_njk1o"] [ext_resource type="Texture2D" uid="uid://cwgak166wa6hw" path="res://addons/goutte.animated_shape_2d/editor/icons/zoom_less.png" id="2_rtykk"] [ext_resource type="Texture2D" uid="uid://b64sqrouwimqs" path="res://addons/goutte.animated_shape_2d/editor/icons/zoom_more.png" id="3_2217o"] [ext_resource type="Texture2D" uid="uid://2vmlyocy0aeu" path="res://addons/goutte.animated_shape_2d/editor/icons/zoom_reset.png" id="3_h377a"] [ext_resource type="Texture2D" uid="uid://btv6bx8bipqrm" path="res://addons/goutte.animated_shape_2d/editor/icons/shift_left.png" id="5_jj5t1"] [ext_resource type="Texture2D" uid="uid://q30fj4bowepc" path="res://addons/goutte.animated_shape_2d/editor/icons/shift_right.png" id="6_66ia2"] [node name="ShapeFramesBottomPanelControl" type="Control"] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_5xwm0") [node name="LinkedFramesFeedback" type="Node" parent="." node_paths=PackedStringArray("editor")] script = ExtResource("2_njk1o") editor = NodePath("..") [node name="HSplitContainer" type="HSplitContainer" parent="."] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 [node name="AnimationNamesItemList" type="ItemList" parent="HSplitContainer"] unique_name_in_owner = true custom_minimum_size = Vector2(80, 0) layout_mode = 2 size_flags_horizontal = 3 allow_search = false [node name="MarginContainer" type="VBoxContainer" parent="HSplitContainer"] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 3.0 [node name="ActionContainer" type="HBoxContainer" parent="HSplitContainer/MarginContainer"] layout_mode = 2 [node name="ZoomLessButton" type="Button" parent="HSplitContainer/MarginContainer/ActionContainer"] layout_mode = 2 tooltip_text = "Zoom out the previews." mouse_default_cursor_shape = 2 icon = ExtResource("2_rtykk") flat = true [node name="ZoomResetButton" type="Button" parent="HSplitContainer/MarginContainer/ActionContainer"] layout_mode = 2 tooltip_text = "Reset the zoom level of the previews." mouse_default_cursor_shape = 2 icon = ExtResource("3_h377a") flat = true [node name="ZoomMoreButton" type="Button" parent="HSplitContainer/MarginContainer/ActionContainer"] layout_mode = 2 tooltip_text = "Zoom in the previews." mouse_default_cursor_shape = 2 icon = ExtResource("3_2217o") flat = true [node name="VSeparator1" type="VSeparator" parent="HSplitContainer/MarginContainer/ActionContainer"] layout_mode = 2 [node name="BackgroundColorPicker" type="ColorPickerButton" parent="HSplitContainer/MarginContainer/ActionContainer"] unique_name_in_owner = true custom_minimum_size = Vector2(50, 31) layout_mode = 2 tooltip_text = "Choose a color for the background of the previews." focus_mode = 0 mouse_default_cursor_shape = 2 color = Color(0.282353, 0.282353, 0.282353, 1) [node name="VSeparator2" type="VSeparator" parent="HSplitContainer/MarginContainer/ActionContainer"] layout_mode = 2 [node name="ShiftLeftButton" type="Button" parent="HSplitContainer/MarginContainer/ActionContainer"] unique_name_in_owner = true layout_mode = 2 tooltip_text = "Shift the selected shape frame one slot to the left, pushing adjacent shape frames if necessary. If there is no free room to shift to, the shift is cancelled, because we don't want to destroy any shape frame data by shifting over it." mouse_default_cursor_shape = 2 disabled = true icon = ExtResource("5_jj5t1") flat = true [node name="ShiftRightButton" type="Button" parent="HSplitContainer/MarginContainer/ActionContainer"] unique_name_in_owner = true layout_mode = 2 tooltip_text = "Shift the selected shape frame one slot to the right, pushing adjacent shape frames if necessary. If there is no free room to shift to, the shift is cancelled, because we don't want to destroy any shape frame data by shifting over it." mouse_default_cursor_shape = 2 disabled = true icon = ExtResource("6_66ia2") flat = true [node name="ScrollContainer" type="ScrollContainer" parent="HSplitContainer/MarginContainer"] layout_mode = 2 size_flags_vertical = 3 follow_focus = true horizontal_scroll_mode = 0 [node name="FramesContainer" type="HFlowContainer" parent="HSplitContainer/MarginContainer/ScrollContainer"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 [connection signal="frame_changed" from="." to="LinkedFramesFeedback" method="_on_shape_frames_bottom_panel_control_frame_changed"] [connection signal="frame_selected" from="." to="LinkedFramesFeedback" method="_on_shape_frames_bottom_panel_control_frame_selected"] [connection signal="item_selected" from="HSplitContainer/AnimationNamesItemList" to="." method="_on_animation_names_item_list_item_selected"] [connection signal="pressed" from="HSplitContainer/MarginContainer/ActionContainer/ZoomLessButton" to="." method="_on_zoom_less_button_pressed"] [connection signal="pressed" from="HSplitContainer/MarginContainer/ActionContainer/ZoomResetButton" to="." method="_on_zoom_reset_button_pressed"] [connection signal="pressed" from="HSplitContainer/MarginContainer/ActionContainer/ZoomMoreButton" to="." method="_on_zoom_more_button_pressed"] [connection signal="color_changed" from="HSplitContainer/MarginContainer/ActionContainer/BackgroundColorPicker" to="." method="_on_background_color_picker_color_changed"] [connection signal="pressed" from="HSplitContainer/MarginContainer/ActionContainer/ShiftLeftButton" to="." method="_on_shift_left_button_pressed"] [connection signal="pressed" from="HSplitContainer/MarginContainer/ActionContainer/ShiftRightButton" to="." method="_on_shift_right_button_pressed"] ================================================ FILE: addons/goutte.animated_shape_2d/editor/shape_preview.gd ================================================ @tool extends CollisionShape2D ## Added to the temporary CollisionShape2D created in the 2D view of the Editor. ## Used to recover the position and size changes, since item_rect_changed NOPE. ## Note that we don't need the size of the shape, it's already propagated. ## This is only for the position, and perhaps later on rotation and scale ? signal rectangle_changed func _ready(): set_notify_local_transform(true) func _notification(what: int): if what == NOTIFICATION_LOCAL_TRANSFORM_CHANGED: rectangle_changed.emit() ================================================ FILE: addons/goutte.animated_shape_2d/plugin.cfg ================================================ [plugin] name="Animated Shape 2D" description="Helps making animated shapes to go with your animated sprites. This allows you to fully configure a CollisionShape2D for each frame of each animation of an AnimatedSprite2D. It also comes with an Editor, and supports linked frames that you can edit together, any type of shape, and various fallback mechanisms." author="Goutte" version="1.5.0" script="plugin.gd" ================================================ FILE: addons/goutte.animated_shape_2d/plugin.gd ================================================ @tool extends EditorPlugin # # This plugin adds the following to the Editor: # - An AnimatedShape2D Node you can add to your scenes in order to configure # a CollisionShape2D with dynamic values per frame of an AnimatedSprite2D. # This is very handy for making dynamic Hurtboxes, Solidboxes, or Hitboxes. # - A GUI for editing a ShapeFrame2D, similar to the SpriteFrames GUI. # const BOTTOM_PANEL_CONTROL_SCENE := preload("./editor/shape_frames_bottom_panel_control.tscn") var bottom_panel_button: Button var bottom_panel_control: Control func _enter_tree(): # I. Register the "Animated Shape" bottom panel. self.bottom_panel_control = BOTTOM_PANEL_CONTROL_SCENE.instantiate() self.bottom_panel_control.configure(self) self.bottom_panel_button = add_control_to_bottom_panel( self.bottom_panel_control, tr("Animated Shape"), ) # II. Show/Hide it depending on what's inspected in the Inspector. # This could also work using what's selected in the Scene Tree Editor? on_inspector_edited_object_changed() get_editor_interface().get_inspector().edited_object_changed.connect( on_inspector_edited_object_changed, ) func _exit_tree(): remove_control_from_bottom_panel(self.bottom_panel_control) if get_editor_interface().get_inspector().edited_object_changed.is_connected( on_inspector_edited_object_changed, ): get_editor_interface().get_inspector().edited_object_changed.disconnect( on_inspector_edited_object_changed, ) func update_bottom_panel(animated_shape: AnimatedShape2D): if animated_shape == null: self.bottom_panel_button.button_pressed = false self.bottom_panel_button.visible = false self.bottom_panel_control.clear() return self.bottom_panel_button.visible = true self.bottom_panel_button.button_pressed = true self.bottom_panel_control.rebuild_gui(animated_shape) func on_inspector_edited_object_changed(): var edited_object := get_editor_interface().get_inspector().get_edited_object() if edited_object is ShapeFrame2D: return if edited_object is CollisionShape2D and (edited_object as CollisionShape2D).owner == null: return # we're editing the previews' shape if edited_object is Shape2D: return # same if edited_object == null: return # same var animated_shape: AnimatedShape2D = null if edited_object is AnimatedShape2D: animated_shape = edited_object as AnimatedShape2D update_bottom_panel(animated_shape) ================================================ FILE: addons/goutte.animated_shape_2d/shape_frame_2d.gd ================================================ @tool extends Resource class_name ShapeFrame2D #class_name Shape2DFrame ## Data object for a single shape frame. ## Basically a configurator for a CollisionShape2D. ## Each frame of each animation of an AnimatedSprite2D will be matched to one ## of these, in a Dictionary in ShapeFrames2D. ## You can also use the metadata of this Resource to store custom records per frame. ## Position of the collision shape in its parent. @export var position := Vector2.ZERO: set(value): if value != position: position = value emit_changed() ## Disable the collision shape when [code]true[/code]. @export var disabled := false: set(value): if value != disabled: disabled = value emit_changed() ## Shape of the collision shape. @export var shape: Shape2D = null: get = get_shape, set = set_shape func get_shape() -> Shape2D: return shape func set_shape(value: Shape2D): shape = value emit_changed() ## Override the debug color of the shape. ## Especially useful when adding metadata to the shape. ## Black with full opacity disables this override. @export var debug_color := Color.BLACK ## Used to make dummy ; perhaps keep as a procedural API ? static func make_rectangle( size: Vector2, position := Vector2.ZERO, disabled := false, ) -> ShapeFrame2D: var sf := ShapeFrame2D.new() sf.shape = RectangleShape2D.new() sf.shape.size = size sf.position = position sf.disabled = disabled return sf ================================================ FILE: addons/goutte.animated_shape_2d/shape_frames_2d.gd ================================================ @tool extends Resource class_name ShapeFrames2D #class_name Shape2DFrames ## Resource holding the configuration of a CollisionShape2D, ## for each frame of each animation of a SpriteFrames. ## This is basically a mapping of ShapeFrame2D for each (animation name, frame). ## This helps avoiding infinite loops in case you pass INF as frame index. ## If you hit that limit (what are you doing?!), it is safe to bump it up. const MAXIMUM_FRAMES_AMOUNT := 666 ## Actual data of this resource. ## This is a Dictionary of Arrays of ShapeFrame2D, indexed by animation name. ## In each Array of ShapeFrame2D, there ought to be one ShapeFrame2D per frame ## in the corresponding animation of the AnimatedSprite2D. ## It means that the shape of this should follow the shape of the SpriteFrames. ## [code] ## { ## '': [ ShapeFrame2D, … ], ## … ## } ## [/code] @export var data := Dictionary() ## Returns the shape frame data for the provided animation and frame. ## This should return null if no data was found. func get_shape_frame(animation_name: StringName, frame: int) -> ShapeFrame2D: if self.data.has(animation_name): var animation_data: Array = self.data[animation_name] as Array if animation_data == null: return null if animation_data.size() > frame: return animation_data[frame] as ShapeFrame2D return null ## Sets a ShapeFrame2D for the provided animation and frame. ## When the AnimatedSprite2D shows this frame, the CollisionShape2D will be ## configured using the provided ShapeFrame2D. See AnimatedShape2D. func set_shape_frame( animation_name: StringName, frame: int, shape_frame: ShapeFrame2D, ): if not self.data.has(animation_name): self.data[animation_name] = Array() var animation_data: Array = self.data[animation_name] as Array frame = max(frame, 0) frame = min(frame, MAXIMUM_FRAMES_AMOUNT) if animation_data.size() <= frame: animation_data.resize(frame + 1) animation_data[frame] = shape_frame ## Removes the ShapeFrame2D datum for the provided key tuple. ## This will let the frame fall back to configured defaults. func remove_shape_frame(animation_name: StringName, frame: int): if not self.data.has(animation_name): return var animation_data: Array = self.data[animation_name] as Array if animation_data.size() > frame: animation_data[frame] = null # Example of a tentative procedural API to make this. # Prefer using the Editor for now. #static func make_dummy() -> ShapeFrames2D: #var dummy := ShapeFrames2D.new() #dummy.data = { #&'jump_ascent': [ #ShapeFrame2D.make_rectangle(Vector2(16, 32), Vector2(0, -17)), #ShapeFrame2D.make_rectangle(Vector2(16, 28), Vector2(0, -15)), #ShapeFrame2D.make_rectangle(Vector2(16, 22), Vector2(0, -12)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #], #&'jump_descent': [ #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 22), Vector2(0, -12)), #ShapeFrame2D.make_rectangle(Vector2(16, 28), Vector2(0, -15)), #ShapeFrame2D.make_rectangle(Vector2(16, 32), Vector2(0, -17)), #], #&'jump_flight': [ #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #ShapeFrame2D.make_rectangle(Vector2(16, 16), Vector2(0, -9)), #], #} #return dummy