├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── icons │ ├── box_switcher_2d.svg.import │ ├── buffered_input_advancer.svg │ ├── buffered_input_advancer.svg.import │ ├── combat_tree.svg.import │ ├── controller.svg │ ├── controller.svg.import │ ├── fighter_body_2d.svg │ ├── fighter_body_2d.svg.import │ ├── hit_attribute.svg │ ├── hit_attribute.svg.import │ ├── hit_attributes.svg.import │ ├── hit_box_2d.svg.import │ ├── hit_state_2d.svg │ ├── hit_state_2d.svg.import │ ├── hit_state_3d.svg │ ├── hit_state_3d.svg.import │ ├── hit_state_controller_2d.svg.import │ ├── hit_state_manager_2d.svg │ ├── hit_state_manager_2d.svg.import │ ├── hit_state_manager_3d.svg │ ├── hit_state_manager_3d.svg.import │ ├── hitbox_2d.svg │ ├── hitbox_2d.svg.import │ ├── hitbox_3d.svg │ ├── hitbox_3d.svg.import │ ├── hurt_box_2d.svg │ ├── hurt_box_2d.svg.import │ ├── input_detector.svg │ ├── input_detector.svg.import │ ├── push_box_2d.svg │ ├── push_box_2d.svg.import │ ├── remove.svg │ ├── remove.svg.import │ ├── state_machine.svg │ ├── state_machine.svg.import │ ├── triple_bar.svg │ ├── triple_bar.svg.import │ ├── warning.svg │ └── warning.svg.import └── images │ ├── .gdignore │ ├── demo.gif │ ├── fray_banner.gif │ ├── fray_banner.png │ ├── fray_logo.svg │ ├── fray_thumbnail.png │ ├── hit_state_inspector.png │ ├── hitbox_tree.png │ └── icon.png ├── docs ├── .gdignore ├── .vitepress │ └── config.mts ├── hit │ ├── creating-hitboxes.md │ ├── managing-hitboxes.md │ └── overview.md ├── index.md ├── input │ ├── detecting-input-sequences.md │ ├── detecting-inputs.md │ ├── overview.md │ └── registering-inputs.md ├── introduction │ ├── installation.md │ └── what-is-fray.md ├── package-lock.json ├── package.json ├── public │ └── assets │ │ ├── guides │ │ ├── add-input-advancer-to-scene.png │ │ ├── add-state-machine-to-scene.png │ │ ├── building-state-machine-root.webp │ │ ├── input-advancer-in-scene.png │ │ ├── inspector-state-machine.png │ │ └── state-machine-in-scene.png │ │ └── icons │ │ ├── fray-logo-dark.svg │ │ └── fray-logo-light.svg └── state-management │ ├── building-a-state-machine.md │ ├── controlling-state-transitions.md │ ├── overview.md │ ├── providing-data-to-states.md │ ├── using-global-transitions.md │ └── using-input-transitions.md ├── lib ├── data_structures │ ├── circular_buffer.gd │ ├── linked_list.gd │ └── reversable_dictionary.gd └── helpers │ ├── child_change_detector.gd │ ├── pseudo_interface.gd │ └── utils │ ├── signal_utils.gd │ └── sorting.gd ├── plugin.cfg ├── plugin.gd └── src ├── fray.gd ├── hit ├── 2d │ ├── hit_state_2d.gd │ ├── hit_state_manager_2d.gd │ └── hitbox_2d.gd ├── 3d │ ├── hit_state_3d.gd │ ├── hit_state_manager_3d.gd │ └── hitbox_3d.gd └── hitbox_attribute.gd ├── input ├── autoloads │ ├── fray_input.gd │ └── fray_input_map.gd ├── controller.gd ├── device │ ├── binds │ │ ├── input_bind.gd │ │ ├── input_bind_action.gd │ │ ├── input_bind_fray_action.gd │ │ ├── input_bind_joy_axis.gd │ │ ├── input_bind_joy_button.gd │ │ ├── input_bind_key.gd │ │ ├── input_bind_mouse_button.gd │ │ └── input_bind_simple.gd │ ├── composites │ │ ├── combination_input.gd │ │ ├── composite_input.gd │ │ ├── conditional_input.gd │ │ ├── group_input.gd │ │ └── simple_input.gd │ ├── device_state.gd │ ├── input_state.gd │ └── virtual_device.gd ├── events │ ├── fray_input_event.gd │ ├── fray_input_event_bind.gd │ └── fray_input_event_composite.gd ├── input_buffer.gd ├── sequence │ ├── input_requirement.gd │ ├── sequence_branch.gd │ └── sequence_tree.gd └── sequence_matcher.gd └── state_mgmt ├── component ├── anim_tracker │ ├── animator_tracker.gd │ ├── animator_tracker_animated_sprite_2d.gd │ ├── animator_tracker_animated_sprite_3d.gd │ ├── animator_tracker_animation_player.gd │ ├── animator_tracker_animation_tree.gd │ └── animator_tracker_tween.gd ├── animation_observer.gd └── buffered_input_advancer.gd ├── state ├── a_star_graph.gd ├── compound_state.gd └── state.gd ├── state_machine.gd └── transition ├── input_transition.gd ├── input_transition_press.gd ├── input_transition_sequence.gd └── state_machine_transition.gd /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize line endings for all files that Git considers text files. 2 | * text=auto eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | 4 | # Godot-specific ignores 5 | .import/ 6 | export.cfg 7 | export_presets.cfg 8 | 9 | # Imported translations (automatically generated from CSV files) 10 | *.translation 11 | 12 | # Mono-specific ignores 13 | .mono/ 14 | data_*/ 15 | mono_crash.*.json 16 | 17 | # Doc-specific ignores 18 | docs/.vitepress/cache/ 19 | docs/node_modules/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Pyxus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fray 2 | 3 |

4 | Fray Logo 5 |

6 | 7 | ![Fray status](https://img.shields.io/badge/status-alpha-red) ![Godot version](https://img.shields.io/badge/godot-v4.2+-blue) ![License](https://img.shields.io/badge/license-MIT-informational) 8 | 9 | ## 📖 About 10 | 11 | Fray is a modular Godot 4 addon designed to aid in the development of action-oriented games. It offers solutions for combatant state management, complex input detection, input buffering, and hitbox organization. If your project requires any of these functionalities you may benefit from using Fray. 12 | 13 | ## ⚠️ IMPORTANT 14 | 15 | **Fray is currently in an alpha state.** 16 | 17 | What does this mean? 18 | 19 | - It has not been tested rigorously enough for me to be comfortable recommending it for use in a serious project. 20 | 21 | - The documentation is incomplete and there is a lack of good examples. 22 | 23 | - Lastly, it is still susceptible to refactors, meaning the API is subject to change. 24 | 25 | That being said, a significant portion of Fray is functional, with any remaining bugs likely being simple oversights rather than major design flaws. If these issues do not concern you, and/or you are interested in testing the framework, please feel free to explore! 26 | 27 | 28 | 29 | ## ✨ Core Features 30 | 31 | ### Resource-Based Hierarchical State Machine 32 | 33 | - Build state machines declaratively in code using the included builder class. 34 | 35 | - Control state transitions using callable transition prerequisites and advance conditions. 36 | 37 | - Extend states and transitions to further control state flow and/or encapsulate game behavior within different states. 38 | 39 | 40 | [comment]: 41 | 42 | ### Composite Input Detection 43 | 44 | - Declaratively describe the many composite inputs featured in action / fighting games ([directional inputs](https://mugen.fandom.com/wiki/Command_input#Directional_inputs), [motion inputs](https://mugen.fandom.com/wiki/Command_input#Motion_input), [charged inputs](https://clips.twitch.tv/FuriousObservantOrcaGrammarKing-c1wo4zhroMVZ9I7y), and [sequence inputs](https://mugen.fandom.com/wiki/Command_input#Sequence_inputs)) using component based approach. 45 | 46 | - Check defined inputs anywhere using included input singleton. 47 | 48 | [comment]: 49 | 50 | 51 | ### Hitbox management 52 | 53 | - Define hitboxes using template class with extendable attributes resource. 54 | 55 | - Organize hitboxes using hit states and hit state managers. 56 | 57 | - Key active hitboxes in animation player using a single property for easier timeline management. 58 | 59 | [comment]: 60 | 61 | ## 📚 Getting Started 62 | 63 | Fray comes with comprehensive documentation integrated with Godot 4's documentation comments. This means you can access explanations for classes and functions directly within the Godot editor. 64 | 65 | For additional guides and resources, check out the [official Fray wiki](https://fray.pyxus.dev). 66 | 67 | ## 📦 Installation 68 | 69 | ### GitHub Release (Recommended, Stable) 70 | 71 | Coming soon... 72 | 73 | ### Asset Library (Recommended, Stable) 74 | 75 | Coming soon... 76 | 77 | ### GitHub Branch (Latest, Unstable, Godot 4.2+) 78 | 79 | 1. Downloaded the [latest main branch](https://github.com/Pyxus/fray/archive/refs/heads/main.zip) 80 | 2. Extract the zip file and move its contents to `addons/fray`. 81 | 3. Enable the addon inside `Project/Project Settings/Plugins` 82 | 83 | --- 84 | 85 | If you would like to know more about installing plugins see the [Official Godot Docs](https://docs.godotengine.org/en/stable/tutorials/plugins/editor/installing_plugins.html). 86 | 87 | ## 📃 Credits 88 | 89 | - Controller Button Images : 90 | -------------------------------------------------------------------------------- /assets/icons/box_switcher_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/box_switcher_2d.svg-7d0da11ebca61da1cfedd0fd0038cef5.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://addons/fray/assets/icons/box_switcher_2d.svg" 13 | dest_files=[ "res://.import/box_switcher_2d.svg-7d0da11ebca61da1cfedd0fd0038cef5.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=true 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=true 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /assets/icons/buffered_input_advancer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /assets/icons/buffered_input_advancer.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://cvbxhtsumglxk" 6 | path="res://.godot/imported/buffered_input_advancer.svg-9564a93a7978416f52254322f62b2ffa.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/buffered_input_advancer.svg" 14 | dest_files=["res://.godot/imported/buffered_input_advancer.svg-9564a93a7978416f52254322f62b2ffa.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/combat_tree.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/combat_tree.svg-3a6885dd643c603283e1d8b1c261441e.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://addons/fray/assets/icons/combat_tree.svg" 13 | dest_files=[ "res://.import/combat_tree.svg-3a6885dd643c603283e1d8b1c261441e.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=true 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=true 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /assets/icons/controller.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/icons/controller.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bpbw01ih06cvh" 6 | path="res://.godot/imported/controller.svg-a926362aa5e951fa4cb208d5484b0c2c.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/controller.svg" 14 | dest_files=["res://.godot/imported/controller.svg-a926362aa5e951fa4cb208d5484b0c2c.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/fighter_body_2d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/icons/fighter_body_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bkhr4erdvlx0j" 6 | path="res://.godot/imported/fighter_body_2d.svg-aadf54b0424eebd2de0d56b0425a83f4.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/fighter_body_2d.svg" 14 | dest_files=["res://.godot/imported/fighter_body_2d.svg-aadf54b0424eebd2de0d56b0425a83f4.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_attribute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/icons/hit_attribute.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://c3k1f422k67x6" 6 | path="res://.godot/imported/hit_attribute.svg-2659a74ad63aeb781c97134b160335e4.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_attribute.svg" 14 | dest_files=["res://.godot/imported/hit_attribute.svg-2659a74ad63aeb781c97134b160335e4.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_attributes.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://dnedynbm3x22h" 6 | path="res://.godot/imported/hit_attribute.svg-40500fac93b7c74c4cd6715b596c7f3e.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_attribute.svg" 14 | dest_files=["res://.godot/imported/hit_attribute.svg-40500fac93b7c74c4cd6715b596c7f3e.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/lossy_quality=0.7 20 | compress/hdr_compression=1 21 | compress/bptc_ldr=0 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_box_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/hit_box_2d.svg-0b1ba9ffc876a36448ac22548fb0a8a1.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://addons/fray/assets/icons/hit_box_2d.svg" 13 | dest_files=[ "res://.import/hit_box_2d.svg-0b1ba9ffc876a36448ac22548fb0a8a1.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=true 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=true 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /assets/icons/hit_state_2d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /assets/icons/hit_state_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bs76g103e6drq" 6 | path="res://.godot/imported/hit_state_2d.svg-7f09395ff53b6d8f454cdabcec7aebf2.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_state_2d.svg" 14 | dest_files=["res://.godot/imported/hit_state_2d.svg-7f09395ff53b6d8f454cdabcec7aebf2.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_state_3d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /assets/icons/hit_state_3d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://yli0twusqnuj" 6 | path="res://.godot/imported/hit_state_3d.svg-eb0fee611169bb21de2b8fadf522f736.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_state_3d.svg" 14 | dest_files=["res://.godot/imported/hit_state_3d.svg-eb0fee611169bb21de2b8fadf522f736.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_state_controller_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/hit_state_controller_2d.svg-17a0bfe3ca7d6971e91e009a4a7c3009.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://addons/fray/assets/icons/hit_state_controller_2d.svg" 13 | dest_files=[ "res://.import/hit_state_controller_2d.svg-17a0bfe3ca7d6971e91e009a4a7c3009.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=true 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=true 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /assets/icons/hit_state_manager_2d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/icons/hit_state_manager_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://b6polbnuoy8dd" 6 | path="res://.godot/imported/hit_state_manager_2d.svg-2d2b6d7e7f9aed9263abd3c2cf988735.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_state_manager_2d.svg" 14 | dest_files=["res://.godot/imported/hit_state_manager_2d.svg-2d2b6d7e7f9aed9263abd3c2cf988735.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hit_state_manager_3d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/icons/hit_state_manager_3d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bpa5q2f8ku053" 6 | path="res://.godot/imported/hit_state_manager_3d.svg-1bc0ba6aeb12554d6b179ac0569b28c7.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hit_state_manager_3d.svg" 14 | dest_files=["res://.godot/imported/hit_state_manager_3d.svg-1bc0ba6aeb12554d6b179ac0569b28c7.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hitbox_2d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /assets/icons/hitbox_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://dx1avye0lsyo8" 6 | path="res://.godot/imported/hitbox_2d.svg-0287744ccbb25ba591ffe1494c93a0de.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hitbox_2d.svg" 14 | dest_files=["res://.godot/imported/hitbox_2d.svg-0287744ccbb25ba591ffe1494c93a0de.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hitbox_3d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /assets/icons/hitbox_3d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://kttvnq85qchg" 6 | path="res://.godot/imported/hitbox_3d.svg-c8f35a7c82cf7bf00d5f34823fcf4d62.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hitbox_3d.svg" 14 | dest_files=["res://.godot/imported/hitbox_3d.svg-c8f35a7c82cf7bf00d5f34823fcf4d62.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/hurt_box_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://cclk6lsklu7fw" 6 | path="res://.godot/imported/hurt_box_2d.svg-a987871b2e0be4c42a8ea61d4f510414.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/hurt_box_2d.svg" 14 | dest_files=["res://.godot/imported/hurt_box_2d.svg-a987871b2e0be4c42a8ea61d4f510414.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/input_detector.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/icons/input_detector.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://1vh8avwbaij2" 6 | path="res://.godot/imported/input_detector.svg-e5c626a063e06f4a6049ae330416c87c.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/input_detector.svg" 14 | dest_files=["res://.godot/imported/input_detector.svg-e5c626a063e06f4a6049ae330416c87c.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/push_box_2d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/icons/push_box_2d.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://b15w0f2tie8k3" 6 | path="res://.godot/imported/push_box_2d.svg-54fc0cbc7615c134fbff2cf95054dc05.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/push_box_2d.svg" 14 | dest_files=["res://.godot/imported/push_box_2d.svg-54fc0cbc7615c134fbff2cf95054dc05.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/remove.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://05b0pf1ew6sa" 6 | path="res://.godot/imported/remove.svg-2da7626be0fb40169774c6b36a4003ed.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/remove.svg" 14 | dest_files=["res://.godot/imported/remove.svg-2da7626be0fb40169774c6b36a4003ed.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/state_machine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /assets/icons/state_machine.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://be0dfn4546vvm" 6 | path="res://.godot/imported/state_machine.svg-729836a6b5958565b9e3dc564c773e44.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/state_machine.svg" 14 | dest_files=["res://.godot/imported/state_machine.svg-729836a6b5958565b9e3dc564c773e44.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/triple_bar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/triple_bar.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://buufiynnq1pcx" 6 | path="res://.godot/imported/triple_bar.svg-7fda2c6e0f53fece856a115f49eacff4.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/triple_bar.svg" 14 | dest_files=["res://.godot/imported/triple_bar.svg-7fda2c6e0f53fece856a115f49eacff4.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/icons/warning.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/warning.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://d4lasdylvk0ss" 6 | path="res://.godot/imported/warning.svg-bd39d80dac5c0519712efd94a6620a81.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/fray/assets/icons/warning.svg" 14 | dest_files=["res://.godot/imported/warning.svg-bd39d80dac5c0519712efd94a6620a81.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /assets/images/.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/.gdignore -------------------------------------------------------------------------------- /assets/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/demo.gif -------------------------------------------------------------------------------- /assets/images/fray_banner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/fray_banner.gif -------------------------------------------------------------------------------- /assets/images/fray_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/fray_banner.png -------------------------------------------------------------------------------- /assets/images/fray_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Combat Framework 25 | 26 | 27 | 28 | 29 | 30 | 31 | Fray 32 | 33 | 34 | Fray 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /assets/images/fray_thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/fray_thumbnail.png -------------------------------------------------------------------------------- /assets/images/hit_state_inspector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/hit_state_inspector.png -------------------------------------------------------------------------------- /assets/images/hitbox_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/hitbox_tree.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/assets/images/icon.png -------------------------------------------------------------------------------- /docs/.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/.gdignore -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Fray Documentation", 6 | description: "Modular combat framework for Godot 4", 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | logo: { 10 | dark: "/assets/icons/fray-logo-dark.svg", 11 | light: "/assets/icons/fray-logo-light.svg" 12 | }, 13 | search: { 14 | provider: "local", 15 | }, 16 | nav: [{ text: "Docs", link: "/introduction/what-is-fray" }], 17 | editLink: { 18 | pattern: "https://github.com/Pyxus/fray/tree/main/docs/:path", 19 | text: "Edit this page on GitHub", 20 | }, 21 | lastUpdated: { 22 | text: "Last updated", 23 | formatOptions: { 24 | dateStyle: "short", 25 | timeStyle: "short", 26 | }, 27 | }, 28 | sidebar: [ 29 | { 30 | text: "Introduction", 31 | collapsed: false, 32 | items: [ 33 | { text: "What is fray?", link: "/introduction/what-is-fray" }, 34 | { text: "Installation", link: "/introduction/installation" }, 35 | ], 36 | }, 37 | { 38 | text: "State Management Module", 39 | collapsed: false, 40 | link: "/state-management/overview", 41 | items: [ 42 | { 43 | text: "Building A State Machine", 44 | link: "/state-management/building-a-state-machine", 45 | }, 46 | { 47 | text: "Providing Data To States", 48 | link: "/state-management/providing-data-to-states", 49 | }, 50 | { 51 | text: "Controlling State Transitions", 52 | link: "/state-management/controlling-state-transitions", 53 | }, 54 | { 55 | text: "Using Input Transitions", 56 | link: "/state-management/using-input-transitions", 57 | }, 58 | { 59 | text: "Using Global Transitions", 60 | link: "/state-management/using-global-transitions", 61 | }, 62 | ], 63 | }, 64 | { 65 | text: "Input Module", 66 | collapsed: false, 67 | link: "/input/overview", 68 | items: [ 69 | { 70 | text: "Registering Inputs", 71 | link: "/input/registering-inputs", 72 | }, 73 | { 74 | text: "Detecting Inputs", 75 | link: "/input/detecting-inputs", 76 | }, 77 | { 78 | text: "Detecting Input Sequences", 79 | link: "/input/detecting-input-sequences", 80 | }, 81 | ], 82 | }, 83 | { 84 | text: "Hit Module", 85 | collapsed: false, 86 | link: "/hit/overview", 87 | items: [ 88 | { 89 | text: "Creating Hitboxes", 90 | link: "/hit/creating-hitboxes", 91 | }, 92 | { 93 | text: "Managing Hitboxes", 94 | link: "/hit/managing-hitboxes", 95 | }, 96 | ], 97 | }, 98 | { 99 | text: "Mixed Module Examples", 100 | collapsed: false, 101 | }, 102 | ], 103 | 104 | socialLinks: [ 105 | { icon: "github", link: "https://github.com/Pyxus/fray" }, 106 | { icon: "twitter", link: "https://twitter.com/pyxus" }, 107 | ], 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /docs/hit/creating-hitboxes.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Creating Hitboxes 7 | -------------------------------------------------------------------------------- /docs/hit/managing-hitboxes.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Managing Hitboxes 7 | -------------------------------------------------------------------------------- /docs/hit/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Hit Module 7 | 8 | ## Purpose Of Module 9 | 10 | While Godot natively provides the necessary tools for hit detection through `Area` nodes, the hitbox configurations in fighting games frequently change. For example, a character's hitboxes when standing may differ from the configuration when crouching, attacking, or getting hit. Hitboxes can also possess various properties and interactions, such as hyper armor, which requires being hit a specific number of times before a character is interrupted, unless the attack is a grab. Managing multiple hitboxes with varying properties can be a tedious task. Therefore, the hit module is designed to simplify this process. 11 | 12 | ## What Is A Hitbox 13 | 14 | In the context of this documentation, the term 'hitbox' refers to all forms of overlap detections, regardless of their purpose within the game. This includes both hitboxes that deal damage (often referred to as attack boxes) and hitboxes that detect damage (sometimes referred to as hurtboxes). It's important to note that Fray does not provide pre-defined implementations for attack or hurt boxes. However, Fray offers all that you need to define and manage your own hitboxes according to your game's requirements. 15 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Fray" 7 | text: "Documentation" 8 | tagline: 🐱‍👤 A modular combat framework for Godot 4.2+ 9 | image: 10 | light: /assets/icons/fray-logo-light.svg 11 | dark: /assets/icons/fray-logo-dark.svg 12 | actions: 13 | - theme: brand 14 | text: Get Started 15 | link: /introduction/what-is-fray 16 | - theme: alt 17 | text: View on GitHub 18 | link: https://github.com/Pyxus/fray 19 | 20 | features: 21 | - title: State Management 22 | details: Flexible hierarchical state machine useful for combatant and general state management. 23 | - title: Complex Input Detection 24 | details: Easily detect the complex inputs featured in many fighting games such as directional inputs, motion inputs, charged inputs, and sequence inputs. 25 | - title: Hitbox Organization 26 | details: Organize hitboxes into discrete hit states which can be keyed in the animation player. 27 | --- 28 | -------------------------------------------------------------------------------- /docs/input/detecting-input-sequences.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Detecting Input Sequences 7 | 8 | ## Input Sequence Matching 9 | 10 | Fray provides a sequence matcher that can be used to detect input sequences. To use this feature, you first need to create a `FraySequenceTree` object, which contains a mapping of sequence names to branch objects. Each sequence name can be associated with multiple `FraySequenceBranch` objects, which allow for alternative inputs. Alternative inputs are useful for creating leniency in a sequence by adding multiple matches for a given sequence name. 11 | 12 | Example Usage: 13 | 14 | ```gdscript 15 | var sequence_tree := FraySequenceTree.new() 16 | var sequence_matcher := FraySequenceMatcher.new() 17 | 18 | func _ready() -> void: 19 | # The following sequence describes the input for Ryu's famous 'Hadouken' attack. 20 | sequence_tree.add("hadouken", FraySequenceBranch.builder() 21 | .first("down").then("down_right").then("right").then("attack") 22 | .build() 23 | ) 24 | 25 | # It can be frustating as a player for an attack to not be performed because of overly strict inputs. 26 | # This is an alternative input for the 'Hadoken' which can match even if the 'down_right' is skipped. 27 | # This supports leniency by catching the case where a player accidently releases 'down' before pressing 'right'. 28 | sequence_tree.add("hadouken", FraySequenceBranch.builder() 29 | .first("down").then("right").then("attack") 30 | .build() 31 | ) 32 | 33 | # This sequence describes Guile's Sonic Boom attack. 34 | # This type of sequence is known as a "charge input" since it requires 'left' to be held for a certain amount of time, 200ms in this example, before the rest of the sequence is performed. 35 | sequence_tree.add("sonic_boom", FraySequenceBranch.builder() 36 | .first("left", 200).then("right").then("attack") 37 | .build() 38 | ) 39 | 40 | sequence_matcher.initialize(sequence_tree) 41 | ``` 42 | 43 | In this example, I've named the sequences after the moves for the sake of clarity. However, while sequences generally stay the same, moves and their names are subject to change. For this reason it's recommend to choose names which describe the sequences rather than the move. A recommended naming convention is the Numpad Notation used in fighting games, which provides a clear and concise way to describe inputs using numbers that correspond to directions on a numeric keypad. You can find more information about Numpad Notation at https://www.dustloop.com/w/Notation. 44 | 45 | ## Understanding Negative Edge 46 | 47 | In some fighting games, there is a special input behavior known as negative edge. Typically, an input sequence is considered valid only when every input is pressed in succession. However, for sequences that support negative edge, the last input in the sequence can be triggered by either a input press or a input release. This means that you can hold the last input in the sequence down, enter the rest of the sequence, and then release it to complete the sequence and trigger the attack. Fray supports this feature, and you can enable it by calling the `enable_negative_edge()` method when building your `FraySequenceBranch` objects. 48 | 49 | Example Usage: 50 | 51 | ```gdscript 52 | # The following sequence describes the input for Ryu's famous 'Hadouken' attack. 53 | sequence_tree.add("hadouken", FraySequenceBranch.builder() 54 | .first("down").then("down_right").then("right").then("attack") 55 | .enable_negative_edge() 56 | .build() 57 | ) 58 | ``` 59 | 60 | ## Using Sequence Matcher 61 | 62 | To perform the match procedure, the sequence matcher uses `FrayInputEvent`s. You can feed events to the matcher using the `FrayInput` singleton's `input_detected` signal. If a matching sequence is found, the matcher will emit a `match_found` signal. 63 | 64 | Example Usage: 65 | 66 | ```gdscript 67 | var sequence_matcher := SequenceMatcher.new() 68 | 69 | func _ready() -> void: 70 | FrayInput.input_detected.connect(_on_FrayInput_input_detected) 71 | sequence_matcher.match_found(_on_SequenceMatcher_match_found) 72 | 73 | func _on_FrayInput_input_detected(input_event: Fray.Input.FrayInputEvent) -> void: 74 | sequence_matcher.read(input_event) 75 | 76 | func _on_SequenceMatcher_match_found(sequence_name: String) -> void: 77 | do_something_with_sequence(sequence_name) 78 | 79 | ``` 80 | -------------------------------------------------------------------------------- /docs/input/detecting-inputs.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Detecting Inputs 7 | 8 | `FrayInput` is a singleton similar to Godot's `Input` singleton. After configuring the input map, this manager can be used to check if binds and composites are pressed using their assigned names. While inputs can be checked per-device, the default behavior is to use device 0, which typically corresponds to the keyboard/mouse and the 'player1' controller. The input manager also contains an input_detected signal, which can be used to detect inputs in a similar manner to Godot's built-in `_input()` virtual method. 9 | 10 | ```gdscript 11 | FrayInput.is_pressed("input_name") 12 | FrayInput.is_just_pressed("input_name") 13 | FrayInput.is_just_released("input_name") 14 | FrayInput.get_axis("negative_input_name", "positive_input_name") 15 | FrayInput.get_strength("input_name") 16 | 17 | ... 18 | 19 | func _ready() -> void: 20 | FrayInput.input_detected.connect(_on_FrayInput_input_dected) 21 | 22 | func _on_FrayInput_input_dected(event: FrayInputEvent) -> void: 23 | if event.input == "input_name" and event.is_pressed(): 24 | do_something() 25 | 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/input/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Input Module 7 | 8 | ## Purpose of the Module 9 | 10 | Action games, particularly fighting games, often associate combatant actions with various input combiatnions. Fray provides its own input singleton, offering a component-based approach to describing composite inputs. Additionally, Fray provides a sequence matcher capable of detecting input sequences. 11 | 12 | ## What Is a Composite Input? 13 | 14 | A composite input is a combination of two or more atomic inputs. Examples of composite inputs commonly found in fighting games include [directional inputs](https://mugen.fandom.com/wiki/Command_input#Directional_inputs), [motion inputs](https://mugen.fandom.com/wiki/Command_input#Motion_input), [charged inputs](https://clips.twitch.tv/FuriousObservantOrcaGrammarKing-c1wo4zhroMVZ9I7y), and [sequence inputs](https://mugen.fandom.com/wiki/Command_input#Sequence_inputs). 15 | -------------------------------------------------------------------------------- /docs/introduction/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Installation 7 | 8 | ::: warning 9 | **Fray is currently in an alpha state.** 10 | 11 | What does this mean? 12 | 13 | - It has not been tested rigorously enough for me to be comfortable recommending it for use in a serious project. 14 | 15 | - The documentation is incomplete and there is a lack of good examples. 16 | 17 | - Lastly, it is still susceptible to refactors, meaning the API is subject to change. 18 | 19 | That being said, a significant portion of Fray is functional, with any remaining bugs likely being simple oversights rather than major design flaws. If these issues do not concern you, and/or you are interested in testing the framework, please feel free to explore! 20 | ::: 21 | 22 | 23 | ## GitHub Release (Recommended, Stable) 24 | 25 | Coming soon... 26 | 27 | ## Asset Library (Recommended, Stable) 28 | 29 | Coming soon... 30 | 31 | ## GitHub Branch (Latest, Unstable, Godot 4.2+) 32 | 33 | 1. Downloaded the [latest main branch](https://github.com/Pyxus/fray/archive/refs/heads/main.zip) 34 | 2. Extract the zip file and move its contents to `addons/fray`. 35 | 3. Enable the addon inside `Project/Project Settings/Plugins` 36 | 37 | --- 38 | 39 | If you would like to know more about installing plugins see the [Official Godot Docs](https://docs.godotengine.org/en/stable/tutorials/plugins/editor/installing_plugins.html). 40 | -------------------------------------------------------------------------------- /docs/introduction/what-is-fray.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # What is Fray? 7 | 8 | `Fray` is a modular Godot 4 addon designed to aid in the development of action-oriented games. It offers solutions for combatant state management, complex input detection, input buffering, and hitbox organization. If your project requires any of these functionalities you may benefit from using Fray. 9 | 10 | ## Modular Design 11 | 12 | Fray is structured into three modules: State Management, Input, and Hit. These modules operate independently and communicate solely through strings, offering the flexibility to replace Fray's solutions with your own and using only what you need. 13 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "vitepress": "^1.0.0-rc.40" 4 | }, 5 | "scripts": { 6 | "docs:dev": "vitepress dev", 7 | "docs:build": "vitepress build", 8 | "docs:preview": "vitepress preview" 9 | } 10 | } -------------------------------------------------------------------------------- /docs/public/assets/guides/add-input-advancer-to-scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/add-input-advancer-to-scene.png -------------------------------------------------------------------------------- /docs/public/assets/guides/add-state-machine-to-scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/add-state-machine-to-scene.png -------------------------------------------------------------------------------- /docs/public/assets/guides/building-state-machine-root.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/building-state-machine-root.webp -------------------------------------------------------------------------------- /docs/public/assets/guides/input-advancer-in-scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/input-advancer-in-scene.png -------------------------------------------------------------------------------- /docs/public/assets/guides/inspector-state-machine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/inspector-state-machine.png -------------------------------------------------------------------------------- /docs/public/assets/guides/state-machine-in-scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pyxus/fray/ab186b335f1fc42837092d16971dca8f4e580b18/docs/public/assets/guides/state-machine-in-scene.png -------------------------------------------------------------------------------- /docs/public/assets/icons/fray-logo-light.svg: -------------------------------------------------------------------------------- 1 | Combat Framework -------------------------------------------------------------------------------- /docs/state-management/building-a-state-machine.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Building A State Machine 7 | 8 | ## What is a Hierarchical State Machine? 9 | 10 | A state machine is a model that represents an entity's various states and the transitions between them in a finite and structured way. They can be visualized as a graph where each point is a state and the connecting lines are transitions. To be hierarchical means that within each state there can exist entire state machines, which is useful when modeling more complex behaviors. 11 | 12 | ## 1. Add State Machine To Scene 13 | 14 | Before you can begin building a state machine first add a `FrayStateMachine` node to the scene. For this guide the node will be named 'FrayStateMachine'. 15 | 16 | ![](/assets/guides/add-state-machine-to-scene.png) 17 | ![](/assets/guides/state-machine-in-scene.png) 18 | 19 | ## 2. Build State Machine Root 20 | 21 | All state machines require a `FrayCompoundState` root in order to function. Compound states are responsible for describing the set of states and transitions present within the state machine. 22 | 23 | The compound state can be configured directly through methods such as `add_state()` and `add_transition()`. However, it is recommended to construct states using the included `FrayCompoundState.Builder`class. An instance of the class can be obtained through the static `FrayCompoundState.builder()` method. 24 | 25 | ```gdscript 26 | # Explicit Configuration 27 | var root := FrayCompoundState.new() 28 | root.add_state("a", FrayState.new()) 29 | root.add_state("b", FrayState.new()) 30 | root.add_state("c", FrayState.new()) 31 | root.add_transition("a", "b", FrayTransition.new()) 32 | root.add_transition("b", "c", FrayTransition.new()) 33 | root.add_transition("c", "a", FrayTransition.new()) 34 | root.start_state = "a" 35 | 36 | # Builder Configuration 37 | var root := (FrayCompoundState.builder() 38 | .start_at("a") 39 | .transition("a", "b") 40 | .transition("b", "c") 41 | .transition("c", "a") 42 | .build() 43 | ) 44 | ``` 45 | 46 | ![](/assets/guides/building-state-machine-root.webp) 47 | 48 | With the exception of `build()`, all builder methods return an instance of the builder, allowing for chain method calls. Additionally, the builder will create a state instance whenever a state is mentioned, meaning it is not required to add a state before using it. However, the builder's `add_state()` method is required when adding custom states. 49 | 50 | ## 3. Initialize State Machine 51 | 52 | Before a state machine can be used it needs to be initialized. The `initialize()` method takes 2 arguments. First a context, which is a dictionary that can be used to provide read-only data to custom states added to the system. Second, a `FrayCompoundState`, which serves as the root of the state machine. 53 | 54 | ```gdscript 55 | state_machine.initialize({}, FrayCompoundState.builder() 56 | .transition("a", "b", {auto_advance=true}) 57 | .transition("b", "c", {auto_advance=true}) 58 | .transition("c", "a", {auto_advance=true}) 59 | .build() 60 | ) 61 | ``` 62 | 63 | Notice `transition()` takes an optional 3rd argument which is a dictionary that allows properties belonging to `FrayStateMachineTransition`. For this example auto advance is enabled as a simple way to see the state machine in action. 64 | 65 | ## 4. Observing State Transitions 66 | 67 | Now that the state machine has been initialized state transitions can be observed using the `state_changed` signal on the state machine. 68 | 69 | ```gdscript 70 | func _ready() -> void: 71 | state_machine.state_changed.connect(_on_StateMachine_state_changed) 72 | 73 | func _on_StateMachine_state_changed(from: StringName, to: StringName) -> void: 74 | print("State transitioned from '%s' to '%s'" % [from, to]) 75 | ``` 76 | 77 | Before you can see the newly created state machine in action first select the state machine node in the tree and then from the inspector set the `active` property to true. Additionally, set `advance_mode` to manual. At the moment the state machine has nothing to limit its transitions so allowing it to advance automatically will result in the state machine cycling to the next avaialble state every frame. 78 | 79 | ![](/assets/guides/inspector-state-machine.png) 80 | 81 | Lastly since the state machine is set to manual mode, call the state machine's `advance` method inside of the `_process()` whenever `ui_select` is just pressed. 82 | 83 | ```gdscript 84 | func _process(delta: float): 85 | if Input.is_action_just_pressed("ui_select"): 86 | state_machine.advance() 87 | ``` 88 | 89 | Now whenever space is pressed the state will change and print a message informing that the current state has changed. 90 | 91 | Alternatively the `print_adj()` method can be used to quickly print the state of a state machine for debug purposes. 92 | 93 | ```gdscript 94 | func _process(): 95 | if Input.is_action_just_pressed("ui_select"): 96 | state_machine.advance() 97 | state_machine.get_root().print_adj() 98 | ``` 99 | 100 | ## Conclusion 101 | 102 | You should now have a script resembling the one below. This script assumes that the state machine is a direct child of the attached node. 103 | 104 | ```gdscript 105 | extends Node 106 | 107 | @onready var state_machine: FrayStateMachine = $FrayStateMachine 108 | 109 | func _ready() -> void: 110 | state_machine.state_changed.connect(_on_StateMachine_state_changed) 111 | state_machine.initialize({}, FrayCompoundState.builder() 112 | .transition("a", "b", {auto_advance=true}) 113 | .transition("b", "c", {auto_advance=true}) 114 | .transition("c", "a", {auto_advance=true}) 115 | .build() 116 | ) 117 | 118 | func _process(delta: float): 119 | if Input.is_action_just_pressed("ui_select"): 120 | state_machine.advance() 121 | 122 | func _on_StateMachine_state_changed(from: StringName, to: StringName) -> void: 123 | print("State transitioned from '%s' to '%s'" % [from, to]) 124 | ``` 125 | -------------------------------------------------------------------------------- /docs/state-management/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # State Management Module 7 | 8 | ## Purpose of Module 9 | 10 | State management is vital when designing complex game mechanics. It involves defining the set of states a game entity can occupy and specifying the corresponding transitions to other states. In the context of action games, this encompasses mapping buttons to actions and determining which actions are accessible from any given state. 11 | 12 | Fray provides an extendable general purpose hierarchical state machine which can be used to define state for entities such as combatants. 13 | -------------------------------------------------------------------------------- /docs/state-management/providing-data-to-states.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | outline: [2, 6] 4 | --- 5 | 6 | # Providing Data To States 7 | 8 | ## Context 9 | 10 | Context is read-only data provided on initialization, accessible to all states during the initialization process. It can be used to provide states with initial values and objects for them to observe or command. While it's technically possible to share an object with mutable data across states within the context, it's recommended to avoid communicating between states using mutable data. Ideally, a state machine models well-defined states and transitions. When states rely on mutable data, it can become challenging to reason about the system, as states are no longer independent. 11 | 12 | ```gdscript 13 | state_machine.initialize({ 14 | player_max_health=100 15 | }, ...) 16 | ``` 17 | 18 | ```gdscript 19 | class_name CustomState 20 | 21 | var player_max_health: float 22 | 23 | func _ready(context: Dictionary) -> void: 24 | player_max_health = context.get("player_max_health", 0.0) 25 | ``` 26 | 27 | ## Node Referencing 28 | 29 | Nodes, like any other objects, can be provided to a state machine within the context. However, it is also possible for states to fetch node references using their `get_node()`, `get_node_of_type()`, and `get_nodes_of_type()` methods. `get_node()` will fetch a node relative to the state machine, while the 'type' variants will fetch nodes that are direct children of the state machine. 30 | 31 | ```gdscript 32 | class_name CustomState 33 | 34 | var anim_player: AnimationPlayer 35 | 36 | func _ready(context: Dictionary) -> void: 37 | anim_player = get_node_of_type(AnimationPlayer) 38 | ``` 39 | 40 | This allows states to interact with and observe nodes within the scene providing the ability to encapsulate control of the game's behavior within different states. 41 | 42 | ## Object Constructor 43 | 44 | When essential data is required for a state to function, it is recommended to provide this data through the state's constructor. 45 | 46 | ```gdscript 47 | class_name CustomState 48 | extends FrayState 49 | 50 | var dep 51 | 52 | func _init(dependency) -> void: 53 | dep = dependency 54 | ``` 55 | 56 | This ensures that the state receives the necessary information during its initialization. 57 | -------------------------------------------------------------------------------- /docs/state-management/using-global-transitions.md: -------------------------------------------------------------------------------- 1 | # Using Global Transitions 2 | 3 | ## What Are Global Transitions? 4 | 5 | Global transitions are a feature designed for convenience, enabling automatic connections between states based on transition rules. Within the state machine, states can be assigned tags, and transition rules can be established between tags. States with a specified 'from_tag' will automatically have transitions set up to states with a corresponding 'to_tag.' This simplifies the process of managing common transitions and reduces the need for repetitive code. 6 | 7 | ## How To Use Global Transitions 8 | 9 | To leverage global transitions, start by assigning tags to relevant states using the builder's `tag()` method. A state can be assigned multiple tags as needed. Next, specify rules using the `add_rule()` method. Finally, make a state global by supplying it with a global transition. A state can have multiple global transitions, and when a rule is matched, a transition will be attempted for each specified global transition. 10 | 11 | ```gdscript 12 | state_machine.initialize({}, FrayCompoundState.builder() 13 | .tag("idle", "normal") 14 | .tag("attack", "normal") 15 | .tag("sp_attack", "special") 16 | .add_rule("normal", "special") 17 | .transition_global("sp_attack") 18 | .transition("idle", "attack") 19 | .build() 20 | ) 21 | ``` 22 | 23 | Global versions also exist for the builder's `transition_press()` and `transition_sequence()` methods. 24 | 25 | ```gdscript 26 | state_machine.initialize({}, FrayCompoundState.builder() 27 | .tag("idle", "normal") 28 | .tag("attack", "normal") 29 | .tag("sp_attack", "special") 30 | .add_rule("normal", "special") 31 | .transition_sequence_global("sp_attack", {sequence="214K"}) 32 | .transition_press("idle", "attack", {input="punch_button"}) 33 | .build() 34 | ) 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/state-management/using-input-transitions.md: -------------------------------------------------------------------------------- 1 | # Using Input Transitions 2 | 3 | ## What Is An Input Transition? 4 | 5 | Input transitions, distinct from [state machine transition inputs](/state-management/controlling-state-transitions#define-accepted-input-custom-transitions), are `FrayStateMachineTransition` implementations used to describe transitions based on input from devices such as a controller. Fray offers two types of input transitions: `FrayInputTransitionPress` and `FrayInputTransitionSequence`. 6 | 7 | ## How Do Input Transitions Work? 8 | 9 | Input transitions do not automatically respond to device-inputs. All an input transition does is check if the provided state machine input contains specific entires satisfying the transition's configuration. Meaning the actual device-input must be checked for externally and then fed to the state machine though the `advance()` method. 10 | 11 | ```gdscript 12 | state_machine.advance({ 13 | input="jump", 14 | is_pressed=true, 15 | time_since_last_input=0, time_held=0 16 | }) 17 | 18 | state_machine.advance({ 19 | sequence="hadoken", 20 | time_since_last_input=0 21 | }) 22 | ``` 23 | 24 | ::: tip Note 25 | In a previous guide the state machine's advance mode was set to 'Manual' before calling `advance()`. However, advance can be called regardless of advance mode. There is no issue with having the state machine automatically advance while trying to advance manually, especially when supplying input data. 26 | ::: 27 | 28 | ## Using Press Transitions 29 | 30 | `FrayInputTransitionPress` is used to define a transition triggered by a single press, like a button on a controller or a key on a keyboard. Press transitions can be created using the builder's `transition_press()` method, with the input name provided in the transition configuration. 31 | 32 | ```gdscript 33 | state_machine.initialize({}, FrayCompoundState.builder() 34 | .transition_press("idle", "attack", {input="punch_button"}) 35 | .build() 36 | ) 37 | ``` 38 | 39 | ## Using Sequence Transitions 40 | 41 | `FrayInputTransitionSequence` describes a transition triggered by a sequence of presses. Sequence transitions can be created using the builder's `transition_sequence()` method, with the sequence name provided in the transition configuration. 42 | 43 | ```gdscript 44 | state_machine.initialize({}, FrayCompoundState.builder() 45 | .transition_sequence("idle", "hadoken", {sequence="236P"}) 46 | .build() 47 | ) 48 | ``` 49 | 50 | ## Taking Advantage of The Input Advancer 51 | 52 | Fray provides a `BufferedInputAdvancer`, a node designed to automatically feed buffered input data to a state machine. If an input is accepted by the state machine, the advancer will stop processing new inputs for the current frame. 53 | 54 | To use the advancer, first add it as a child of the desired state machine. Then, buffer the desired inputs using the included `buffer_press()` and `buffer_sequence()` methods. 55 | 56 | ![](/assets/guides/input-advancer-in-scene.png) 57 | 58 | ```gdscript 59 | if Input.is_action_just_pressed("ui_select"): 60 | advancer.buffer_press("jump_button") 61 | ``` 62 | 63 | Additionally, the advancer can be paused to control the timing of input feeding. Note that pausing doesn't stop the advancer; inputs can still be buffered during pauses, and they will expire if they exceed the maximum buffer time. This behavior allows the timeframe during which device inputs have the opportunity to trigger state transitions to be defined. Even if input buffering is not required, the advancer offers a friendly interface for feeding device inputs to a state machine. 64 | 65 | ```gdscript 66 | advancer.paused = false 67 | ``` -------------------------------------------------------------------------------- /lib/data_structures/circular_buffer.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | ## docstring 3 | 4 | #signals 5 | 6 | #enums 7 | 8 | #constants 9 | 10 | #preloaded scripts and scenes 11 | 12 | #exported variables 13 | 14 | var capacity: int = 1: 15 | set(value): 16 | if value <= 0: 17 | push_error("Circular buffer capacity can not be smaller than 1") 18 | 19 | capacity = max(1, value) 20 | _buffer.resize(capacity) 21 | _read_index = 0 22 | _write_index = 0 23 | 24 | 25 | var _read_index: int 26 | var _write_index: int 27 | var _buffer: Array 28 | var _is_full: bool 29 | 30 | #onready variables 31 | 32 | 33 | func _init(buffer_capacity: int = 1) -> void: 34 | set_capacity(buffer_capacity) 35 | 36 | #built-in virtual _ready method 37 | 38 | #remaining built-in virtual methods 39 | 40 | func set_capacity(value: int) -> void: 41 | if value <= 0: 42 | push_error("Circular buffer capacity can not be smaller than 1") 43 | 44 | capacity = max(1, value) 45 | _buffer.resize(capacity) 46 | _read_index = 0 47 | _write_index = 0 48 | 49 | 50 | func add(item) -> bool: 51 | if not _is_full: 52 | _buffer[_write_index] = item 53 | _write_index = (_write_index + 1) % capacity 54 | _is_full = _write_index == _read_index 55 | return true 56 | return false 57 | 58 | 59 | func insert(position: int, item): 60 | _buffer[position] = item 61 | 62 | 63 | func peek(): 64 | return _buffer[_read_index] 65 | 66 | 67 | func peek_at(position: int): 68 | return _buffer[position] 69 | 70 | 71 | func read(): 72 | if not empty(): 73 | var item = peek() 74 | _read_index = (_read_index + 1) % capacity 75 | _is_full = false 76 | return item 77 | return null 78 | 79 | 80 | func full() -> bool: 81 | return _is_full 82 | 83 | 84 | func empty() -> bool: 85 | return _write_index == _read_index and not _is_full 86 | 87 | 88 | func get_count() -> int: 89 | return _write_index - _read_index 90 | 91 | 92 | func get_read_index() -> int: 93 | return _read_index 94 | 95 | 96 | func get_write_index() -> int: 97 | return _write_index 98 | 99 | 100 | func clear() -> void: 101 | _read_index = 0 102 | _write_index = 0 103 | _is_full = false 104 | 105 | 106 | func _iter_init(arg): 107 | return not empty() 108 | 109 | 110 | func _iter_next(arg): 111 | return not empty() 112 | 113 | 114 | func _iter_get(arg): 115 | return read() 116 | 117 | #signal methods 118 | 119 | #inner class 120 | -------------------------------------------------------------------------------- /lib/data_structures/linked_list.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | 3 | var _head: ListNode 4 | var _itter_current: ListNode 5 | var _count: int = 0 6 | 7 | func add(data) -> void: 8 | if _head == null: 9 | _head = ListNode.new(data) 10 | _count = 1 11 | else: 12 | var next_node := _head 13 | while next_node._next != null: 14 | next_node = next_node._next 15 | next_node._next = ListNode.new(data) 16 | _count += 1 17 | 18 | func print_list() -> void: 19 | if _head == null: 20 | print("[]") 21 | return 22 | 23 | var string := "" 24 | var next_node := _head 25 | while next_node != null: 26 | string += "[" + next_node.data.to_string() + "]" 27 | next_node = next_node.get_next() 28 | if next_node != null: 29 | string += " --> " 30 | print(string) 31 | 32 | 33 | func remove_first() -> void: 34 | if _head != null: 35 | _head = _head._next 36 | _count -= 1 37 | 38 | 39 | func get_head() -> ListNode: 40 | return _head 41 | 42 | 43 | func get_count() -> int: 44 | return _count 45 | 46 | 47 | func empty() -> bool: 48 | return _head == null 49 | 50 | 51 | func clear() -> void: 52 | _head = null 53 | _count = 0 54 | 55 | 56 | func _iter_init(arg): 57 | _itter_current = _head 58 | return _head != null 59 | 60 | 61 | func _iter_next(arg): 62 | _itter_current = _itter_current._next 63 | return _itter_current != null 64 | 65 | 66 | func _iter_get(arg): 67 | return _itter_current.data 68 | 69 | 70 | class ListNode: 71 | extends RefCounted 72 | 73 | ## Type: Variant 74 | var data 75 | 76 | ## Type: ListNode 77 | var _next: RefCounted 78 | 79 | func _init(node_data) -> void: 80 | data = node_data 81 | 82 | ## Returns: ListNode 83 | func get_next() -> RefCounted: 84 | return _next 85 | -------------------------------------------------------------------------------- /lib/data_structures/reversable_dictionary.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | 3 | var _main_dict: Dictionary 4 | var _reverse_dict: Dictionary 5 | 6 | 7 | func add(key, value) -> void: 8 | _main_dict[key] = value 9 | _reverse_dict[value] = key 10 | 11 | 12 | func clear() -> void: 13 | _main_dict.clear() 14 | _reverse_dict.clear() 15 | 16 | 17 | func is_empty() -> bool: 18 | return _main_dict.is_empty() 19 | 20 | 21 | func erase_key(key) -> bool: 22 | if _main_dict.has(key): 23 | var dict_item = _main_dict[key] 24 | _main_dict.erase(key) 25 | _reverse_dict.erase(dict_item) 26 | return true 27 | return false 28 | 29 | 30 | func erase_value(value) -> bool: 31 | if _reverse_dict.has(value): 32 | var dict_item = _reverse_dict[value] 33 | _reverse_dict.erase(value) 34 | _main_dict.erase(dict_item) 35 | return true 36 | return false 37 | 38 | 39 | func get_value(key, default = null): 40 | if _main_dict.has(key): 41 | return _main_dict[key] 42 | return default 43 | 44 | 45 | func get_key(value, default = null): 46 | if _reverse_dict.has(value): 47 | return _reverse_dict[value] 48 | return default 49 | 50 | 51 | func has_key(key) -> bool: 52 | return _main_dict.has(key) 53 | 54 | 55 | func has_value(value) -> bool: 56 | return _reverse_dict.has(value) 57 | 58 | 59 | func has_all_keys(keys: Array) -> bool: 60 | if _main_dict.is_empty(): 61 | return false 62 | 63 | for key in keys: 64 | if not _main_dict.has(key): 65 | return false 66 | 67 | return true 68 | 69 | 70 | func has_all_values(values: Array) -> bool: 71 | if _reverse_dict.is_empty(): 72 | return false 73 | 74 | for value in values: 75 | if not _reverse_dict.has(value): 76 | return false 77 | 78 | return true 79 | 80 | 81 | func hash_main() -> int: 82 | return _main_dict.hash() 83 | 84 | 85 | func hash_reverse() -> int: 86 | return _reverse_dict.hash() 87 | 88 | 89 | func size() -> int: 90 | return _main_dict.size() 91 | 92 | 93 | func keys() -> Array: 94 | return _main_dict.keys() 95 | 96 | 97 | func values() -> Array: 98 | return _main_dict.values() 99 | -------------------------------------------------------------------------------- /lib/helpers/child_change_detector.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | 3 | signal child_changed(node: Node, change: Change) 4 | 5 | enum Change{ 6 | ADDED, 7 | REMOVED, 8 | RENAMED, 9 | SCRIPT_CHANGED, 10 | } 11 | 12 | const SignalUtils = preload("utils/signal_utils.gd") 13 | 14 | var _parent: Node 15 | 16 | func _init(parent: Node) -> void: 17 | if not parent.is_inside_tree(): 18 | push_error("Failed to initialize. Given node parent is not inside of tree.") 19 | return 20 | 21 | var scene_tree = parent.get_tree() 22 | _parent = parent 23 | SignalUtils.safe_connect(scene_tree.node_added, _on_SceneTree_node_added) 24 | SignalUtils.safe_connect(scene_tree.node_removed, _on_SceneTree_node_removed) 25 | 26 | 27 | func _on_SceneTree_node_added(node: Node) -> void: 28 | if node.get_parent() == _parent: 29 | child_changed.emit(node, Change.ADDED) 30 | SignalUtils.safe_connect(node.script_changed, _on_ChildNode_script_changed, [node]) 31 | SignalUtils.safe_connect(node.renamed, _on_ChildNode_renamed, [node]) 32 | 33 | 34 | func _on_SceneTree_node_removed(node: Node) -> void: 35 | if node.get_parent() == _parent: 36 | child_changed.emit(node, Change.REMOVED) 37 | SignalUtils.safe_disconnect(node.script_changed, _on_ChildNode_script_changed) 38 | SignalUtils.safe_disconnect(node.renamed, _on_ChildNode_renamed) 39 | 40 | 41 | func _on_ChildNode_script_changed(node: Node) -> void: 42 | child_changed.emit(node, Change.SCRIPT_CHANGED) 43 | 44 | 45 | func _on_ChildNode_renamed(node: Node) -> void: 46 | child_changed.emit(node, Change.RENAMED) 47 | -------------------------------------------------------------------------------- /lib/helpers/pseudo_interface.gd: -------------------------------------------------------------------------------- 1 | extends Object 2 | 3 | const _interfaces = { 4 | "IHitbox" : { 5 | "methods" : ["activate", "deactivate", "set_source"], 6 | "signals" : ["hitbox_entered", "hitbox_exited"], 7 | }, 8 | } 9 | 10 | func _init() -> void: 11 | assert(false, "Thiss class only provides pseudo-interface membership test and is not intended to be instantiated") 12 | free() 13 | 14 | 15 | static func implements(obj: Object, interface: String) -> bool: 16 | if not _interfaces.has(interface): 17 | push_error("Failed to check interface. Interface '%s' is not defined." % interface) 18 | return false 19 | 20 | return _get_missing_members(obj, interface).is_empty() 21 | 22 | 23 | static func assert_implements(obj: Object, interface: String) -> void: 24 | var script_name: String = obj.get_script().resource_path.get_file() 25 | var has_implementation := implements(obj, interface) 26 | 27 | if not has_implementation: 28 | for missing_member in _get_missing_members(obj, interface): 29 | push_error(missing_member) 30 | 31 | assert(has_implementation, "Script '%s' does not implement interface '%s'" % [script_name, interface]) 32 | 33 | 34 | static func _get_missing_members(obj: Object, interface: String) -> PackedStringArray: 35 | var script_name: String = obj.get_script().resource_path.get_file() 36 | var missing_members: PackedStringArray = [] 37 | 38 | for method in _interfaces[interface]["methods"]: 39 | if not obj.has_method(method): 40 | missing_members.append("'%s' does not implement interface method '%s.%s'" % [script_name, interface, method]) 41 | 42 | for sig in _interfaces[interface]["signals"]: 43 | if not obj.has_signal(sig): 44 | missing_members.append("'%s' does not implement interface signal '%s.%s'" % [script_name, interface, sig]) 45 | 46 | return missing_members 47 | -------------------------------------------------------------------------------- /lib/helpers/utils/signal_utils.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | 3 | ## Connects signal only if not already connected. 4 | static func safe_connect( 5 | sig: Signal, 6 | method: Callable, binds: Array = [], flags: int = 0 7 | ) -> int: 8 | if not sig.is_connected(method): 9 | for bind in binds: 10 | method = method.bind(bind) 11 | return sig.connect(method, flags) 12 | return OK 13 | 14 | ## Disconnects signal only if already connected. 15 | static func safe_disconnect( 16 | sig: Signal, 17 | method: Callable) -> void: 18 | if sig.is_connected(method): 19 | sig.disconnect(method) 20 | -------------------------------------------------------------------------------- /lib/helpers/utils/sorting.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | 3 | static func sort_ascending(t1, t2, property: String = "") -> bool: 4 | if property.is_empty(): 5 | if t1[property] > t2[property]: 6 | return true 7 | else: 8 | return false 9 | else: 10 | if t1 > t2: 11 | return true 12 | else: 13 | return false 14 | -------------------------------------------------------------------------------- /plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Fray - Combat Framework" 4 | description="Provides tools which aid in the implementation of action/fighting game style combat." 5 | author="Pyxus" 6 | version="2.0.0-alpha" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | var _added_types: Array 5 | var _added_singletons: Array 6 | var _editor_interface := get_editor_interface() 7 | var _editor_settings := _editor_interface.get_editor_settings() 8 | 9 | func _ready() -> void: 10 | pass 11 | 12 | 13 | func _enter_tree() -> void: 14 | add_autoload_singleton("FrayInputMap", "res://addons/fray/src/input/autoloads/fray_input_map.gd") 15 | add_autoload_singleton("FrayInput", "res://addons/fray/src/input/autoloads/fray_input.gd") 16 | 17 | 18 | func _exit_tree(): 19 | for singleton in _added_singletons: 20 | remove_autoload_singleton(singleton) 21 | 22 | for type in _added_types: 23 | remove_custom_type(type) 24 | 25 | 26 | func add_autoload_singleton(name: String, path: String) -> void: 27 | super(name, path) 28 | _added_singletons.append(name) 29 | 30 | 31 | func add_custom_type(type: String, base: String, script: Script, icon: Texture2D) -> void: 32 | super(type, base, script, icon) 33 | _added_types.append(type) 34 | -------------------------------------------------------------------------------- /src/fray.gd: -------------------------------------------------------------------------------- 1 | class_name Fray 2 | extends Object 3 | ## A collection of static helper functions and constants 4 | 5 | ## Converts time in [kbd]frames[/kbd] to time in milliseconds. 6 | ## [br] 7 | ## One frame is equal to 1 / fps 8 | ## [br] 9 | ## if [kbd]fps[/kbd] is a value less than or equal to 0 then fps defaults to [member Engine.physics_ticks_per_second]. 10 | static func frame_to_msec(frames: int, fps: int = -1) -> int: 11 | fps = Engine.physics_ticks_per_second if fps <= 0 else fps 12 | return floori((frames / float(Engine.physics_ticks_per_second)) * 1000) 13 | 14 | ## Converts time in [kbd]msec[/kbd] to time in frames. 15 | ## [br] 16 | ## One frame is equal to 1 / fps 17 | ## [br] 18 | ## if [kbd]fps[/kbd] is a value less than or equal to 0 then fps defaults to [member Engine.physics_ticks_per_second]. 19 | static func msec_to_frame(msec: int, fps: int = -1) -> int: 20 | fps = Engine.physics_ticks_per_second if fps <= 0 else fps 21 | return ceili((Engine.physics_ticks_per_second * msec) / 1000.0) 22 | 23 | ## Converts time in [kbd]frames[/kbd] to time in seconds. 24 | ## [br] 25 | ## One frame is equal to 1 / fps 26 | ## [br] 27 | ## if [kbd]fps[/kbd] is a value less than or equal to 0 then fps defaults to [member Engine.physics_ticks_per_second]. 28 | static func frame_to_sec(frames: int, fps: int = -1) -> int: 29 | fps = Engine.physics_ticks_per_second if fps <= 0 else fps 30 | return floori(frames / float(Engine.physics_ticks_per_second)) 31 | 32 | ## Converts time in [kbd]sec[/kbd] to time in frames. 33 | ## [br] 34 | ## One frame is equal to 1 / fps 35 | ## [br] 36 | ## if [kbd]fps[/kbd] is a value less than or equal to 0 then fps defaults to [member Engine.physics_ticks_per_second]. 37 | static func sec_to_frame(sec: int, fps: int = -1) -> int: 38 | fps = Engine.physics_ticks_per_second if fps <= 0 else fps 39 | return ceili(Engine.physics_ticks_per_second * sec) 40 | 41 | ## Converts time in [kdb]milliseconds[/kbd] to time in seconds. 42 | static func msec_to_sec(msec: int) -> float: 43 | return msec / 1000.0 44 | 45 | ## Converts time in [kdb]seconds[/kbd] to time in milliseconds. 46 | static func sec_to_msec(sec: float) -> int: 47 | return roundi(sec * 1000) 48 | 49 | ## Returns true if given value is of the expected type. 50 | static func is_of_type(value, expected_type: Script) -> bool: 51 | if not value is Object: 52 | return false 53 | 54 | return is_of_type_script(value.get_script(), expected_type) 55 | 56 | ## Returns true if given scripts are of the same type. 57 | static func is_of_type_script(script: Script, expected_type: Script) -> bool: 58 | if script == null: 59 | return false 60 | 61 | return true if script == expected_type else is_of_type_script(script.get_base_script(), expected_type) 62 | -------------------------------------------------------------------------------- /src/hit/2d/hit_state_manager_2d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/hit_state_manager_2d.svg") 3 | class_name FrayHitStateManager2D 4 | extends Node2D 5 | ## Node used to enforce discrete hit states. 6 | ## 7 | ## This node only allows one hit state child to be active at a time. 8 | ## When [member FrayHitState2D.active_hitboxes] changes all other hit states will be deactivate. 9 | 10 | const _ChildChangeDetector = preload("res://addons/fray/lib/helpers/child_change_detector.gd") 11 | const _SignalUtils = preload("res://addons/fray/lib/helpers/utils/signal_utils.gd") 12 | 13 | ## Emitted when the received [kbd]detected_hitbox[/kbd] enters the child [kbd]detector_hitbox[/kbd]. 14 | ## Requires child [FrayHitbox2D.monitoring] to be set to [code]true[/code]. 15 | signal hitbox_intersected(detector_hitbox: FrayHitbox2D, detected_hitbox: FrayHitbox2D) 16 | 17 | ## Emitted when the received [kbd]detected_hitbox[/kbd] enters the child [kbd]detector_hitbox[/kbd]. 18 | ## Requires child [FrayHitbox2D.monitoring] to be set to [code]true[/code]. 19 | signal hitbox_separated(detector_hitbox: FrayHitbox2D, detected_hitbox: FrayHitbox2D) 20 | 21 | ## Source of the [FrayHitbox2D]s beneath this node. 22 | ## [br] 23 | ## This is a convinience that allows you to set the hitbox source from the inspector. 24 | ## However, this property only allows nodes to be used as sources. 25 | ## Any object can be used by calling [member set_hitbox_source]. 26 | @export var source: Node = null: 27 | set(value): 28 | source = value 29 | 30 | for child in get_children(): 31 | if child is FrayHitState2D: 32 | child["metadata/_editor_prop_ptr_source"] = ( 33 | child.get_path_to(source) if source else NodePath() 34 | ) 35 | set_hitbox_source(value) 36 | 37 | var _current_state: StringName = "" 38 | var _cc_detector: _ChildChangeDetector 39 | 40 | func _ready() -> void: 41 | if Engine.is_editor_hint(): 42 | return 43 | 44 | for child in get_children(): 45 | if child is FrayHitState2D: 46 | child.set_hitbox_source(source) 47 | child.hitbox_intersected.connect(_on_Hitstate_hitbox_intersected) 48 | child.hitbox_intersected.connect(_on_Hitstate_hitbox_separated) 49 | child.active_hitboxes_changed.connect(_on_HitState_active_hitboxes_changed.bind(child)) 50 | 51 | 52 | func _get_configuration_warnings() -> PackedStringArray: 53 | var warnings: PackedStringArray = [] 54 | 55 | if get_children().any(func(node): node is FrayHitState2D): 56 | warnings.append("This node has no hit states so there is nothing to manage. Consider adding a FrayHitState2D as a child.") 57 | 58 | return warnings 59 | 60 | 61 | func _enter_tree() -> void: 62 | if Engine.is_editor_hint(): 63 | _cc_detector = _ChildChangeDetector.new(self) 64 | _cc_detector.child_changed.connect(_on_ChildChangeDetector_child_changed) 65 | 66 | 67 | func _set(property: StringName, value) -> bool: 68 | match property: 69 | "metadata/_editor_prop_ptr_source": 70 | var node: Node = get_node_or_null(value) as Node 71 | if value.is_empty() or node == null: 72 | set_meta("_editor_prop_ptr_source", NodePath()) 73 | source = null 74 | else: 75 | set_meta("_editor_prop_ptr_source", value) 76 | source = node 77 | return true 78 | return false 79 | 80 | ## Returns the name of the current hit state 81 | func get_current_state() -> StringName: 82 | return _current_state 83 | 84 | ## Returns a reference to the current state. Returns null if no state is set. 85 | func get_current_state_obj() -> FrayHitState2D: 86 | return get_node_or_null(NodePath(_current_state)) as FrayHitState2D 87 | 88 | ## Sets the [kbd]source[/kbd] of all [FrayHitbox2D] beneath this node. 89 | func set_hitbox_source(source: Object) -> void: 90 | for child in get_children(): 91 | if child is FrayHitState2D: 92 | child.set_hitbox_source(source) 93 | 94 | 95 | func _set_current_state(new_current_state: StringName) -> void: 96 | if new_current_state != _current_state: 97 | _current_state = new_current_state 98 | 99 | for child in get_children(): 100 | if child is FrayHitState2D and child.name != _current_state: 101 | child.deactivate() 102 | print(child.name) 103 | 104 | 105 | func _on_ChildChangeDetector_child_changed(node: Node, change: _ChildChangeDetector.Change) -> void: 106 | if change == _ChildChangeDetector.Change.ADDED: 107 | set_deferred("source", source) 108 | 109 | if node is FrayHitState2D and change != _ChildChangeDetector.Change.REMOVED: 110 | _SignalUtils.safe_connect(node.active_hitboxes_changed, _on_HitState_active_hitboxes_changed, [node]) 111 | 112 | 113 | func _on_Hitstate_hitbox_intersected(detector_hitbox: FrayHitbox2D, detected_hitbox: FrayHitbox2D) -> void: 114 | hitbox_intersected.emit(detector_hitbox, detected_hitbox) 115 | 116 | 117 | func _on_Hitstate_hitbox_separated(detector_hitbox: FrayHitbox2D, detected_hitbox: FrayHitbox2D) -> void: 118 | hitbox_separated.emit(detector_hitbox, detected_hitbox) 119 | 120 | 121 | func _on_HitState_active_hitboxes_changed(hitstate: FrayHitState2D) -> void: 122 | if hitstate.active_hitboxes != 0: 123 | _set_current_state(hitstate.name) 124 | hitstate.activate() -------------------------------------------------------------------------------- /src/hit/2d/hitbox_2d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/hitbox_2d.svg") 3 | class_name FrayHitbox2D 4 | extends Area2D 5 | ## 2D area intended to detect combat interactions. 6 | ## 7 | ## The hitbox node doesn't provide much functionality out of the box. 8 | ## It serves as a template you can expand upon through the use of [FrayHitboxAttribute] 9 | 10 | ## Emitted when the received [kbd]hitbox[/kbd] enters this hitbox. Requires monitoring to be set to [code]true[/code]. 11 | signal hitbox_entered(hitbox: FrayHitbox2D) 12 | 13 | ## Emitted when the received [kbd]hitbox[/kbd] exits this hitbox. Requires monitoring to be set to [code]true[/code]. 14 | signal hitbox_exited(hitbox: FrayHitbox2D) 15 | 16 | ## If true then hitboxes that share the same source as this one will still be detected 17 | @export var detect_source_hitboxes: bool = false 18 | 19 | ## The [FrayHitboxAttribute] assigned to this hitbox 20 | @export var attribute: FrayHitboxAttribute = null: 21 | set(value): 22 | attribute = value 23 | 24 | if attribute != null: 25 | _update_collision_colors() 26 | 27 | update_configuration_warnings() 28 | 29 | ## Source of this hitbox. 30 | ## Hitboxes with the same source will not detect one another unless [member detect_source_hitboxes] is enabled. 31 | var source: Object = null 32 | 33 | var _hitbox_exceptions: Array[FrayHitbox2D] 34 | var _source_exceptions: Array[Object] 35 | 36 | 37 | func _ready() -> void: 38 | if Engine.is_editor_hint(): 39 | child_entered_tree.connect( 40 | func(node: Node): 41 | if node is CollisionShape2D: 42 | _update_collision_colors() 43 | ) 44 | return 45 | 46 | area_entered.connect(_on_area_entered) 47 | area_exited.connect(_on_area_exited) 48 | 49 | 50 | func _get_configuration_warnings() -> PackedStringArray: 51 | var warnings: PackedStringArray = [] 52 | if attribute == null: 53 | warnings.append("Hitboxes without attribute are effectively just Area2Ds. Consider giving this node a FrayHitboxAttribute resource.") 54 | elif not attribute.get_script().is_tool(): 55 | warnings.append("Custom attribute must be a tool script to avoid editor errors. Consider adding the @tool annotation to the top of the script.") 56 | return warnings 57 | 58 | ## Returns a list of intersecting [FrayHitbox2D]s. 59 | func get_overlapping_hitboxes() -> Array[FrayHitbox2D]: 60 | var hitboxes: Array[FrayHitbox2D] 61 | for area in get_overlapping_areas(): 62 | if can_detect(area): 63 | hitboxes.append(area) 64 | return hitboxes 65 | 66 | ## Adds a [kbd]hitbox[/kbd] to a list of hitboxes this hitbox can't detect 67 | func add_hitbox_exception_with(hitbox: FrayHitbox2D) -> void: 68 | if hitbox is FrayHitbox2D and not _hitbox_exceptions.has(hitbox): 69 | _hitbox_exceptions.append(hitbox) 70 | 71 | ## Removes a [kbd]hitbox[/kbd] from a list of hitboxes this hitbox can't detect 72 | func remove_hitbox_exception_with(hitbox: FrayHitbox2D) -> void: 73 | if _hitbox_exceptions.has(hitbox): 74 | _hitbox_exceptions.erase(hitbox) 75 | 76 | ## Adds a source [kbd]object[/kbd] to a list of sources whose hitboxes this hitbox can't detect 77 | func add_source_exception_with(object: Object) -> void: 78 | if not _source_exceptions.has(object): 79 | _source_exceptions.append(object) 80 | 81 | ## Removes a source [kbd]object[/kbd] to a list of sources whose hitboxes this hitbox can't detect 82 | func remove_source_exception_with(object: Object) -> void: 83 | if _source_exceptions.has(object): 84 | _source_exceptions.erase(object) 85 | 86 | ## Activates this hitbox allowing it to monitor and be monitored. 87 | func activate() -> void: 88 | monitorable = true 89 | monitoring = true 90 | show() 91 | 92 | ## Deactivates this hitobx preventing it from monitoring and being monitored. 93 | func deactivate() -> void: 94 | monitorable = false 95 | monitoring = false 96 | hide() 97 | 98 | ## Returns [code]true[/code] if this hitbox is able to detect the given [kbd]hitbox[/kbd]. 99 | ## [br] 100 | ## A hitbox can not detect another hitbox if there is a source or hitbox exception 101 | ## or if the set hitbox attribute does not allow interaction with the given hitbox. 102 | func can_detect(hitbox: FrayHitbox2D) -> bool: 103 | return ( 104 | not _hitbox_exceptions.has(hitbox) 105 | and not _source_exceptions.has(hitbox.source) 106 | and (detect_source_hitboxes or source == null or hitbox.source != source) 107 | and attribute.allows_detection_of(hitbox.attribute) if attribute != null else true 108 | ) 109 | 110 | 111 | func _update_collision_colors() -> void: 112 | if attribute != null: 113 | for node in get_children(): 114 | if node is CollisionShape2D: 115 | node.debug_color = attribute.get_color() 116 | 117 | 118 | func _on_area_entered(area: Area2D) -> void: 119 | if area is FrayHitbox2D and can_detect(area): 120 | hitbox_entered.emit(area) 121 | 122 | 123 | func _on_area_exited(area: Area2D) -> void: 124 | if area is FrayHitbox2D and can_detect(area): 125 | hitbox_exited.emit(area) 126 | -------------------------------------------------------------------------------- /src/hit/3d/hit_state_manager_3d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/hit_state_manager_3d.svg") 3 | class_name FrayHitStateManager3D 4 | extends Node3D 5 | ## Node used to enforce discrete hit states. 6 | ## 7 | ## This node only allows one hit state child to be active at a time. 8 | ## When [member FrayHitState3D.active_hitboxes] changes all other hit states will be deactivate. 9 | 10 | const _ChildChangeDetector = preload("res://addons/fray/lib/helpers/child_change_detector.gd") 11 | const _SignalUtils = preload("res://addons/fray/lib/helpers/utils/signal_utils.gd") 12 | 13 | ## Emitted when the received [kbd]detected_hitbox[/kbd] enters the child [kbd]detector_hitbox[/kbd]. 14 | ## Requires child [FrayHitbox3D.monitoring] to be set to [code]true[/code]. 15 | signal hitbox_intersected(detector_hitbox: FrayHitbox3D, detected_hitbox: FrayHitbox3D) 16 | 17 | ## Emitted when the received [kbd]detected_hitbox[/kbd] enters the child [kbd]detector_hitbox[/kbd]. 18 | ## Requires child [FrayHitbox3D.monitoring] to be set to [code]true[/code]. 19 | signal hitbox_separated(detector_hitbox: FrayHitbox3D, detected_hitbox: FrayHitbox3D) 20 | 21 | ## Source of the [FrayHitbox3D]s beneath this node. 22 | ## [br] 23 | ## This is a convinience that allows you to set the hitbox source from the inspector. 24 | ## However, this property only allows nodes to be used as sources. 25 | ## Any object can be used by calling [member set_hitbox_source]. 26 | @export var source: Node = null: 27 | set(value): 28 | source = value 29 | 30 | for child in get_children(): 31 | if child is FrayHitState3D: 32 | child["metadata/_editor_prop_ptr_source"] = ( 33 | child.get_path_to(source) if source else NodePath() 34 | ) 35 | set_hitbox_source(value) 36 | 37 | var _current_state: StringName = "" 38 | var _cc_detector: _ChildChangeDetector 39 | 40 | func _ready() -> void: 41 | if Engine.is_editor_hint(): 42 | return 43 | 44 | for child in get_children(): 45 | if child is FrayHitState3D: 46 | child.set_hitbox_source(source) 47 | child.hitbox_intersected.connect(_on_Hitstate_hitbox_intersected) 48 | child.hitbox_intersected.connect(_on_Hitstate_hitbox_separated) 49 | child.active_hitboxes_changed.connect(_on_HitState_active_hitboxes_changed.bind(child)) 50 | 51 | 52 | func _get_configuration_warnings() -> PackedStringArray: 53 | var warnings: PackedStringArray = [] 54 | 55 | if get_children().any(func(node): node is FrayHitState3D): 56 | warnings.append("This node has no hit states so there is nothing to manage. Consider adding a FrayHitState3D as a child.") 57 | 58 | return warnings 59 | 60 | 61 | func _enter_tree() -> void: 62 | if Engine.is_editor_hint(): 63 | _cc_detector = _ChildChangeDetector.new(self) 64 | _cc_detector.child_changed.connect(_on_ChildChangeDetector_child_changed) 65 | 66 | 67 | func _set(property: StringName, value) -> bool: 68 | match property: 69 | "metadata/_editor_prop_ptr_source": 70 | var node: Node = get_node_or_null(value) as Node 71 | if value.is_empty() or node == null: 72 | set_meta("_editor_prop_ptr_source", NodePath()) 73 | source = null 74 | else: 75 | set_meta("_editor_prop_ptr_source", value) 76 | source = node 77 | return true 78 | return false 79 | 80 | ## Returns the name of the current hit state 81 | func get_current_state() -> StringName: 82 | return _current_state 83 | 84 | ## Returns a reference to the current state. Returns null if no state is set. 85 | func get_current_state_obj() -> FrayHitState3D: 86 | return get_node_or_null(NodePath(_current_state)) as FrayHitState3D 87 | 88 | ## Sets the [kbd]source[/kbd] of all [FrayHitbox3D] beneath this node. 89 | func set_hitbox_source(source: Object) -> void: 90 | for child in get_children(): 91 | if child is FrayHitState3D: 92 | child.set_hitbox_source(source) 93 | 94 | 95 | func _set_current_state(new_current_state: StringName) -> void: 96 | if new_current_state != _current_state: 97 | _current_state = new_current_state 98 | 99 | for child in get_children(): 100 | if child is FrayHitState3D and child.name != _current_state: 101 | child.deactivate() 102 | 103 | 104 | func _on_ChildChangeDetector_child_changed(node: Node, change: _ChildChangeDetector.Change) -> void: 105 | if change == _ChildChangeDetector.Change.ADDED: 106 | set_deferred("source", source) 107 | 108 | if node is FrayHitState3D and change != _ChildChangeDetector.Change.REMOVED: 109 | _SignalUtils.safe_connect(node.active_hitboxes_changed, _on_HitState_active_hitboxes_changed, [node]) 110 | 111 | 112 | func _on_Hitstate_hitbox_intersected(detector_hitbox: FrayHitbox3D, detected_hitbox: FrayHitbox3D) -> void: 113 | hitbox_intersected.emit(detector_hitbox, detected_hitbox) 114 | 115 | 116 | func _on_Hitstate_hitbox_separated(detector_hitbox: FrayHitbox3D, detected_hitbox: FrayHitbox3D) -> void: 117 | hitbox_separated.emit(detector_hitbox, detected_hitbox) 118 | 119 | 120 | func _on_HitState_active_hitboxes_changed(hitstate: FrayHitState3D) -> void: 121 | if hitstate.active_hitboxes != 0: 122 | _set_current_state(hitstate.name) 123 | hitstate.activate() 124 | -------------------------------------------------------------------------------- /src/hit/3d/hitbox_3d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/hitbox_3d.svg") 3 | class_name FrayHitbox3D 4 | extends Area3D 5 | ## 3D area intended to detect combat interactions. 6 | ## 7 | ## The hitbox node doesn't provide much functionality out of the box. 8 | ## It serves as a template you can expand upon through the use of [FrayHitboxAttribute] 9 | 10 | ## Emitted when the received [kbd]hitbox[/kbd] enters this hitbox. Requires monitoring to be set to [code]true[/code]. 11 | signal hitbox_entered(hitbox: FrayHitbox3D) 12 | 13 | ## Emitted when the received [kbd]hitbox[/kbd] exits this hitbox. Requires monitoring to be set to [code]true[/code]. 14 | signal hitbox_exited(hitbox: FrayHitbox3D) 15 | 16 | ## If true then hitboxes that share the same source as this one will still be detected 17 | @export var detect_source_hitboxes: bool = false 18 | 19 | ## The [FrayHitboxAttribute] assigned to this hitbox 20 | @export var attribute: FrayHitboxAttribute: 21 | set(value): 22 | attribute = value 23 | 24 | if attribute != null: 25 | _update_collision_colors() 26 | 27 | update_configuration_warnings() 28 | 29 | ## Source of this hitbox. 30 | ## Hitboxes with the same source will not detect one another unless [member detect_source_hitboxes] is enabled. 31 | var source: Object = null 32 | 33 | var _hitbox_exceptions: Array[FrayHitbox3D] 34 | var _source_exceptions: Array[Object] 35 | var _debug_meshes: Array[MeshInstance3D] 36 | 37 | 38 | func _ready() -> void: 39 | if Engine.is_editor_hint(): 40 | child_entered_tree.connect( 41 | func(node: Node): 42 | if node is CollisionShape3D: 43 | _update_collision_colors() 44 | ) 45 | return 46 | 47 | area_entered.connect(_on_area_entered) 48 | area_exited.connect(_on_area_exited) 49 | 50 | 51 | func _get_configuration_warnings() -> PackedStringArray: 52 | var warnings: PackedStringArray = [] 53 | if attribute == null: 54 | warnings.append("Hitboxes without attribute are effectively just Area2Ds. Consider giving this node a FrayHitboxAttribute resource.") 55 | elif not attribute.get_script().is_tool(): 56 | warnings.append("Custom attribute must be a tool script to avoid editor errors. Consider adding the @tool annotation to the top of the script.") 57 | return warnings 58 | 59 | ## Returns a list of intersecting [FrayHitbox3D]s. 60 | func get_overlapping_hitboxes() -> Array[FrayHitbox3D]: 61 | var hitboxes: Array[FrayHitbox3D] 62 | for area in get_overlapping_areas(): 63 | if can_detect(area): 64 | hitboxes.append(area) 65 | return hitboxes 66 | 67 | ## Adds a [kbd]hitbox[/kbd] to a list of hitboxes this hitbox can't detect 68 | func add_hitbox_exception_with(hitbox: FrayHitbox3D) -> void: 69 | if hitbox is FrayHitbox3D and not _hitbox_exceptions.has(hitbox): 70 | _hitbox_exceptions.append(hitbox) 71 | 72 | ## Removes a [kbd]hitbox[/kbd] from a list of hitboxes this hitbox can't detect 73 | func remove_hitbox_exception_with(hitbox: FrayHitbox3D) -> void: 74 | if _hitbox_exceptions.has(hitbox): 75 | _hitbox_exceptions.erase(hitbox) 76 | 77 | ## Adds a source [kbd]object[/kbd] to a list of sources whose hitboxes this hitbox can't detect 78 | func add_source_exception_with(object: Object) -> void: 79 | if not _source_exceptions.has(object): 80 | _source_exceptions.append(object) 81 | 82 | ## Removes a source [kbd]object[/kbd] to a list of sources whose hitboxes this hitbox can't detect 83 | func remove_source_exception_with(object: Object) -> void: 84 | if _source_exceptions.has(object): 85 | _source_exceptions.erase(object) 86 | 87 | ## Activates this hitbox allowing it to monitor and be monitored. 88 | func activate() -> void: 89 | monitorable = true 90 | monitoring = true 91 | show() 92 | 93 | ## Deactivates this hitobx preventing it from monitoring and being monitored. 94 | func deactivate() -> void: 95 | monitorable = false 96 | monitoring = false 97 | hide() 98 | 99 | ## Returns [code]true[/code] if this hitbox is able to detect the given [kbd]hitbox[/kbd]. 100 | ## [br] 101 | ## A hitbox can not detect another hitbox if there is a source or hitbox exception 102 | ## or if the set hitbox attribute does not allow interaction with the given hitbox. 103 | func can_detect(hitbox: FrayHitbox3D) -> bool: 104 | return ( 105 | not _hitbox_exceptions.has(hitbox) 106 | and not _source_exceptions.has(hitbox.source) 107 | and (detect_source_hitboxes or source == null or hitbox.source != source) 108 | and attribute.allows_detection_of(hitbox.attribute) if attribute != null else true 109 | ) 110 | 111 | 112 | func _update_collision_colors() -> void: 113 | # 3D debug colors are planned for Godot 4.x 114 | # I was going to do my own implementation but since it's 115 | # not that important i'll leave it for now 116 | pass 117 | 118 | 119 | func _on_area_entered(area: Area3D) -> void: 120 | if area is FrayHitbox3D and can_detect(area): 121 | hitbox_entered.emit(area) 122 | 123 | 124 | func _on_area_exited(area: Area3D) -> void: 125 | if area is FrayHitbox3D and can_detect(area): 126 | hitbox_exited.emit(area) 127 | -------------------------------------------------------------------------------- /src/hit/hitbox_attribute.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/hit_attribute.svg") 3 | class_name FrayHitboxAttribute 4 | extends Resource 5 | ## Abstract data class used to define hitbox attribute. 6 | 7 | ## Returns the color a hitbox with this attribute should be. 8 | func get_color() -> Color: 9 | return _get_color_impl() 10 | 11 | ## Returns true if a hitbox with this attribute should allow detection of the given attribute. 12 | func allows_detection_of(attribute: FrayHitboxAttribute) -> bool: 13 | return _allows_detection_of_impl(attribute) 14 | 15 | ## [code]Virtual method[/code] used to implement [method get_color]. 16 | ## Currently this does nothing for [FrayHitbox3D]. 17 | func _get_color_impl() -> Color: 18 | return Color(0, 0, 0, .5) 19 | 20 | ## [code]Virtual method[/code] used to implement [method allows_detection_of] method 21 | func _allows_detection_of_impl(attribute: FrayHitboxAttribute) -> bool: 22 | return true 23 | -------------------------------------------------------------------------------- /src/input/controller.gd: -------------------------------------------------------------------------------- 1 | @icon("res://addons/fray/assets/icons/controller.svg") 2 | class_name FrayController 3 | extends Node 4 | ## A node representing a controller 5 | ## 6 | ## This node is a helper node which wrapper around `FrayInput` input checks. 7 | ## It can be used to decouple entities from the inputs they check in a way that is accesible from the node tree. 8 | ## If an entity needs to respond to inputs from another device you only need to change the controller's [member device] value. 9 | ## It is also possible to hand the controller off to an AI by using virtual devices. 10 | 11 | ## The ID of the device to check for 12 | @export var device: int = _FrayInput.DEVICE_KBM_JOY1 13 | 14 | ## If [code]true[/code] then all input checks will return false. 15 | @export var disabled: bool = false 16 | 17 | @onready var _fray_input: Node = get_node("/root/FrayInput") 18 | 19 | func _ready() -> void: 20 | if _fray_input == null: 21 | push_error("Failed to access FrayInput singleton. Fray plugin may not be enabled.") 22 | return 23 | 24 | ## Returns true if this controller is connected 25 | func is_device_connected() -> bool: 26 | return _fray_input.is_device_connected(device) 27 | 28 | ## Returns true if an input is being pressed. 29 | func is_pressed(input: StringName) -> bool: 30 | return not disabled and is_device_connected() and _fray_input.is_pressed(input, device) 31 | 32 | ## Returns true if any of the inputs given are being pressed 33 | func is_any_pressed(inputs: PackedStringArray) -> bool: 34 | return not disabled and is_device_connected() and _fray_input.is_any_pressed(inputs, device) 35 | 36 | ## Returns true when a user starts pressing the input, 37 | ## meaning it's true only on the frame the user pressed down the input. 38 | func is_just_pressed(input: StringName) -> bool: 39 | return not disabled and is_device_connected() and _fray_input.is_just_pressed(input, device) 40 | 41 | ## Returns true if input was physically pressed 42 | ## meaning it is only true if the press was not trigerred virtually. 43 | func is_just_pressed_real(input: StringName) -> bool: 44 | return not disabled and is_device_connected() and _fray_input.is_just_pressed_real(input, device) 45 | 46 | ## Returns true when the user stops pressing the input, 47 | ## meaning it's true only on the frame that the user released the button. 48 | func is_just_released(input: StringName) -> bool: 49 | return not disabled and is_device_connected() and _fray_input.is_just_released(input, device) 50 | 51 | ## Returns a value between 0 and 1 representing the intensity of an input. 52 | ## If the input has no range of strngth a discrete value of 0 or 1 will be returned. 53 | func get_strength(input: StringName) -> float: 54 | return _fray_input.get_strength(input, device) if is_device_connected() and not disabled else 0.0 55 | 56 | ## Get axis input by specifiying two input ids, one negative and one positive. 57 | func get_axis(negative_input: StringName, positive_input: StringName) -> float: 58 | return _fray_input.get_axis(negative_input, positive_input, device) if is_device_connected() and not disabled else 0.0 59 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputBind 2 | extends Resource 3 | ## Abstract base class for all input binds 4 | ## 5 | ## An input bind is used to map physical device presses to inputs fray input names. 6 | 7 | 8 | ## Returns true if the bind is pressed 9 | func is_pressed(device: int = 0) -> bool: 10 | return _is_pressed_impl(device) 11 | 12 | ## Returns true if this bind is equal to the given bind 13 | func equals(input_bind: Resource) -> bool: 14 | return _equals_impl(input_bind) 15 | 16 | ## Returns a value between 0 and 1 representing the intensity of an input. 17 | func get_strength(device: int = 0) -> float: 18 | return _get_strength_impl() 19 | 20 | ## [code]Abstract method[/code] used to implement a bind's [method is_pressed] method. 21 | func _is_pressed_impl(device: int = 0) -> bool: 22 | assert(false, "Method not implemented") 23 | return false 24 | 25 | ## [code]Virtual method[/code] used to implement [method equals] method 26 | func _equals_impl(input_bind: Resource) -> bool: 27 | return input_bind is FrayInputBind 28 | 29 | ## [code]Virtual method[/code] used to implement [method get_strength] method 30 | func _get_strength_impl(device: int = 0) -> float: 31 | return float(is_pressed(device)) 32 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_action.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayInputBindAction 3 | extends "input_bind_simple.gd" 4 | ## Action input bind 5 | ## 6 | ## Bind that makes use of godot's actions 7 | 8 | ## Action name 9 | var action: StringName 10 | 11 | 12 | func _init(action_name: StringName = "") -> void: 13 | action = action_name 14 | 15 | 16 | func _is_pressed_impl(_device: int = 0) -> bool: 17 | if not InputMap.has_action(action): 18 | push_warning("Action '%s' does not exist in Godot InputMap" % action) 19 | return false 20 | return Input.is_action_pressed(action) 21 | 22 | 23 | func _equals_impl(input_bind: Resource) -> bool: 24 | return ( 25 | super(input_bind) 26 | and action == input_bind.action) 27 | 28 | 29 | func _get_strength_impl(_device: int = 0) -> float: 30 | return Input.get_action_strength(action) 31 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_fray_action.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputBindFrayAction 2 | extends "input_bind.gd" 3 | ## Fray action input bind 4 | ## 5 | ## Bind that makes use of simple binds in a way that mimic's Godot's actions. 6 | 7 | var _binds: Array[FrayInputBindSimple] 8 | 9 | func _init(binds: Array[FrayInputBindSimple] = []) -> void: 10 | for bind in binds: 11 | add_bind(bind) 12 | 13 | 14 | func _is_pressed_impl(device: int = 0) -> bool: 15 | for bind in _binds: 16 | if bind.is_pressed(device): 17 | return true 18 | return false 19 | 20 | ## Adds bind to this action. 21 | func add_bind(simple_bind: FrayInputBindSimple) -> void: 22 | _binds.append(simple_bind) 23 | 24 | ## Erases bind from this action. 25 | func erase_bind(simple_bind: FrayInputBindSimple) -> void: 26 | _binds.erase(simple_bind) 27 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_joy_axis.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayInputBindJoyAxis 3 | extends "input_bind_simple.gd" 4 | ## Joy axis input bind 5 | 6 | ## Joy axis identifier. See JoyStickList 7 | @export var axis: int 8 | 9 | ## Determines whether to check the positive or negative side of the axis 10 | @export var use_positive_axis: bool 11 | 12 | ## Joystick deadzone 13 | @export var deadzone: float: 14 | set(value): 15 | deadzone = clamp(value, 0, 1) 16 | 17 | func _init(joys_axis: int = -1, joy_use_positive_axis: bool = true, joy_deadzone: float = .5) -> void: 18 | axis = joys_axis 19 | deadzone = joy_deadzone 20 | use_positive_axis = joy_use_positive_axis 21 | 22 | 23 | func _is_pressed_impl(device: int = 0) -> bool: 24 | var joy_axis := Input.get_joy_axis(device, axis) 25 | var is_positive_dir: bool = sign(joy_axis) == 1 26 | 27 | if use_positive_axis != is_positive_dir: 28 | return false 29 | 30 | return abs(joy_axis) >= deadzone 31 | 32 | 33 | func _equals_impl(input_bind: Resource) -> bool: 34 | return ( 35 | super(input_bind) 36 | and axis == input_bind.axis 37 | and use_positive_axis == input_bind.use_positive_axis) 38 | 39 | 40 | func _get_strength_impl(device: int = 0) -> float: 41 | var joy_axis := Input.get_joy_axis(device, axis) 42 | var is_positive_dir: bool = sign(joy_axis) == 1 43 | 44 | if abs(joy_axis) < deadzone or use_positive_axis != is_positive_dir: 45 | return 0.0 46 | 47 | return abs(joy_axis) 48 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_joy_button.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayInputBindJoyButton 3 | extends "input_bind_simple.gd" 4 | ## Joystick input bind 5 | 6 | 7 | ## Button identifier. One of the JoyStickList button constants 8 | @export var button: int 9 | 10 | func _init(joystick_button: int = -1) -> void: 11 | button = joystick_button 12 | 13 | 14 | func _is_pressed_impl(device: int = 0) -> bool: 15 | return Input.is_joy_button_pressed(device, button) 16 | 17 | func _equals_impl(input_bind: Resource) -> bool: 18 | return ( 19 | super(input_bind) 20 | and button == input_bind.button) 21 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_key.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayInputBindKey 3 | extends "input_bind_simple.gd" 4 | ## Keyboard input bind 5 | 6 | ## The key scancode, which corresponds to one of the KeyList constants. 7 | @export var key: int 8 | 9 | func _init(keyboard_key: int = -1) -> void: 10 | key = keyboard_key 11 | 12 | 13 | func _is_pressed_impl(_device: int = 0) -> bool: 14 | return Input.is_key_pressed(key) 15 | 16 | 17 | func _equals_impl(input_bind: Resource) -> bool: 18 | return ( 19 | super(input_bind) 20 | and key == input_bind.key) 21 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_mouse_button.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayInputBindMouseButton 3 | extends "input_bind_simple.gd" 4 | ## Mouse input bind 5 | 6 | ## The mouse button identifier, one of the ButtonList buttons or button wheel constants. 7 | @export var button: int 8 | 9 | func _init(mouse_button: int = -1) -> void: 10 | button = mouse_button 11 | 12 | 13 | func _is_pressed_impl(_device: int = 0) -> bool: 14 | return Input.is_mouse_button_pressed(button) 15 | 16 | 17 | func _equals_impl(input_bind: Resource) -> bool: 18 | return ( 19 | super(input_bind) 20 | and button == input_bind.button) 21 | -------------------------------------------------------------------------------- /src/input/device/binds/input_bind_simple.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputBindSimple 2 | extends "input_bind.gd" 3 | ## Abstract base class representing all simple inputs 4 | -------------------------------------------------------------------------------- /src/input/device/composites/combination_input.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayCombinationInput 3 | extends FrayCompositeInput 4 | ## A composite input used to create combination inputs 5 | ## 6 | ## A combination will be considered as press when 7 | ## all components are pressed according to the mode. 8 | 9 | enum Mode { 10 | SYNC, ## Components must all be pressed at the same time. 11 | ASYNC, ## Components can be pressed at any time so long as they are all pressed. 12 | ORDERED, ## Like asynchronous but the presses must occur in order. 13 | } 14 | 15 | ## Determines press condition necessary to trigger combination 16 | var mode: Mode = Mode.SYNC 17 | 18 | ## Returns a builder instance 19 | static func builder() -> Builder: 20 | return Builder.new() 21 | 22 | func _is_pressed_impl(device: int) -> bool: 23 | match mode: 24 | Mode.SYNC: 25 | return _is_combination_quick_enough(device) 26 | Mode.ASYNC: 27 | return _is_all_components_pressed(device) 28 | Mode.ORDERED: 29 | return _is_combination_in_order(device) 30 | _: 31 | push_error("Failed to check combination. Unknown mode '%d'" % mode) 32 | 33 | return false 34 | 35 | 36 | func _decompose_impl(device: int) -> Array[StringName]: 37 | # Returns all components decomposed and joined 38 | var binds: Array[StringName] 39 | for component in _components: 40 | binds.append_array(component.decompose(device)) 41 | return binds 42 | 43 | 44 | # Returns: InputState[] 45 | func _get_decomposed_states(device: int) -> Array: 46 | var decomposed_states := [] 47 | for bind in decompose(device): 48 | decomposed_states.append(get_bind_state(bind, device)) 49 | return decomposed_states 50 | 51 | 52 | func _is_all_components_pressed(device: int) -> bool: 53 | for component in _components: 54 | if not component.is_pressed(device): 55 | return false 56 | return true 57 | 58 | 59 | func _is_combination_quick_enough(device: int, tolerance: float = 10) -> bool: 60 | var decomposed_states := _get_decomposed_states(device) 61 | var avg_difference := 0 62 | 63 | for i in len(decomposed_states): 64 | if i > 0: 65 | var input1: FrayInputState = decomposed_states[i] 66 | var input2: FrayInputState = decomposed_states[i - 1] 67 | 68 | if not input1.is_pressed or not input2.is_pressed: 69 | return false 70 | 71 | avg_difference += abs(input1.time_pressed - input2.time_pressed) 72 | 73 | avg_difference /= float(decomposed_states.size()) 74 | return avg_difference <= tolerance 75 | 76 | 77 | func _is_combination_in_order(device: int, tolerance: float = 10) -> bool: 78 | var decomposed_states := _get_decomposed_states(device) 79 | 80 | for i in len(decomposed_states): 81 | if i > 0: 82 | var input1: FrayInputState = decomposed_states[i] 83 | var input2: FrayInputState = decomposed_states[i - 1] 84 | 85 | if not input1.is_pressed or not input2.is_pressed: 86 | return false 87 | 88 | if input2.time_pressed - tolerance > input1.time_pressed: 89 | return false 90 | 91 | return true 92 | 93 | 94 | class Builder: 95 | extends FrayCompositeInput.Builder 96 | 97 | func _init() -> void: 98 | _composite_input = FrayCombinationInput.new() 99 | 100 | ## Builds the composite input. 101 | ## [br] 102 | ## Returns a reference to the newly built combination input. 103 | func build() -> FrayCombinationInput: 104 | return _composite_input 105 | 106 | ## Sets the combination to async mode. 107 | ## [br] 108 | ## Returns a reference to the newly built combination input. 109 | func mode_async() -> Builder: 110 | _composite_input.mode = FrayCombinationInput.Mode.ASYNC 111 | return self 112 | 113 | ## Sets the combination to sync mode. 114 | ## [br] 115 | ## Returns a reference to the newly built combination input. 116 | func mode_sync() -> Builder: 117 | _composite_input.mode = FrayCombinationInput.Mode.SYNC 118 | return self 119 | 120 | ## Sets the combination to ordered mode. 121 | ## [br] 122 | ## Returns a reference to the newly built combination input. 123 | func mode_ordered() -> Builder: 124 | _composite_input.mode = FrayCombinationInput.Mode.ORDERED 125 | return self 126 | 127 | func add_component(composite_input: FrayCompositeInput) -> Builder: 128 | return super(composite_input) 129 | 130 | func add_component_simple(bind: StringName) -> Builder: 131 | return super(bind) 132 | 133 | func is_virtual(value: bool = true) -> Builder: 134 | return super(value) 135 | 136 | func priority(value: int) -> Builder: 137 | return super(value) -------------------------------------------------------------------------------- /src/input/device/composites/conditional_input.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayConditionalInput 3 | extends FrayCompositeInput 4 | 5 | ## A composite input used to create conditional inputs 6 | ## 7 | ## @desc: 8 | ## Returns whether a specific component is pressed based on a string condition. 9 | ## Useful for creating directional inputs which change based on what side a 10 | ## combatant stands on as is seen in many 2D fighting games. 11 | ## 12 | ## If no condition is true then the input will default to checking the first component. 13 | 14 | # Type: Array[func(device: int) -> bool]; index=component_index 15 | var _conditions: Array[Callable] 16 | 17 | 18 | ## Returns a builder instance. 19 | static func builder() -> Builder: 20 | return Builder.new() 21 | 22 | 23 | ## Sets the condition associated with a given component. 24 | ## [br] 25 | ## [kbd]component_index[/kbd] is the index of the component based on the order added. 26 | ## [br] 27 | ## [kbd]condition[/kbd] is a function of type func(device: int) -> bool. 28 | func set_condition(component_index: int, condition: Callable) -> void: 29 | if component_index == 0: 30 | push_warning( 31 | "The first component is treated as the default input. Condition will be ignored" 32 | ) 33 | elif component_index >= 1 and component_index < _components.size(): 34 | _conditions[component_index - 1] = condition 35 | else: 36 | push_warning("Failed to set condition on input. Given index out of range") 37 | 38 | 39 | func add_component(component: FrayCompositeInput) -> void: 40 | super(component) 41 | 42 | if get_component_count() > 1: 43 | _conditions.append(Callable()) 44 | 45 | 46 | func _is_pressed_impl(device: int) -> bool: 47 | if _components.is_empty(): 48 | push_warning("Conditional input has no components") 49 | return false 50 | 51 | return _get_active_component(device).is_pressed(device) 52 | 53 | 54 | func _decompose_impl(device: int) -> Array[StringName]: 55 | # Returns the first component with a true condition. Defaults to component at index 0 56 | 57 | if _components.is_empty(): 58 | return [] 59 | 60 | return _get_active_component(device).decompose(device) 61 | 62 | 63 | func _get_active_component(device: int) -> FrayCompositeInput: 64 | var comp: FrayCompositeInput = _components[0] 65 | 66 | for i in len(_conditions): 67 | var condition := _conditions[i] 68 | 69 | if condition.is_null(): 70 | push_error( 71 | "Failed to check condition for component at index %d. Condition not set." % [i + 1] 72 | ) 73 | return comp 74 | 75 | if _conditions[i].call(device): 76 | comp = _components[i + 1] 77 | break 78 | return comp 79 | 80 | 81 | class Builder: 82 | extends FrayCompositeInput.Builder 83 | 84 | func _init() -> void: 85 | _composite_input = FrayConditionalInput.new() 86 | 87 | ## Builds the composite input 88 | ## [br] 89 | ## Returns a reference to the newly built conditional input. 90 | func build() -> FrayConditionalInput: 91 | return _composite_input 92 | 93 | ## Sets the condition of the previously added component. 94 | ## Will do nothing for the first component added as this component is treated as default. 95 | ## [br] 96 | ## Returns a reference to this builder. 97 | func use_condition(condition: Callable) -> Builder: 98 | var component_count := _composite_input.get_component_count() 99 | 100 | if component_count == 0: 101 | push_warning("No components have been added. Condition will be ignored.") 102 | elif component_count == 1: 103 | push_warning("The first component is treated as default. Condition will be ignored.") 104 | else: 105 | _composite_input.set_condition(component_count - 1, condition) 106 | 107 | return self 108 | 109 | func add_component(composite_input: FrayCompositeInput) -> Builder: 110 | return super(composite_input) 111 | 112 | func add_component_simple(bind: StringName) -> Builder: 113 | return super(bind) 114 | 115 | func is_virtual(value: bool = true) -> Builder: 116 | return super(value) 117 | 118 | func priority(value: int) -> Builder: 119 | return super(value) -------------------------------------------------------------------------------- /src/input/device/composites/group_input.gd: -------------------------------------------------------------------------------- 1 | class_name FrayGroupInput 2 | extends FrayCompositeInput 3 | ## A composite input used to create group inputs 4 | ## 5 | ## A group will be considered press when the 6 | ## minimum number of components in the group is pressed. 7 | 8 | ## The minimum number of components that must be pressed for the input to 9 | ## be considered as pressed. 10 | @export var min_pressed: int = 1: 11 | set(value): 12 | min_pressed = max(1, value) 13 | 14 | # Note: I really don't like the composite inputs maintaing state. They're really supposed to 15 | # just peek at the fray input state and determine if they're considered pressed. 16 | # But this is the only way I could think to handle their decomposition. 17 | # Type: Dictionary 18 | var _last_inputs_by_device: Dictionary 19 | 20 | 21 | ## Returns a builder instance. 22 | static func builder() -> Builder: 23 | return Builder.new() 24 | 25 | 26 | func _is_pressed_impl(device: int) -> bool: 27 | var press_count := 0 28 | var last_inputs: Array[StringName] = [] 29 | 30 | for component in _components: 31 | if component.is_pressed(device): 32 | last_inputs.append_array(component.decompose(device)) 33 | press_count += 1 34 | 35 | if press_count >= min(min_pressed, _components.size()): 36 | _last_inputs_by_device[device] = last_inputs 37 | return true 38 | 39 | return false 40 | 41 | 42 | func _decompose_impl(device: int) -> Array[StringName]: 43 | return _last_inputs_by_device[device] if _last_inputs_by_device.has(device) else [] 44 | 45 | 46 | class Builder: 47 | extends FrayCompositeInput.Builder 48 | ## [FrayGroupInput] builder. 49 | 50 | func _init() -> void: 51 | _composite_input = FrayGroupInput.new() 52 | 53 | ## Builds the composite input 54 | ## [br] 55 | ## Returns a reference to the newly built composite input. 56 | func build() -> FrayGroupInput: 57 | return _composite_input 58 | 59 | ## Sets the minimum number of components that must be pressed. 60 | ## [br] 61 | ## Returns a reference to this builder. 62 | func min_pressed(value: int) -> Builder: 63 | _composite_input.min_pressed = value 64 | return self 65 | 66 | func add_component(composite_input: FrayCompositeInput) -> Builder: 67 | return super(composite_input) 68 | 69 | func add_component_simple(bind: StringName) -> Builder: 70 | return super(bind) 71 | 72 | func is_virtual(value: bool = true) -> Builder: 73 | return super(value) 74 | 75 | func priority(value: int) -> Builder: 76 | return super(value) -------------------------------------------------------------------------------- /src/input/device/composites/simple_input.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FraySimpleInput 3 | extends FrayCompositeInput 4 | ## A composite input used as a wrapper around input binds 5 | ## 6 | ## Simple inputs do nothing with thier components and will ignore them. 7 | ## They are considered pressed when their bind is pressed. 8 | ## Simple inputs are intended to be the 'leaf' that ends any input composition. 9 | 10 | ## Name of the bind associated with this input 11 | var bind: StringName 12 | 13 | 14 | func _is_pressed_impl(device: int) -> bool: 15 | return get_bind_state(bind, device).is_pressed 16 | 17 | 18 | ## Returns a builder instance. 19 | static func builder() -> Builder: 20 | return Builder.new() 21 | 22 | 23 | ## Returns a simple input using the given [kbd]bind[/kbd]. 24 | ## [br] 25 | ## Shorthand for [code]builder().bind(bind).build()[/code] 26 | static func from_bind(bind: StringName) -> FraySimpleInput: 27 | return builder().bind(bind).build() 28 | 29 | 30 | func _decompose_impl(device: int) -> Array[StringName]: 31 | return [bind] 32 | 33 | 34 | class Builder: 35 | extends FrayCompositeInput.Builder 36 | ## [FraySimpleInput] builder. 37 | 38 | func _init() -> void: 39 | _composite_input = FraySimpleInput.new() 40 | 41 | ## Builds the composite input 42 | ## [br] 43 | ## Returns a reference to the newly built simple input. 44 | func build() -> FraySimpleInput: 45 | return _composite_input 46 | 47 | ## Adds a bind to this simple input. 48 | ## [br] 49 | ## Returns a reference to this ComponentBuilder. 50 | func bind(bind_name: StringName) -> Builder: 51 | _composite_input.bind = bind_name 52 | return self 53 | -------------------------------------------------------------------------------- /src/input/device/device_state.gd: -------------------------------------------------------------------------------- 1 | class_name FrayDeviceState 2 | extends RefCounted 3 | ## Used by [_FrayInput] to track device state 4 | 5 | # Type: Dictionary 6 | var _input_state_by_name: Dictionary 7 | var _is_valid := true 8 | 9 | 10 | ## Returns [code]true[/code] if the input state is still valid. 11 | func is_valid() -> bool: 12 | return _is_valid 13 | 14 | 15 | ## Invalidates this input state resulting in it being removed from the [_FrayInput] singleton. 16 | func invalidate() -> void: 17 | _is_valid = false 18 | 19 | 20 | ## Returns an array containing the names of every pressed input in this device state. 21 | func get_pressed_inputs() -> PackedStringArray: 22 | var pressed_inputs: PackedStringArray 23 | for input in _input_state_by_name: 24 | if _input_state_by_name[input].pressed: 25 | pressed_inputs.append(input) 26 | return pressed_inputs 27 | 28 | 29 | ## Returns an array containing the names of every unpressed input in this device state. 30 | func get_unpressed_inputs() -> PackedStringArray: 31 | var unpressed_inputs: PackedStringArray 32 | for input in _input_state_by_name: 33 | if not _input_state_by_name[input].pressed: 34 | unpressed_inputs.append(input) 35 | return unpressed_inputs 36 | 37 | 38 | ## Returns the names of all inputs tracked by this device. 39 | func get_all_inputs() -> PackedStringArray: 40 | return PackedStringArray(_input_state_by_name.keys()) 41 | 42 | 43 | ## Returns the input state of an input associated with a given [kbd]input_name[\kbd], 44 | ## if it exists. 45 | func get_input_state(input_name: StringName) -> FrayInputState: 46 | if _input_state_by_name.has(input_name): 47 | return _input_state_by_name[input_name] 48 | return register_input_state(input_name) 49 | 50 | 51 | ## Creates a new input state for the given [kbd]input_name[\kbd]. 52 | func register_input_state(input_name: StringName) -> FrayInputState: 53 | var input_state := FrayInputState.new(input_name) 54 | _input_state_by_name[input_name] = input_state 55 | return input_state 56 | 57 | 58 | ## Flags all [kbd]inputs[/kbd] given as being used by given [kbd]composite[/kbd]. 59 | func flag_inputs_use_in_composite(composite: StringName, inputs: PackedStringArray) -> void: 60 | for input in inputs: 61 | if _input_state_by_name.has(input): 62 | _input_state_by_name[input].composites_used_in[composite] = true 63 | 64 | 65 | ## Unflags all [kbd]inputs[/kbd] given as being used by given [kbd]composite[/kbd]. 66 | func unflag_inputs_use_in_composite(composite: StringName, inputs: PackedStringArray) -> void: 67 | for input in inputs: 68 | if _input_state_by_name.has(input): 69 | _input_state_by_name[input].composites_used_in.erase(composite) 70 | 71 | 72 | ## Sets all [kbd]inputs[/kbd] as distinct. 73 | func set_inputs_as_distinct(inputs: PackedStringArray, ignore_in_comp_check: bool = false) -> void: 74 | for input in inputs: 75 | if ( 76 | _input_state_by_name.has(input) 77 | and (ignore_in_comp_check or _input_state_by_name[input].composites_used_in.is_empty()) 78 | ): 79 | _input_state_by_name[input].is_distinct = true 80 | 81 | 82 | ## Unsets all [kbd]inputs[/kbd] as distinct. 83 | func unset_inputs_as_distinct(inputs: PackedStringArray) -> void: 84 | for input in inputs: 85 | if _input_state_by_name.has(input): 86 | _input_state_by_name[input].is_distinct = false 87 | 88 | 89 | ## Returns [code]true[/code] if all [kbd]inputs[/kbd] are distinct. 90 | func is_all_distinct(inputs: PackedStringArray) -> bool: 91 | for input in inputs: 92 | if _input_state_by_name.has(input) and _input_state_by_name[input].is_distinct: 93 | return true 94 | return false 95 | -------------------------------------------------------------------------------- /src/input/device/input_state.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputState 2 | extends RefCounted 3 | ## Used by FrayInput to track state of individual inputs 4 | 5 | ## Set of composites this input is used in 6 | # Type: Peudo-HashSet 7 | var composites_used_in: Dictionary 8 | 9 | ## Name of this input 10 | var input: StringName 11 | 12 | ## Physics frame this input was pressed 13 | var physics_frame: int = -1 14 | 15 | ## Process frame this input was pressed 16 | var process_frame: int = -1 17 | 18 | ## Time in miliseconds this input was pressed 19 | var time_pressed: int = -1 20 | 21 | ## Press intensity of this input. 22 | ## [br] 23 | ## Will be 0 or 1 for inputs with boolean state. 24 | var strength: float 25 | 26 | ## If [code]true[/code] then the input was pressed. 27 | var is_pressed: bool 28 | 29 | ## If [code]true[/code] then the input was pressed virtually. 30 | var is_virtually_pressed: bool 31 | 32 | ## If [code]true[/code] then the input is considered pressed without any overlapping inputs. 33 | var is_distinct: bool = true 34 | 35 | 36 | func _init(input_name: StringName) -> void: 37 | input = input_name 38 | 39 | ## Presses the input and records the new input state. 40 | func press() -> void: 41 | is_distinct = true 42 | is_pressed = true 43 | physics_frame = Engine.get_physics_frames() 44 | process_frame = Engine.get_process_frames() 45 | time_pressed = Time.get_ticks_msec() 46 | 47 | if strength <= 0: 48 | strength = 1 49 | 50 | func press_virtually() -> void: 51 | press() 52 | is_virtually_pressed = true 53 | 54 | ## Unpresses the input and records the new input state. 55 | func unpress() -> void: 56 | is_pressed = false 57 | physics_frame = Engine.get_physics_frames() 58 | process_frame = Engine.get_process_frames() 59 | strength = 0 60 | composites_used_in.clear() 61 | is_distinct = true 62 | -------------------------------------------------------------------------------- /src/input/device/virtual_device.gd: -------------------------------------------------------------------------------- 1 | class_name FrayVirtualDevice 2 | extends RefCounted 3 | ## Manually controlled device. 4 | ## 5 | ## A device's whos inputs must be manually controlled through code. 6 | 7 | var _device_state: FrayDeviceState 8 | var _id: int 9 | 10 | func _init(device_state: FrayDeviceState, id: int): 11 | _device_state = device_state 12 | _id = id 13 | 14 | func _notification(what: int) -> void: 15 | if what == NOTIFICATION_PREDELETE: 16 | _device_state.invalidate() 17 | 18 | ## presses given input on virtual device 19 | func press(input: StringName, press_strength: float = 1.0) -> void: 20 | match _device_state.get_input_state(input): 21 | var input_state: 22 | input_state.strength = press_strength 23 | input_state.press() 24 | null: 25 | push_error("Unrecognized input '%s. Failed to press input on virtual device with id '%d'" % [input, _id]) 26 | pass 27 | pass 28 | 29 | ## Unpresses given input on virtual device 30 | func unpress(input: StringName) -> void: 31 | match _device_state.get_input_state(input): 32 | var input_state: 33 | input_state.unpress() 34 | null: 35 | push_error("Unrecognized input '%s. Failed to unpress input on virtual device with id '%d'" % [input, _id]) 36 | pass 37 | pass 38 | 39 | ## Returns the integer id used by the FrayInput singleton to store 40 | ## the virtual device's device state. 41 | func get_id() -> int: 42 | return _id 43 | 44 | ## Disconnects the virtual device by invalidating the input state whichs removes it from the FrayInput singleton. 45 | ## The input state is automatically invalidated when the virtual device is no longer being referenced. 46 | func unplug() -> void: 47 | _device_state.invalidate() 48 | -------------------------------------------------------------------------------- /src/input/events/fray_input_event.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputEvent 2 | extends RefCounted 3 | ## Base class for inputs detected by the FrayInput singleton. 4 | 5 | ## Time in miliseconds that the input was initially pressed 6 | var time_pressed: int = 0 7 | 8 | ## Time in miliseconds that the input was detected. This is recorded when the signal is emitted 9 | var time_detected: int = 0 10 | 11 | ## The physics frame when the input was first pressed 12 | var physics_frame: int = 0 13 | 14 | ## The idle frame when the input was first pressed 15 | var process_frame: int = 0 16 | 17 | ## The ID of the device that caused this event 18 | var device: int = 0 19 | 20 | ## The input's name 21 | var input: StringName = "" 22 | 23 | var _is_echo: bool = false 24 | var _is_pressed: bool = false 25 | var _is_virtually_pressed: bool = false 26 | var _is_distinct: bool = false 27 | 28 | 29 | func _to_string() -> String: 30 | return "{input:%s, pressed:%s, device:%d}" % [input, is_pressed(), device] 31 | 32 | ## If [code]true[/code], the input is being pressed. If false, it is released 33 | func is_pressed() -> bool: 34 | return _is_pressed 35 | 36 | ## If [code]true[/code], this event was triggered by a virtual press. 37 | func is_virtually_pressed() -> bool: 38 | return _is_virtually_pressed 39 | 40 | ## If [code]true[/code], the input has already been detected 41 | func is_echo() -> bool: 42 | return _is_echo 43 | 44 | ## If [code]true[/code], this input is considered to have occured before any other overlapping inputs. 45 | ## If multiple composite inputs which share binds are overlapping then try increasing 46 | ## the more complex input's [member FrayCompositeInput.priority]. 47 | func is_distinct() -> bool: 48 | return _is_distinct 49 | 50 | ## Returns the time between two input events in miliseconds. 51 | func get_time_between_msec(fray_input_event: RefCounted, use_time_pressed: bool = false) -> int: 52 | var t1: int = fray_input_event.time_pressed if use_time_pressed else fray_input_event.time_detected 53 | var t2: int = time_pressed if use_time_pressed else time_detected 54 | return int(abs(t1 - t2)) 55 | 56 | ## Returns the time between two input events in seconds 57 | func get_time_between_sec(fray_input_event: RefCounted, use_time_pressed: bool = false) -> float: 58 | return get_time_between_msec(fray_input_event, use_time_pressed) / 1000.0 59 | 60 | ## returns how long this input was held in miliseconds 61 | func get_time_held_msec() -> int: 62 | return time_detected - time_pressed 63 | 64 | ## returns how long this input was held in seconds 65 | func get_time_held_sec() -> float: 66 | return get_time_held_msec() / 1000.0 67 | 68 | ## Returns true if input was pressed with no echo 69 | func is_just_pressed() -> bool: 70 | return is_pressed() and not is_echo() 71 | 72 | ## Returns true if input was not virtually pressed 73 | func is_just_pressed_real() -> bool: 74 | return is_just_pressed() and not is_virtually_pressed() 75 | -------------------------------------------------------------------------------- /src/input/events/fray_input_event_bind.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputEventBind 2 | extends "fray_input_event.gd" 3 | ## Fray input event type for bind inputs 4 | 5 | ## An array of composite inputs that this bind was a part of when the event was emitted 6 | ## Type: Pesudo-HashSet 7 | var composites_used_in: Dictionary 8 | 9 | ## Returns true if this input press was used as a part of a composite input press 10 | ## when the event was emitted. 11 | func is_used_in_composite() -> bool: 12 | return not composites_used_in.is_empty() 13 | -------------------------------------------------------------------------------- /src/input/events/fray_input_event_composite.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputEventComposite 2 | extends "fray_input_event.gd" 3 | ## Fray input event type for composite inputs 4 | -------------------------------------------------------------------------------- /src/input/sequence/input_requirement.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputRequirement 2 | extends Resource 3 | ## Used by [FraySequenceBranch] to describe a sequence of inputs. 4 | 5 | ## The name of the input 6 | var input: StringName 7 | 8 | ## The minimum amount of time that the input must be held in milliseconds. 9 | var min_time_held: int 10 | 11 | ## The max delay between this input and the last in milliseconds. 12 | var max_delay: int 13 | 14 | ## Returns [code]true[/code] if the input is a charge input.[br] 15 | ## 16 | ## An input is considered a charge input if its [member min_time_held] is greater than 0. 17 | func is_charge_input() -> bool: 18 | return min_time_held > 0 19 | -------------------------------------------------------------------------------- /src/input/sequence/sequence_branch.gd: -------------------------------------------------------------------------------- 1 | class_name FraySequenceBranch 2 | extends Resource 3 | ## Contains data on the inputs required for a sequence to be recognized. 4 | 5 | ## Array holding the InputRequirements used to define this sequence. 6 | var input_requirements: Array[FrayInputRequirement] 7 | 8 | ## If true the final input in the sequence is allowed to be triggered by a button release.. 9 | ## Search "fighting game negative edge" for more info on the concept 10 | var is_negative_edge_enabled: bool 11 | 12 | ## Returns new builder instance. 13 | static func builder() -> Builder: 14 | return Builder.new() 15 | 16 | 17 | class Builder: 18 | extends RefCounted 19 | ## [FraySequenceBranch] builder. 20 | 21 | var _input_requirements: Array[FrayInputRequirement] 22 | var _is_negative_edge_enabled: bool 23 | var _first_input: FrayInputRequirement 24 | 25 | ## Returns newly constructed sequence branch. 26 | func build() -> FraySequenceBranch: 27 | if _first_input != null: 28 | _input_requirements.push_front(_first_input) 29 | 30 | var branch := FraySequenceBranch.new() 31 | branch.input_requirements = _input_requirements 32 | branch.is_negative_edge_enabled = _is_negative_edge_enabled 33 | return branch 34 | 35 | ## Appends an input requirement to the end of this sequence 36 | ## [br] 37 | ## Returns a reference to this builder. 38 | ## [br][br] 39 | ## [kbd]max_delay[/kbd] is the maximum time in miliseconds between two inputs. 40 | ## A negative delay means that an infinite amount of time is allowed between inputs. 41 | ## This parameter has no effect on the first requirement of a sequence. 42 | ## [br] 43 | ## [kbd]min_time_held[/kbd] is the minimum time in miliseconds that the input is required to be held. 44 | ## Inputs with a non-zero time are considered to be "charged inputs" and will only match with releases, not presses. 45 | func then(input: StringName, max_delay := 200, min_time_held := 0) -> Builder: 46 | var input_requirement := FrayInputRequirement.new() 47 | input_requirement.input = input 48 | input_requirement.max_delay = max_delay 49 | input_requirement.min_time_held = min_time_held 50 | _input_requirements.append(input_requirement) 51 | return self 52 | 53 | ## Sets the first input of this sequence. 54 | ## [br] 55 | ## This is an optional method that works similar to [method then] 56 | ## except it directly sets the first input, it doest not append. 57 | ## It also omits the max_delay parameter since the first input in a sequence ignores the delay. 58 | ## This exists to make the builder read better when chaining calls. 59 | ## [br] 60 | ## [kbd]min_time_held[/kbd] is the minimum time in miliseconds that the input is required to be held. 61 | func first(input: StringName, min_time_held := 0) -> Builder: 62 | _first_input = FrayInputRequirement.new() 63 | _first_input.input = input 64 | _first_input.max_delay = 0 65 | _first_input.min_time_held = min_time_held 66 | return self 67 | 68 | ## Used to neable negative edge. 69 | ## 70 | ## Returns a reference to this sequence builder. 71 | ## [br] 72 | ## If true the final input in the sequence is allowed to be triggered by a button release.. 73 | ## Search "fighting game negative edge" for more info on the concept 74 | func enable_negative_edge() -> Builder: 75 | _is_negative_edge_enabled = true 76 | return self 77 | -------------------------------------------------------------------------------- /src/input/sequence/sequence_tree.gd: -------------------------------------------------------------------------------- 1 | class_name FraySequenceTree 2 | extends Resource 3 | ## Contains a mapping of sequence names to [FraySequenceBranch] 4 | ## 5 | ## A sequence name can be associated with one or more sequence branches. 6 | ## This is to support sequence leneiency where partial or 'dirty' sequences 7 | ## will still be considered valid. 8 | 9 | # Type: Dictionary 10 | # Hint: 11 | var _sequence_branch_by_name: Dictionary 12 | 13 | ## Adds a sequence to list under a given name. 14 | ## [br][br] 15 | ## [kbd]sequence_name[/kbd] is the name of the sequence, a name can be associated with many sequence branches. 16 | ## A sequence can have many branchs which allows support for 'lenient inputs'. 17 | ## These are inputs that do not exactly match the intended sequence. 18 | ## [br][br] 19 | ## [kbd]sequence_branch[/kbd] is a collection of input requirements that define a branch. 20 | func add(sequence_name: StringName, sequence_branch: FraySequenceBranch) -> void: 21 | if not _sequence_branch_by_name.has(sequence_name): 22 | _sequence_branch_by_name[sequence_name] = [] 23 | _sequence_branch_by_name[sequence_name].append(sequence_branch) 24 | 25 | ## Removes a sequence branch at given index. 26 | func remove_sequence_branch(sequence_name: StringName, branch_index: int) -> void: 27 | if _sequence_branch_by_name.has(sequence_name): 28 | var sequences: Array = _sequence_branch_by_name[sequence_name] 29 | if sequences.size() < branch_index and branch_index >= 0: 30 | sequences.remove_at(branch_index) 31 | else: 32 | push_error("Index out of range") 33 | 34 | ## Removes all sequence branchs associated with a given sequence. 35 | func remove_sequence_branch_all(sequence_name: StringName) -> void: 36 | if _sequence_branch_by_name.has(sequence_name): 37 | _sequence_branch_by_name[sequence_name].clear() 38 | 39 | ## Removes sequence along with all its branchs. 40 | func remove_sequence(sequence_name: StringName) -> void: 41 | if _sequence_branch_by_name.has(sequence_name): 42 | _sequence_branch_by_name.erase(sequence_name) 43 | 44 | ## Returns the sequence branch at a given index. 45 | func get_sequence_branch(sequence_name: StringName, branch_index: int = 0) -> FraySequenceBranch: 46 | if _sequence_branch_by_name.has(sequence_name): 47 | var sequences: Array = _sequence_branch_by_name[sequence_name] 48 | if sequences.size() < branch_index and branch_index >= 0: 49 | return sequences[branch_index] 50 | else: 51 | push_error("Index out of range") 52 | return null 53 | 54 | ## Returns an array of all sequence branchs associated with the given sequence name. 55 | func get_sequence_branchs(sequence_name: StringName) -> Array: 56 | if _sequence_branch_by_name.has(sequence_name): 57 | return _sequence_branch_by_name[sequence_name] 58 | return [] 59 | 60 | ## Returns an array of all sequence names in the list. 61 | func get_sequence_names() -> PackedStringArray: 62 | return PackedStringArray(_sequence_branch_by_name.keys()) 63 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayAnimatorTracker 3 | extends Resource 4 | ## Abstract base class for animator trackers. 5 | 6 | ## Emitted when the observed animation controller reaches the start of the animation. 7 | signal animation_started(animation: StringName) 8 | 9 | ## Emitted when the observed animation controller reaches the end of the animation. 10 | signal animation_finished(animation: StringName) 11 | 12 | ## Emited when the observed animation controller updates the animation progress. 13 | signal animation_updated(animation: StringName, play_position: float) 14 | 15 | ## func(node: [Node]) -> [NodePath] 16 | ## [br] 17 | ## Returns the path from a given node to the tracker property of the observer containing this tracker.. 18 | var fn_get_path_from: Callable = Callable() 19 | 20 | ## func(path: [NodePath]) -> [Node] 21 | ## [br] 22 | ## Fetches a node relative to the observer containing this tracker. 23 | var fn_get_node_or_null: Callable = Callable() 24 | 25 | 26 | ## Readies this tracker. 27 | ## [br] 28 | ## This method is only intended to be used by the [FrayAnimationObserver]. 29 | func ready() -> void: 30 | _ready_impl() 31 | _add_anim_signals(_get_animation_list_impl()) 32 | 33 | 34 | ## Processes this tracker. 35 | ## [br] 36 | ## This method is only intended to be used by the [FrayAnimationObserver]. 37 | func process(delta: float) -> void: 38 | _process_impl(delta) 39 | 40 | 41 | ## Physics process this tracker. 42 | ## [br] 43 | ## This method is only intended to be used by the [FrayAnimationObserver]. 44 | func physics_process(delta: float) -> void: 45 | _physics_process_impl(delta) 46 | 47 | 48 | ## [code]Virtual method[/code] invoked when the [FrayAnimationObserver] using this tracker is readied. 49 | func _ready_impl() -> void: 50 | pass 51 | 52 | 53 | ## [code]Virtual method[/code] invoked when the tracker is being processed. [kbd]delta[/kbd] is in seconds. 54 | func _process_impl(delta: float) -> void: 55 | pass 56 | 57 | 58 | ## [code]Virtual method[/code] invoked when the tracker is being processed. [kbd]delta[/kbd] is in seconds. 59 | func _physics_process_impl(delta: float) -> void: 60 | pass 61 | 62 | 63 | ## [code]Virtual method[/code] used to report configuration warnings to the [FrayAnimationObserver]. 64 | func _get_configuration_warnings_impl() -> PackedStringArray: 65 | return [] 66 | 67 | 68 | ## Used to emit an animation started signal. Emits both built-in signal and per-animation user signal. 69 | func emit_anim_started(animation: StringName) -> void: 70 | animation_started.emit(animation) 71 | 72 | if has_user_signal(format_usignal_started(animation)): 73 | emit_signal(format_usignal_started(animation)) 74 | 75 | 76 | ## Used to emit an animation finished signal. Emits both built-in signal and per-animation user signal. 77 | func emit_anim_finished(animation: StringName) -> void: 78 | animation_started.emit(animation) 79 | 80 | if has_user_signal(format_usignal_finished(animation)): 81 | emit_signal(format_usignal_finished(animation)) 82 | 83 | 84 | ## Used to emit an animation updated signal. Emits both built-in signal and per-animation user signal. 85 | func emit_anim_updated(animation: StringName, play_position: float) -> void: 86 | animation_updated.emit(animation, play_position) 87 | 88 | if has_user_signal(format_usignal_updated(animation)): 89 | emit_signal(format_usignal_updated(animation), play_position) 90 | 91 | 92 | ## Returns the given [kbd]animation[/kbd] string formatted as a 'started' user signal. 93 | func format_usignal_started(animation: StringName) -> StringName: 94 | return "_%s_started" % animation 95 | 96 | 97 | ## Returns the given [kbd]animation[/kbd] string formatted as a 'finished' user signal. 98 | func format_usignal_finished(animation: StringName) -> StringName: 99 | return "_%s_finished" % animation 100 | 101 | 102 | ## Returns the given [kbd]animation[/kbd] string formatted as a 'updated' user signal. 103 | func format_usignal_updated(animation: StringName) -> StringName: 104 | return "_%s_updated" % animation 105 | 106 | 107 | ## [code]Abstract method[/code] used to return the list of animations beloning to the tracked animator. 108 | ## This list is used to initialize the per-animation user signals. 109 | func _get_animation_list_impl() -> PackedStringArray: 110 | assert(false, "Method not implemented") 111 | return [] 112 | 113 | 114 | func _add_anim_signals(animation_list: Array[String]) -> void: 115 | for anim_name in animation_list: 116 | add_user_signal(format_usignal_started(anim_name)) 117 | add_user_signal(format_usignal_finished(anim_name)) 118 | add_user_signal(format_usignal_updated(anim_name)) 119 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker_animated_sprite_2d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayAnimatorTrackerAnimatedSprite2D 3 | extends FrayAnimatorTracker 4 | ## [AnimatedSprite2D] tracker 5 | 6 | @export_node_path("AnimatedSprite2D") var anim_sprite_path: NodePath 7 | 8 | var _anim_sprite: AnimatedSprite2D = null 9 | 10 | var _prev_frame: int = -1 11 | 12 | 13 | func _ready_impl() -> void: 14 | _anim_sprite = fn_get_node_or_null.call(anim_sprite_path) 15 | 16 | func _get_configuration_warnings_impl() -> PackedStringArray: 17 | var anim_sprite = fn_get_node_or_null.call(anim_sprite_path) 18 | 19 | if anim_sprite == null: 20 | return ["Path to animated sprite not set."] 21 | 22 | return [] 23 | 24 | 25 | func _process_impl(delta: float) -> void: 26 | if _anim_sprite.is_playing(): 27 | if _anim_sprite.frame != _prev_frame: 28 | var last_frame := _anim_sprite.sprite_frames.get_frame_count(_anim_sprite.animation) - 1 29 | 30 | if _anim_sprite.frame == 0: 31 | emit_anim_started(_anim_sprite.animation) 32 | 33 | emit_anim_updated(_anim_sprite.animation, _anim_sprite.frame) 34 | 35 | if _anim_sprite.frame == last_frame: 36 | emit_anim_finished(_anim_sprite.animation) 37 | 38 | _prev_frame = _anim_sprite.frame 39 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker_animated_sprite_3d.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayAnimatorTrackerAnimatedSprite3D 3 | extends FrayAnimatorTracker 4 | ## [AnimatedSprite3D] tracker 5 | 6 | @export_node_path("AnimatedSprite3D") var anim_sprite_path: NodePath 7 | 8 | var _anim_sprite: AnimatedSprite3D = null 9 | 10 | var _prev_frame: int = -1 11 | 12 | 13 | func _ready_impl() -> void: 14 | _anim_sprite = fn_get_node_or_null.call(anim_sprite_path) 15 | 16 | 17 | func _process_impl(delta: float) -> void: 18 | if _anim_sprite.is_playing(): 19 | if _anim_sprite.frame != _prev_frame: 20 | var last_frame := _anim_sprite.sprite_frames.get_frame_count(_anim_sprite.animation) - 1 21 | 22 | if _anim_sprite.frame == 0: 23 | emit_anim_started(_anim_sprite.animation) 24 | 25 | emit_anim_updated(_anim_sprite.animation, _anim_sprite.frame) 26 | 27 | if _anim_sprite.frame == last_frame: 28 | emit_anim_finished(_anim_sprite.animation) 29 | 30 | _prev_frame = _anim_sprite.frame 31 | 32 | 33 | func _get_configuration_warnings_impl() -> PackedStringArray: 34 | var anim_sprite = fn_get_node_or_null.call(anim_sprite_path) 35 | 36 | if anim_sprite == null: 37 | return ["Path to animated sprite not set."] 38 | 39 | return [] 40 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker_animation_player.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayAnimatorTrackerAnimationPlayer 3 | extends FrayAnimatorTracker 4 | ## [AnimationPlayer] tracker 5 | 6 | @export_node_path("AnimationPlayer") var anim_player_path: NodePath 7 | 8 | var _anim_player: AnimationPlayer 9 | 10 | 11 | func _get_animation_list_impl() -> PackedStringArray: 12 | return _anim_player.get_animation_list() 13 | 14 | 15 | func _get_configuration_warnings_impl() -> PackedStringArray: 16 | var anim_player := fn_get_node_or_null.call(anim_player_path) 17 | 18 | if anim_player == null: 19 | return ["Path to animation player not set."] 20 | 21 | return [] 22 | 23 | 24 | func _ready_impl() -> void: 25 | _anim_player = fn_get_node_or_null.call(anim_player_path) 26 | 27 | 28 | func _process_impl(delta: float) -> void: 29 | if _anim_player.is_playing(): 30 | if _anim_player.current_animation_position == 0: 31 | emit_anim_started(_anim_player.current_animation) 32 | 33 | emit_anim_updated(_anim_player.current_animation, _anim_player.current_animation_position) 34 | 35 | if _anim_player.current_animation_position + delta >= _anim_player.current_animation_length: 36 | emit_anim_finished(_anim_player.current_animation) 37 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker_animation_tree.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | extends FrayAnimatorTracker 4 | ## EXPERIMENTAL [AnimationTree] tracker 5 | 6 | @export_node_path("AnimationTree") var anim_tree_path: NodePath 7 | 8 | var _anim_tree: AnimationTree = null 9 | var _playback_state := _PlaybackState.new() 10 | 11 | 12 | func _ready_impl() -> void: 13 | super() 14 | 15 | _anim_tree = fn_get_node_or_null.call(anim_tree_path) 16 | 17 | 18 | func _process_impl(delta: float) -> void: 19 | if not _anim_tree.tree_root is AnimationNodeStateMachine: 20 | push_warning( 21 | "Due to interface limitations only the AnimationNodeStateMachine tree root is supported for tracking." 22 | ) 23 | return 24 | 25 | var playback_path := "parameters/playback" 26 | var anim_node_path := "" 27 | var current_playback_state := _playback_state 28 | var current_playback = _anim_tree.get(playback_path) 29 | var current_node: StringName = current_playback.get_current_node() 30 | 31 | while current_playback != null: 32 | anim_node_path += "%s" % current_node 33 | 34 | if current_playback_state.current_node != current_node: 35 | if not current_playback_state.current_node.is_empty(): 36 | emit_anim_finished(anim_node_path) 37 | 38 | current_playback_state.child = null 39 | current_playback_state.current_node = current_node 40 | 41 | emit_anim_started(anim_node_path) 42 | 43 | emit_anim_updated(anim_node_path, current_playback.current_play_position) 44 | 45 | playback_path += "/%s/playback" % current_node 46 | current_playback = _anim_tree.get(playback_path) 47 | 48 | if current_playback: 49 | anim_node_path += "/" 50 | current_playback_state.child = _PlaybackState.new() 51 | current_playback_state = current_playback_state.child 52 | current_node = current_playback.get_current_node() 53 | 54 | 55 | func _get_animation_list_impl() -> PackedStringArray: 56 | var anim_player: AnimationPlayer = _anim_tree.get_node(_anim_tree.anim_player) 57 | return anim_player.get_animation_list() 58 | 59 | 60 | func _get_configuration_warnings_impl() -> PackedStringArray: 61 | var anim_tree := fn_get_node_or_null.call(anim_tree_path) 62 | 63 | if anim_tree == null: 64 | return ["Path to animation tree not set."] 65 | 66 | if not anim_tree.tree_root is AnimationNodeStateMachine: 67 | return [ 68 | "Due to interface limitations only the AnimationNodeStateMachine tree root is supported for tracking." 69 | ] 70 | 71 | return [] 72 | 73 | 74 | class _PlaybackState: 75 | extends RefCounted 76 | 77 | var current_node: StringName = "" 78 | var child: _PlaybackState = null 79 | -------------------------------------------------------------------------------- /src/state_mgmt/component/anim_tracker/animator_tracker_tween.gd: -------------------------------------------------------------------------------- 1 | class_name FrayAnimatorTrackerTween 2 | extends FrayAnimatorTracker 3 | ## [Tween] tracker 4 | 5 | var _tween: Tween = null 6 | 7 | 8 | func _get_animation_list_impl() -> PackedStringArray: 9 | return ["tween"] 10 | 11 | 12 | func set_tween(tween: Tween) -> void: 13 | _tween = tween 14 | 15 | emit_anim_started("tween") 16 | tween.finished.connect(_on_Tween_finished) 17 | tween.step_finished.connect(_on_Tween_step_finished) 18 | 19 | 20 | func _on_Tween_finished() -> void: 21 | emit_anim_finished("tween") 22 | 23 | 24 | func _on_Tween_step_finished(idx: int) -> void: 25 | emit_anim_updated("tween", idx) 26 | -------------------------------------------------------------------------------- /src/state_mgmt/component/animation_observer.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name FrayAnimationObserver 3 | extends Node 4 | ## A node used to observe animation events 5 | ## 6 | ## The animation observer can observe animation events from animators provided by the [FrayAnimatorTracker] resource. 7 | ## These events can be subscribed to per-animation using the included 'usignal' methods. 8 | ## The tracker also has event signals which can be connected to in a way that isn't per-animation. 9 | 10 | ## Used to determine which animator to observe 11 | @export var tracker: FrayAnimatorTracker: 12 | set(value): 13 | tracker = value 14 | tracker.fn_get_path_from = _get_path_from 15 | tracker.fn_get_node_or_null = get_node_or_null 16 | 17 | 18 | func _ready() -> void: 19 | if Engine.is_editor_hint(): 20 | return 21 | 22 | tracker.ready() 23 | 24 | 25 | func _process(delta: float) -> void: 26 | if Engine.is_editor_hint(): 27 | return 28 | 29 | tracker.process(delta) 30 | 31 | 32 | func _physics_process(delta: float) -> void: 33 | if Engine.is_editor_hint(): 34 | return 35 | 36 | tracker.physics_process(delta) 37 | 38 | 39 | func _get_configuration_warnings() -> PackedStringArray: 40 | var warnings: PackedStringArray = [] 41 | 42 | if tracker: 43 | warnings += tracker._get_configuration_warnings_impl() 44 | 45 | return warnings 46 | 47 | 48 | ## Returns a user defined signal which is used to connect to the start event of a given animation. 49 | ## [br] 50 | ## Signal excepts a method of type [code]func() -> void[/code] 51 | func usignal_started(animation: String) -> Signal: 52 | return Signal(tracker, tracker.format_usignal_started(animation)) 53 | 54 | 55 | ## Returns a user defined signal which is used to connect to the finish event of a given animation. 56 | ## [br] 57 | ## Signal excepts a method of type [code]func() -> void[/code] 58 | func usignal_finished(animation: String) -> Signal: 59 | return Signal(tracker, tracker.format_usignal_finished(animation)) 60 | 61 | 62 | ## Returns a user defined signal which is used to connect to the update event of a given animation. 63 | ## [br] 64 | ## Signal excepts a method of type [code]func(play_position: float) -> void[/code] 65 | func usignal_updated(animation: String) -> Signal: 66 | return Signal(tracker, tracker.format_usignal_updated(animation)) 67 | 68 | 69 | func _get_path_from(from_node: Node) -> NodePath: 70 | return NodePath(from_node.get_path_to(self)) 71 | -------------------------------------------------------------------------------- /src/state_mgmt/component/buffered_input_advancer.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | @icon("res://addons/fray/assets/icons/buffered_input_advancer.svg") 3 | class_name FrayBufferedInputAdvancer 4 | extends Node 5 | ## A node designed to advance a specified state machine using buffered inputs. 6 | ## 7 | ## This node automatically feeds buffered inputs to the designated state machine to trigger state transitions. 8 | ## When an input is accepted by the state machine, the advancer stops processing new inputs for the current frame. 9 | ## The advancer can be paused to control the timing of input feeding, but note that pausing doesn't affect the input buffer. 10 | ## Inputs can still be buffered during pauses, and they will expire if they exceed the maximum buffer time. 11 | ## This behavior allows you to define the timeframe during which user inputs can trigger state transitions. 12 | 13 | enum AdvanceMode { 14 | IDLE, ## Advance during the idle process 15 | PHYSICS, ## Advance during the physics process 16 | } 17 | 18 | ## If [code]true[/code], the buffer does not attempt to advance by feeding inputs to the state machine. 19 | ## Enabling or disabling this property allows control over when buffered inputs are consumed. 20 | ## This can be useful for managing when a player can 'cancel' an attack using their buffered inputs. 21 | @export var paused: bool = false 22 | 23 | ## The max time an input can exist in the buffer before it is ignored, in seconds. 24 | @export_range(0.0, 5.0, 0.01, "suffix:sec") var max_buffer_time: float = 1.0 25 | 26 | ## Determines the process during which the advancer machine can advance the state machine. 27 | @export var advance_mode: AdvanceMode 28 | 29 | var _state_machine: FrayStateMachine 30 | var _input_buffer: Array[BufferedInput] 31 | var _accepted_input_time_stamp: int 32 | 33 | 34 | func _ready() -> void: 35 | if Engine.is_editor_hint(): 36 | return 37 | 38 | _state_machine = get_state_machine() 39 | 40 | 41 | func _process(delta: float) -> void: 42 | if Engine.is_editor_hint(): 43 | return 44 | 45 | if advance_mode == AdvanceMode.IDLE: 46 | _advance() 47 | 48 | 49 | func _physics_process(delta: float) -> void: 50 | if Engine.is_editor_hint(): 51 | return 52 | 53 | if advance_mode == AdvanceMode.PHYSICS: 54 | _advance() 55 | 56 | 57 | func _get_configuration_warnings() -> PackedStringArray: 58 | if get_state_machine() == null: 59 | return ["This node is expected to be the the child of a FrayStateMachine."] 60 | return [] 61 | 62 | 63 | ## Buffers an input press to be processed by the state machine 64 | ## 65 | ## [kbd]input[/kbd] is the name of the input. 66 | ## This is just an identifier used in input transitions. 67 | ## It is not default associated with any actions in godot or inputs in fray. 68 | ## 69 | ## If [kbd]is_presse[/kbd] is true then a pressed input is buffered, else a released input is buffered. 70 | func buffer_press(input: StringName, is_pressed: bool = true) -> void: 71 | _input_buffer.append(BufferedInputPress.new(Time.get_ticks_msec(), input, is_pressed)) 72 | 73 | 74 | ## Buffers an input sequence to be processed by the state machine 75 | # 76 | ## [kbd]sequence_name[/kbd] is the name of the sequence. 77 | ## This is just an identifier used in input transitions. 78 | ## It is not default associated with any actions in godot or inputs in fray. 79 | func buffer_sequence(sequence_name: StringName) -> void: 80 | _input_buffer.append(BufferedInputSequence.new(Time.get_ticks_msec(), sequence_name)) 81 | 82 | 83 | ## Clears the input buffer 84 | func clear_buffer() -> void: 85 | _input_buffer.clear() 86 | 87 | 88 | ## Returns a shallow copy of the current buffer. 89 | func get_buffer() -> Array[BufferedInput]: 90 | return _input_buffer.duplicate() 91 | 92 | 93 | ## Returns the state machine this component belongs to if it exists. 94 | func get_state_machine() -> FrayStateMachine: 95 | return get_parent() as FrayStateMachine 96 | 97 | 98 | func _advance() -> void: 99 | while not _input_buffer.is_empty() and not paused: 100 | var buffered_input: BufferedInput = _input_buffer.pop_front() 101 | var is_input_within_buffer := ( 102 | buffered_input.calc_elapsed_time_msec() <= Fray.sec_to_msec(max_buffer_time) 103 | ) 104 | var accepted_input_age_sec := Fray.msec_to_sec( 105 | Time.get_ticks_msec() - _accepted_input_time_stamp 106 | ) 107 | var state_machine_input := _create_state_machine_input( 108 | buffered_input, accepted_input_age_sec 109 | ) 110 | 111 | if is_input_within_buffer and _state_machine.advance(state_machine_input): 112 | _accepted_input_time_stamp = Time.get_ticks_msec() 113 | break 114 | 115 | 116 | func _create_state_machine_input( 117 | buffered_input: BufferedInput, time_since_last_input: float 118 | ) -> Dictionary: 119 | if buffered_input is BufferedInputPress: 120 | return { 121 | input = buffered_input.input, 122 | is_pressed = buffered_input.is_pressed, 123 | time_since_last_input = time_since_last_input, 124 | time_held = buffered_input.calc_elapsed_time_msec() 125 | } 126 | elif buffered_input is BufferedInputSequence: 127 | return { 128 | sequence = buffered_input.sequence_name, 129 | time_since_last_input = time_since_last_input, 130 | } 131 | return {} 132 | 133 | 134 | class BufferedInput: 135 | extends RefCounted 136 | 137 | var time_stamp: int 138 | 139 | func _init(input_time_stamp: int = 0) -> void: 140 | time_stamp = input_time_stamp 141 | 142 | func calc_elapsed_time_msec() -> int: 143 | return Time.get_ticks_msec() - time_stamp 144 | 145 | 146 | class BufferedInputPress: 147 | extends BufferedInput 148 | 149 | var input: StringName 150 | var is_pressed: bool 151 | 152 | func _init( 153 | input_time_stamp: int = 0, input_name: StringName = "", input_is_pressed: bool = true 154 | ) -> void: 155 | super(input_time_stamp) 156 | input = input_name 157 | is_pressed = input_is_pressed 158 | 159 | 160 | class BufferedInputSequence: 161 | extends BufferedInput 162 | 163 | var sequence_name: StringName 164 | 165 | func _init(input_time_stamp: int = 0, input_sequence_name: StringName = "") -> void: 166 | super(input_time_stamp) 167 | sequence_name = input_sequence_name 168 | -------------------------------------------------------------------------------- /src/state_mgmt/state/a_star_graph.gd: -------------------------------------------------------------------------------- 1 | extends RefCounted 2 | ## Simple wrapper around [AStar2D] class that stores points as string names. 3 | 4 | # Type: Dictionary 5 | # Hint: 6 | var _point_id_by_node: Dictionary 7 | 8 | # Type: Dictionary 10 | var _node_by_point_id: Dictionary 11 | 12 | var _astar: _CustomAStar 13 | var _astar_point_id := 0 14 | var _travel_path: PackedStringArray 15 | var _travel_index: int 16 | 17 | # func_get_transition_cost: func(StringName, StringName) -> float 18 | func _init(func_get_transition_cost: Callable) -> void: 19 | _astar = _CustomAStar.new(func_get_transition_cost, _get_node_from_id) 20 | 21 | ## Adds point to graph. 22 | func add_point(state_name: StringName) -> void: 23 | _point_id_by_node[state_name] = _astar_point_id 24 | _node_by_point_id[_astar_point_id] = state_name 25 | _astar.add_point(_astar_point_id, Vector2.ZERO) 26 | _astar_point_id += 1 27 | 28 | ## Removes point from graph. 29 | func remove_point(state_name: StringName) -> void: 30 | var point_id: int = _point_id_by_node[state_name] 31 | _astar.remove_point(point_id) 32 | _point_id_by_node.erase(state_name) 33 | _node_by_point_id.erase(point_id) 34 | 35 | ## Renames point on graph. 36 | func rename_point(old_name: StringName, new_name: StringName) -> void: 37 | if _point_id_by_node.has(old_name) and not _point_id_by_node.has(new_name): 38 | var id: int = _point_id_by_node[old_name] 39 | 40 | _point_id_by_node.erase(old_name) 41 | _node_by_point_id.erase(id) 42 | 43 | _point_id_by_node[new_name] = id 44 | _node_by_point_id[id] = new_name 45 | 46 | ## Connects [kbd]from[/kbd] one point [kbd]to[/kbd] another point. 47 | ## [br] 48 | ## if [kbd]bidirectional[/kbd] is true then transitions are allowed in both directions. 49 | func connect_points(from: StringName, to: StringName, bidirectional: bool) -> void: 50 | _astar.connect_points(_point_id_by_node[from], _point_id_by_node[to], bidirectional) 51 | 52 | ## Disconnects [kbd]from[/kbd] one point [kbd]to[/kbd] another point. 53 | ## [br] 54 | ## if [kbd]bidirectional[/kbd] is true then transitions are disconnected in both directions. 55 | func disconnect_points(from: StringName, to: StringName, bidirectional: bool) -> void: 56 | _astar.disconnect_points(_point_id_by_node[from], _point_id_by_node[to], bidirectional) 57 | 58 | ## Determines the optimal travel path [kbd]from[/kbd] one point [kbd]to[/kbd] another point. 59 | func compute_travel_path(from: StringName, to: StringName) -> PackedStringArray: 60 | var id_path := _astar.get_id_path(_point_id_by_node[from], _point_id_by_node[to]) 61 | var path := PackedStringArray() 62 | 63 | for id in id_path: 64 | path.append(_node_by_point_id[id]) 65 | 66 | _travel_index = 0 67 | _travel_path = path 68 | return path 69 | 70 | ## Returns the cached path of a previous computation. 71 | func get_computed_travel_path() -> PackedStringArray: 72 | return _travel_path 73 | 74 | ## Clears the previously cached travel path. 75 | func clear_travel_path() -> void: 76 | _travel_path = PackedStringArray() 77 | 78 | ## Returns [code]true[/code] if there is a next point to fetch using [member get_next_travel_node]. 79 | func has_next_travel_point() -> bool: 80 | return _travel_index < _travel_path.size() 81 | 82 | ## Returns the name of the next node on the calculated travel path. 83 | func get_next_travel_point() -> StringName: 84 | if not has_next_travel_point(): 85 | return "" 86 | var next_state := _travel_path[_travel_index] 87 | _travel_index += 1 88 | return next_state 89 | 90 | 91 | func _get_node_from_id(id: int) -> StringName: 92 | return _node_by_point_id[id] 93 | 94 | 95 | class _CustomAStar: 96 | extends AStar2D 97 | 98 | # Type: func(StringName, StringName) -> int 99 | var _func_get_transition_cost: Callable 100 | 101 | # Type: func(int) -> StringName 102 | var _func_get_node_from_id: Callable 103 | 104 | func _init(func_get_transition_cost: Callable, func_get_node_from_id: Callable) -> void: 105 | _func_get_transition_cost = func_get_transition_cost 106 | _func_get_node_from_id = func_get_node_from_id 107 | 108 | func _compute_cost(from_id: int, to_id: int) -> float: 109 | var from: StringName = _func_get_node_from_id.call(from_id) 110 | var to: StringName = _func_get_node_from_id.call(to_id) 111 | return _func_get_transition_cost.call(from, to) 112 | -------------------------------------------------------------------------------- /src/state_mgmt/state/state.gd: -------------------------------------------------------------------------------- 1 | class_name FrayState 2 | extends Resource 3 | ## Base state class 4 | 5 | # Type: WeakRef 6 | var _root_ref: WeakRef 7 | 8 | # Type: WeakRef 9 | var _parent_ref: WeakRef 10 | 11 | # Type: Dictionary 12 | var _callables_by_signal: Dictionary 13 | 14 | 15 | ## Returns [code]true[/code] if this state is child of another state. 16 | func has_parent() -> bool: 17 | return _parent_ref != null 18 | 19 | 20 | ## Returns the parent of this state if it exists. 21 | func get_parent() -> FrayCompoundState: 22 | return _parent_ref.get_ref() if has_parent() else null 23 | 24 | 25 | ## Returns the root of of this state's hierarchy. 26 | func get_root() -> FrayCompoundState: 27 | return _root_ref.get_ref() if _root_ref != null else null 28 | 29 | 30 | ## Returns [code]true[/code] if the state is considered to be done processing. 31 | func is_done_processing() -> bool: 32 | return _is_done_processing_impl() 33 | 34 | 35 | ## Returns the node located at the given [kbd]path[/kbd] relative to the state machine. 36 | func get_node(path: NodePath) -> Node: 37 | return get_root().get_node(path) 38 | 39 | 40 | ## Returns the first node of a given [kbd]type[/kbd] attached to this state machine. 41 | func get_node_of_type(type: Variant) -> Node: 42 | return get_root().get_node_of_type(type) 43 | 44 | 45 | ## Returns all nodes of a given [kbd]type[/kbd] attached to this state machine. 46 | func get_nodes_of_type(type: Variant) -> Array[Node]: 47 | return get_root().get_nodes_of_type(type) 48 | 49 | 50 | ## Process child states then this state. 51 | ## This method is intended to only be used by the [FrayCompoundState] when exiting sub-states. 52 | func exit() -> void: 53 | for sig in _callables_by_signal: 54 | for callable in _callables_by_signal[sig]: 55 | if sig.is_connected(callable): 56 | sig.disconnect(callable) 57 | 58 | _callables_by_signal.clear() 59 | _exit_impl() 60 | 61 | 62 | ## Connects a signal which disconnects when this state is no longer active. 63 | func connect_while_active(sig: Signal, callable: Callable, flags: int = 0) -> Error: 64 | if not sig.is_connected(callable): 65 | if not _callables_by_signal.has(sig): 66 | _callables_by_signal[sig] = [] 67 | 68 | _callables_by_signal[sig].append(callable) 69 | 70 | return sig.connect(callable, flags) 71 | 72 | 73 | ## [code]Virtual method[/code] used to implement [method is_done_processing]. 74 | func _is_done_processing_impl() -> bool: 75 | return true 76 | 77 | 78 | ## [code]Virtual method[/code] invoked when the state machine is initialized. Child states are readied before parent states. 79 | ## [br] 80 | ## [kbd]context[/kbd] is read-only dictionary which provides a way to pass data which is available to all states within a hierachy. 81 | func _ready_impl(context: Dictionary) -> void: 82 | pass 83 | 84 | 85 | ## [code]Virtual method[/code] invoked when the state is entered. 86 | ## [br] 87 | ## [kbd]args[/kbd] is user-defined data which is passed to the advanced state on enter. 88 | func _enter_impl(args: Dictionary) -> void: 89 | pass 90 | 91 | 92 | ## [code]Virtual method[/code] invoked when the state is existed. 93 | func _exit_impl() -> void: 94 | pass 95 | 96 | 97 | ## [code]Virtual method[/code] invokved when the state is added to the state machine hierarchy. 98 | func _enter_tree_impl() -> void: 99 | pass 100 | 101 | 102 | ## [code]Virtual method[/code] invoked when the state is being processed. [kbd]delta[/kbd] is in seconds. 103 | func _process_impl(delta: float) -> void: 104 | pass 105 | 106 | 107 | ## [code]Virtual method[/code] invoked when the state is being physics processed. [kbd]delta[/kbd] is in seconds. 108 | func _physics_process_impl(delta: float) -> void: 109 | pass 110 | 111 | 112 | ## [code]Virtual method[/code] invoked when there is a godot input event. 113 | func _input_impl(event: InputEvent) -> void: 114 | pass 115 | 116 | 117 | ## [code]Virtual method[/code] invoked when there is a godot input event that has not been consumed by [method Node._input]. 118 | func _unhandled_input_impl(event: InputEvent) -> void: 119 | pass 120 | -------------------------------------------------------------------------------- /src/state_mgmt/state_machine.gd: -------------------------------------------------------------------------------- 1 | @icon("res://addons/fray/assets/icons/state_machine.svg") 2 | class_name FrayStateMachine 3 | extends Node 4 | ## General purpose hierarchical state machine 5 | ## 6 | ## This class wraps around the [FrayCompoundState] and uses the [SceneTree] to 7 | ## process state nodes. 8 | 9 | ## Emitted when the current state within the root changes. 10 | signal state_changed(from: StringName, to: StringName) 11 | 12 | enum AdvanceMode { 13 | IDLE, ## Advance during the idle process 14 | PHYSICS, ## Advance during the physics process 15 | MANUAL, ## Advance manually 16 | } 17 | 18 | ## If true the [FrayStateMachine] will be processing. 19 | @export var active: bool = false 20 | 21 | ## Determines the process during which the state machine can advance. 22 | ## Advancing only relates to transitions. 23 | ## If the state machine is active then the current state is still processed 24 | ## during both idle and physics frames regardless of advance mode. 25 | @export var advance_mode: AdvanceMode = AdvanceMode.IDLE 26 | 27 | var _root: FrayCompoundState 28 | 29 | 30 | func _input(event: InputEvent) -> void: 31 | if _can_process(): 32 | _root.input(event) 33 | 34 | 35 | func _unhandled_input(event: InputEvent): 36 | if _can_process(): 37 | _root.unhandled_input(event) 38 | 39 | 40 | func _process(delta: float) -> void: 41 | if _can_process(): 42 | _root.process(delta) 43 | 44 | if advance_mode == AdvanceMode.IDLE: 45 | advance() 46 | 47 | 48 | func _physics_process(delta: float) -> void: 49 | if _can_process(): 50 | _root.physics_process(delta) 51 | 52 | if advance_mode == AdvanceMode.PHYSICS: 53 | advance() 54 | 55 | 56 | ## Used to initialize the root of the state machine. 57 | ## [br] 58 | ## [kbd]context[/kbd] is an optional dictionary which can pass read-only data to all states within the hierarchy. 59 | ## This data is accessible within a state's [method FrayState._ready_impl] method when it is invoked. 60 | ## [br] 61 | ## [b]WARN:[/b] The dictionary provided to the context argument will be made read-only. 62 | func initialize(context: Dictionary, root: FrayCompoundState) -> void: 63 | if root.has_parent(): 64 | push_error("Failed to initialize statemachine. Provided root has parent state.") 65 | return 66 | 67 | _root = root 68 | _root._fn_get_node = get_node 69 | _root._fn_get_node_of_type = get_node_of_type 70 | _root._fn_get_nodes_of_type = get_nodes_of_type 71 | _root.ready(context) 72 | _root._enter_impl({}) 73 | _root.transitioned.connect(_on_RootState_transitioned) 74 | 75 | 76 | ## Returns the state machine root. 77 | func get_root() -> FrayCompoundState: 78 | return _root 79 | 80 | 81 | ## Used to manually advance the state machine. 82 | func advance(input: Dictionary = {}, args: Dictionary = {}) -> bool: 83 | if _can_process(): 84 | return _advance_impl(input, args) 85 | return false 86 | 87 | 88 | ## Returns the first node of a given [kbd]type[/kbd] attached to this state machine. Types can either be scripts or native classes. 89 | func get_node_of_type(type: Variant) -> Node: 90 | for child in get_children(): 91 | if is_instance_of(child, type): 92 | return child 93 | 94 | return null 95 | 96 | 97 | ## Returns all nodes of a given [kbd]type[/kbd] attached to this state machine. Types can either be scripts or native classes. 98 | func get_nodes_of_type(type: Variant) -> Array[Node]: 99 | var nodes: Array[Node] = [] 100 | 101 | for child in get_children(): 102 | if is_instance_of(child, type): 103 | nodes.append(child) 104 | 105 | return nodes 106 | 107 | 108 | ## Returns the name of the root's current state. 109 | ## [br] 110 | ## Shorthand for root.get_current_state_name() 111 | func get_current_state_name() -> StringName: 112 | if _ERR_ROOT_NOT_SET("Failed to travel"): 113 | return "" 114 | return _root.get_current_state_name() 115 | 116 | 117 | ## Transitions from the current state to another one, following the shortest path. 118 | ## Transitions will ignore prerequisites and advance conditions, but will wait until a state is done processing. 119 | ## If no travel path can be formed then the [kbd]to[/kbd] state will be visted directly. 120 | ## [br] 121 | ## Shorthand for root.travel(input, args) 122 | func travel(to: StringName, args: Dictionary = {}) -> void: 123 | if _ERR_ROOT_NOT_SET("Failed to travel"): 124 | return 125 | 126 | _root.travel(to, args) 127 | 128 | 129 | ## Goes directly to the given state if it exists. 130 | ## If a travel is being performed it will be interupted. 131 | ## [br] 132 | ## Shorthand for root.goto(path, args) 133 | func goto(path: StringName, args: Dictionary = {}) -> void: 134 | if _ERR_ROOT_NOT_SET("Failed to go to state"): 135 | return 136 | 137 | _root.goto(path, args) 138 | 139 | 140 | ## Goes directly to the start state. 141 | ## [br] 142 | ## Shorthand for root.goto_start() 143 | func goto_start(args: Dictionary = {}) -> void: 144 | if _ERR_ROOT_NOT_SET("Failed to go to start state"): 145 | return 146 | 147 | _root.goto_start(args) 148 | 149 | 150 | ## Goes directly to the end state. 151 | ## [br] 152 | ## Shorthand for root.goto_start() 153 | func goto_end(args: Dictionary = {}) -> void: 154 | if _ERR_ROOT_NOT_SET("Failed to go to end state"): 155 | return 156 | 157 | _root.goto_end(args) 158 | 159 | 160 | ## [code]Virtual method[/code] used to implement [method advance] method. 161 | func _advance_impl(input: Dictionary = {}, args: Dictionary = {}) -> bool: 162 | if get_current_state_name().is_empty(): 163 | push_warning("Failed to advance. Current state not set.") 164 | return false 165 | 166 | return _root.advance(input, args) 167 | 168 | 169 | func _can_process() -> bool: 170 | return _root != null and active 171 | 172 | 173 | func _ERR_ROOT_NOT_SET(msg: String = "") -> bool: 174 | if _root == null: 175 | push_error("%s. Current state not set." % msg) 176 | return true 177 | 178 | return false 179 | 180 | 181 | func _on_RootState_transitioned(from: StringName, to: StringName) -> void: 182 | state_changed.emit(from, to) 183 | -------------------------------------------------------------------------------- /src/state_mgmt/transition/input_transition.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputTransition 2 | extends FrayStateMachineTransition 3 | ## Abstract input based transition class. 4 | ## 5 | ## Accepts input dictionary that contains these entires: 6 | ## [br] [br] 7 | ## - [code]time_since_last_input[/code] is the time in seconds since the last input was provided, as a [float]. 8 | 9 | ## Minimum time that must have elapsed since the last input, in seconds. If negative then this check is ignored. 10 | @export var min_input_delay: float = -1.0 11 | 12 | 13 | func _accepts_impl(input: Dictionary) -> bool: 14 | return _can_ignore_min_input_delay() or input.get("time_since_last_input", 0) >= min_input_delay 15 | 16 | 17 | func _can_ignore_min_input_delay() -> bool: 18 | return sign(min_input_delay) == -1 19 | -------------------------------------------------------------------------------- /src/state_mgmt/transition/input_transition_press.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputTransitionPress 2 | extends FrayInputTransition 3 | ## Input transition representing an atomic press input such as a key or button. 4 | ## 5 | ## Accepts input dictionary that contains these entires: 6 | ## [br] [br] 7 | ## - [code]input[/code] is the name of the input, as a [StringName]; 8 | ## [br] [br] 9 | ## - [code]is_pressed[/code] is the state of the input, as a [bool]; 10 | ## [br] [br] 11 | ## - [code]time_held[/code] is the time in seconds that the input was held for, as a [float]. 12 | 13 | ## Input name. 14 | @export var input: StringName = "" 15 | 16 | ## If [code]true[/code] the input is only accepted on release. 17 | @export var is_triggered_on_release: bool = false 18 | 19 | ## Minimum time the input must be held in seconds. If negative then this check is ignored. 20 | @export var min_time_held: float = -1.0 21 | 22 | ## Maximum time the input is allowed to be held in seconds. If negative then this check is ignored. 23 | @export var max_time_held: float = -1.0 24 | 25 | 26 | func _accepts_impl(sm_input: Dictionary) -> bool: 27 | return ( 28 | super(sm_input) 29 | and sm_input.get("input", null) == input 30 | and sm_input.get("is_pressed", false) != is_triggered_on_release 31 | and (_can_ignore_min_time_held() or sm_input.get("time_held", 0.0) >= min_time_held) 32 | and (_can_ignore_max_time_held() or sm_input.get("time_held", 0.0) <= max_time_held) 33 | ) 34 | 35 | 36 | func _can_ignore_min_time_held() -> bool: 37 | return min_time_held < 0 38 | 39 | 40 | func _can_ignore_max_time_held() -> bool: 41 | return max_time_held < 0 42 | -------------------------------------------------------------------------------- /src/state_mgmt/transition/input_transition_sequence.gd: -------------------------------------------------------------------------------- 1 | class_name FrayInputTransitionSequence 2 | extends FrayInputTransition 3 | ## Input transition representing a sequence input. 4 | ## 5 | ## Accepts input dictionary that contains these entires: 6 | ## [br] [br] 7 | ## - [code]sequence[/code] is the name of the input, as a [StringName]; 8 | 9 | ## Name of the sequence. 10 | @export var sequence: StringName = "" 11 | 12 | func _accepts_impl(sm_input: Dictionary) -> bool: 13 | return ( 14 | super(sm_input) 15 | and sm_input.get("sequence", null) == sequence 16 | ) 17 | -------------------------------------------------------------------------------- /src/state_mgmt/transition/state_machine_transition.gd: -------------------------------------------------------------------------------- 1 | class_name FrayStateMachineTransition 2 | extends Resource 3 | ## Represents transition from one state to another. 4 | 5 | enum SwitchMode{ 6 | IMMEDIATE, ## Switch to the next state immediately. 7 | AT_END, ## Wait for the current state to finish processing, then switch to the beginning of the next state. 8 | } 9 | 10 | ## If [member auto_advance] is enabled then the transition will occur automatically when all advance conditions are true. 11 | @export var advance_conditions: PackedStringArray 12 | 13 | ## Prevents transition from occuring unless all prerequisite conditions are true. 14 | @export var prereqs: PackedStringArray 15 | 16 | ## If true then the transition can advance automatically. 17 | @export var auto_advance: bool = false 18 | 19 | ## Lower priority transitions are be preffered when determining next transitions. 20 | @export var priority: int = 0 21 | 22 | ## The transition type. 23 | @export var switch_mode: SwitchMode = SwitchMode.AT_END 24 | 25 | ## Returns true if the transition accepts the given input. 26 | func accepts(input: Dictionary) -> bool: 27 | return _accepts_impl(input) 28 | 29 | ## [code]Virtual method[/code] used to implement [method accepts] method. 30 | func _accepts_impl(input: Dictionary) -> bool: 31 | return true 32 | --------------------------------------------------------------------------------