├── .gitignore ├── LICENSE ├── README.md ├── addons └── g4climbnglide │ ├── CameraController.gd │ ├── CharacterAnimationController.gd │ ├── CharacterController.gd │ ├── PlayerContainer.gd │ ├── PlayerContainer.tscn │ ├── StaminaBar.gd │ ├── alpha-e-box.svg.import │ ├── icon.png │ ├── icon.png.import │ ├── vertbar.png │ └── vertbar.png.import └── screenshots ├── .gdignore └── Recording 2022-09-23 at 06.43.38.gif /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Godot-specific ignores 3 | .import/ 4 | export.cfg 5 | export_presets.cfg 6 | 7 | # Mono-specific ignores 8 | .mono/ 9 | data_*/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ashton McAllan 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 | # g4 climb'n'glide character controller 2 | A character controller for Godot 4 with run, jump, climb, glide, and stamina. 3 | 4 | ![image](https://github.com/acegiak/g4-climb-n-glide-character-controller/raw/main/screenshots/Recording%202022-09-23%20at%2006.43.38.gif) 5 | 6 | ## Installation 7 | * Clone or download the repo 8 | * put the addons/g4climbnglide folder in your project's addons folder 9 | * make sure your inputmap defines the following actions: 10 | * "move_forward" 11 | * "move_backward" 12 | * "move_left" 13 | * "move_right" 14 | * "jump" 15 | * "dash" 16 | 17 | -------------------------------------------------------------------------------- /addons/g4climbnglide/CameraController.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | @onready var player_container = $".." 3 | 4 | @export var follow_object:NodePath = "../Player" 5 | @onready var follow_speed:float = player_container.follow_speed 6 | @onready var invertX:bool = player_container.invertX 7 | @onready var invertY:bool = player_container.invertY 8 | var follow:Node3D 9 | 10 | 11 | var mousev:Vector2 12 | # Called when the node enters the scene tree for the first time. 13 | func _ready(): 14 | Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) 15 | follow = get_node(follow_object) 16 | pass # Replace with function body. 17 | 18 | func _input(event): 19 | if event is InputEventMouseMotion: 20 | mousev = event.relative*0.001 21 | if event is InputEventJoypadMotion: 22 | mousev = Input.get_vector("camera_left","camera_right","camera_up","camera_down")*0.1 23 | 24 | 25 | # Called every frame. 'delta' is the elapsed time since the previous frame. 26 | func _process(delta): 27 | 28 | rotate_y(mousev.x if invertX else mousev.x*-1) 29 | rotate_object_local(Vector3.LEFT,(mousev.y if invertY else mousev.y*-1)) 30 | rotation.x = clamp(rotation.x, -15, 15) 31 | if follow != null: 32 | var goto = follow.position+Vector3(0,1,0) 33 | position = position + ((goto-position)*delta*follow_speed) 34 | mousev = Vector2.ZERO 35 | 36 | pass 37 | -------------------------------------------------------------------------------- /addons/g4climbnglide/CharacterAnimationController.gd: -------------------------------------------------------------------------------- 1 | extends AnimationTree 2 | 3 | var last_state:CharacterController.state 4 | var destinationStates:Dictionary = {} 5 | 6 | # Called when the node enters the scene tree for the first time. 7 | func _ready(): 8 | destinationStates["parameters/Air/blend_amount"]= 0.0 9 | destinationStates["parameters/Climb/blend_amount"]= 0.0 10 | destinationStates["parameters/Glide/blend_amount"]= 0.0 11 | destinationStates["parameters/Speed/scale"]= 1.0 12 | destinationStates["parameters/Run/blend_amount"]= 0.0 13 | destinationStates["parameters/Dash/blend_amount"]= 0.0 14 | destinationStates["parameters/JumpStart/active"]= 0.0 15 | # Replace with function body. 16 | 17 | 18 | # Called every frame. 'delta' is the elapsed time since the previous frame. 19 | func _process(delta): 20 | for stringname in destinationStates.keys(): 21 | if get(stringname) is bool: 22 | set(stringname,destinationStates[stringname]) 23 | continue 24 | var diff = destinationStates[stringname] - get(stringname) #destination 1, current 0.4, diff 0.6 25 | var dir = 1 26 | if diff > 0: 27 | dir = 1 28 | else: 29 | dir = -1 30 | 31 | if diff != 0: 32 | var result = clampf(get(stringname)+(delta*dir/0.1),0,1) 33 | set(stringname,result) 34 | 35 | 36 | func state_changed(new_state:CharacterController.state): 37 | destinationStates["parameters/Air/blend_amount"]= 0.0 38 | destinationStates["parameters/Climb/blend_amount"]= 0.0 39 | destinationStates["parameters/Glide/blend_amount"]= 0.0 40 | destinationStates["parameters/Speed/scale"]= 1.0 41 | destinationStates["parameters/Run/blend_amount"]= 0.0 42 | destinationStates["parameters/Dash/blend_amount"]= 0.0 43 | destinationStates["parameters/JumpStart/active"]= 0.0 44 | if new_state == CharacterController.state.CLIMB: 45 | destinationStates["parameters/Climb/blend_amount"]= 1.0 46 | if new_state == CharacterController.state.GLIDE: 47 | destinationStates["parameters/Glide/blend_amount"]= 1.0 48 | destinationStates["parameters/Air/blend_amount"]= 1.0 49 | if new_state == CharacterController.state.FALL: 50 | destinationStates["parameters/Air/blend_amount"]= 1.0 51 | if new_state == CharacterController.state.JUMP: 52 | destinationStates["parameters/Air/blend_amount"]= 1.0 53 | destinationStates["parameters/JumpStart/active"]= 1.0 54 | if new_state == CharacterController.state.RUN: 55 | destinationStates["parameters/Run/blend_amount"]= 1.0 56 | if new_state == CharacterController.state.DASH: 57 | destinationStates["parameters/Dash/blend_amount"]= 1.0 58 | if new_state == CharacterController.state.HANG: 59 | destinationStates["parameters/Climb/blend_amount"]= 1.0 60 | destinationStates["parameters/Speed/scale"]= 0.0 61 | last_state = new_state 62 | -------------------------------------------------------------------------------- /addons/g4climbnglide/CharacterController.gd: -------------------------------------------------------------------------------- 1 | extends CharacterBody3D 2 | class_name CharacterController 3 | @onready var player_container = $".." 4 | 5 | @onready var jump_length:float = player_container.jump_length 6 | @onready var jump_power:float = player_container.jump_power 7 | @onready var wall_jump_power:float = player_container.wall_jump_power 8 | @onready var gravity:float = player_container.gravity 9 | @onready var runspeed:float = player_container.runspeed 10 | @onready var climbspeed:float = player_container.climbspeed 11 | @export var camera_object:NodePath 12 | @onready var climb_hold_force:float = player_container.climb_hold_force 13 | @onready var climb_hold_time:float = player_container.climb_hold_time 14 | @onready var air_run_time:float = player_container.air_run_time 15 | @onready var wall_jump_outness:float = player_container.wall_jump_outness 16 | @onready var dash_speed:float = player_container.dash_speed 17 | @onready var dash_length:float = player_container.dash_length 18 | @onready var air_dash:bool = player_container.air_dash 19 | @onready var attack_time:float = player_container.attack_time 20 | 21 | 22 | enum state{RUN,JUMP,FALL,GLIDE,CLIMB,HANG,IDLE,DASH,ATTACK} 23 | signal change_state(state) 24 | 25 | var camera:Node3D 26 | var control_direction:Vector2 = Vector2(0,0) 27 | var fallspeed = 0 28 | var movement:Vector3 = Vector3(0,0,0) 29 | var jumptime:float = 0 30 | var dashtime:float = 0 31 | var dashdir:Vector3 32 | var last_facing:Vector2 = Vector2(0,1) 33 | var glidable = false 34 | var current_state:state = state.IDLE 35 | var previous_state:state = state.IDLE 36 | var locked:Array = [] 37 | 38 | var last_on_wall:float = 0 39 | var last_wall_normal:Vector3 = Vector3.FORWARD 40 | var last_on_floor:float = 0 41 | 42 | 43 | var climbing:bool = false 44 | 45 | 46 | var last_attacked:float = 10 47 | var next_attack:float = 0 48 | 49 | #grab the camera so we can use it for relative movement 50 | func _ready(): 51 | camera = get_node(camera_object) 52 | 53 | 54 | #Emit signals when we change player state 55 | func _process(delta): 56 | if current_state != previous_state: 57 | emit_signal("change_state",current_state) 58 | previous_state = current_state 59 | 60 | # last_attacked += delta 61 | # if Input.is_action_just_pressed("attack"): 62 | # last_attacked = 0 63 | # if next_attack <= 0: 64 | # 65 | # var weapons = find_children("*","Weapon",true,false) 66 | # 67 | # if last_attacked < attack_time: 68 | # next_attack = attack_time 69 | # move("back_weapon","hand_weapon") 70 | # $AnimationTree.set("parameters/Attack/active",1.0) 71 | # if weapons.size()>0: 72 | # weapons[0].active = true 73 | # $DamageHitbox.do_damage() 74 | # else: 75 | # $AnimationTree.set("parameters/Attack/active",0.0) 76 | # move("hand_weapon","back_weapon") 77 | # if weapons.size()>0: 78 | # weapons[0].active = false 79 | # next_attack -= delta 80 | 81 | func move(from,to): 82 | var fnode = find_child(from,true,false) 83 | var tnode = find_child(to,true,false) 84 | if fnode.get_child_count() > 0: 85 | var moved = fnode.get_child(0) 86 | fnode.remove_child(moved) 87 | tnode.add_child(moved) 88 | return moved 89 | 90 | func _physics_process(delta): 91 | #don't do anythng till the camera is ready or we'll crash 92 | if camera == null: 93 | return 94 | 95 | #zero movement out before we start 96 | movement = Vector3.ZERO; 97 | 98 | 99 | #get directional input 100 | if ! state.RUN in locked: 101 | var vertical = Input.get_action_strength("move_forward") - Input.get_action_strength("move_back") 102 | var horizontal = Input.get_action_strength("move_left") - Input.get_action_strength("move_right") 103 | control_direction = Vector2(horizontal,vertical).normalized() 104 | 105 | #save that directional input in case we need it later 106 | if control_direction.length()>0.1: 107 | last_facing = control_direction 108 | 109 | 110 | #give a little bit of buffer for on_wall and on_floor 111 | if is_on_wall(): 112 | last_on_wall = 0 113 | last_wall_normal = get_wall_normal() 114 | else: 115 | last_on_wall += delta 116 | 117 | if is_on_floor(): 118 | last_on_floor = 0 119 | else: 120 | last_on_floor += delta 121 | 122 | 123 | #get jump/climb button input. If we're not climbing then store jump for later 124 | if Input.is_action_just_pressed("jump") and ! state.JUMP in locked: 125 | if last_on_wall 0: 134 | current_state = state.CLIMB 135 | else: 136 | current_state = state.HANG 137 | movement += last_wall_normal*-1*climb_hold_force 138 | look_at(global_position+(last_wall_normal*5),Vector3.UP) 139 | var wallangle = (last_facing).rotated(PI*1.5).angle() 140 | var camera_relative = Vector3(0,climbspeed*control_direction.length(),0).rotated(last_wall_normal*-1,wallangle) 141 | movement += camera_relative 142 | 143 | #if we're climbing and we've stored a jump, that's a wall jump. Do it! 144 | if jumptime > 0: 145 | jumptime -= delta 146 | movement += jumptime*delta*wall_jump_power*((last_wall_normal*0.5)+(Vector3.UP)).normalized() 147 | 148 | #cause climbing is a weird exception space, we do the movement and 149 | # then return so we don't accidentally do regular movement as well 150 | velocity = movement 151 | move_and_slide() 152 | return 153 | 154 | 155 | #if we're not climbing then stand up straight and end the climb time 156 | rotation.x = 0 157 | climbing = false 158 | 159 | 160 | #now that we know we're not climbing, let's do horizontal movement! 161 | if control_direction.length()>0.1 and ! state.RUN in locked: 162 | rotation.y = (last_facing*Vector2(-1,1)).rotated(PI*1.5).angle()+camera.rotation.y 163 | var camera_relative = Vector3(0,0,runspeed*control_direction.length()).rotated(Vector3.UP,rotation.y) 164 | movement += camera_relative 165 | 166 | #dash input and set direction 167 | if Input.is_action_just_pressed("dash") and dashtime <= 0 and (last_on_floor< air_run_time || air_dash) and ! state.DASH in locked: 168 | dashtime = dash_length 169 | rotation.y = (last_facing*Vector2(-1,1)).rotated(PI*1.5).angle()+camera.rotation.y 170 | dashdir = Vector3(0,0,dash_speed*runspeed*control_direction.length()).rotated(Vector3.UP,rotation.y) 171 | 172 | #extra movement in dash direction 173 | if dashtime > 0: 174 | current_state = state.DASH 175 | movement += dashdir 176 | dashtime -= delta 177 | 178 | #process falling and gliding, as long as we're not on the floor 179 | if is_on_floor(): 180 | fallspeed = 0; 181 | glidable = false 182 | else: 183 | if Input.is_action_pressed("jump") and ! state.GLIDE in locked and glidable: 184 | fallspeed = gravity*0.1 185 | current_state = state.GLIDE 186 | else: 187 | glidable = true 188 | fallspeed += gravity*delta 189 | current_state = state.FALL 190 | movement.y -= fallspeed*delta 191 | 192 | #jumping make go up 193 | if jumptime > 0: 194 | jumptime -= delta 195 | current_state = state.JUMP 196 | movement.y += jumptime*delta*jump_power 197 | 198 | #set the states for run/idle if we're not doing any of the other fancy stuff 199 | if is_on_floor() && dashtime <= 0: 200 | if control_direction.length()>0.1: 201 | current_state = state.RUN 202 | else: 203 | current_state = state.IDLE 204 | 205 | #do the actual movement 206 | velocity = movement 207 | move_and_slide() 208 | 209 | 210 | #function to allow other systems to prevent certain kinds of movement 211 | #that Array passed in needs to be string names of state enums eg "GLIDE" 212 | #because I don't know how to specify an array of a particular enum 213 | func lockout(new_locked:Array): 214 | locked.clear() 215 | for state_name in state.keys(): 216 | if state_name in new_locked: 217 | locked.append(state[state_name]) 218 | print_debug(locked) 219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /addons/g4climbnglide/PlayerContainer.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | 3 | @export_category("Movement Settings") 4 | @export var jump_length:float = 0.5 5 | @export var jump_power:float = 2000; 6 | @export var wall_jump_power:float = 2000; 7 | @export var gravity:float = 500 8 | @export var runspeed:float = 5 9 | @export var climbspeed:float = 2 10 | @export var climb_hold_force:float = 2 11 | @export var climb_hold_time:float = 0.25 12 | @export var air_run_time:float = 0.25 13 | @export var wall_jump_outness:float = 0.5 14 | @export var dash_speed:float = 3 15 | @export var dash_length:float = 0.25 16 | @export var air_dash:bool = true 17 | @export var attack_time:float = 0.9*0.5 18 | 19 | @export_category("Stamina Settings") 20 | @export var max_stamina:float = 100 21 | @export var current_stamina:float = 100 22 | @export var drain_rates:Dictionary = {"CLIMB": 10.0,"DASH": 100.0,"GLIDE": 10.0} 23 | @export var recovery_rate:float = 50.0 24 | @export var recovery_wait_time:float = 3.0 25 | @export var hide_wait_time:float = 0.0 26 | 27 | @export_category("Camera Settings") 28 | @export var follow_speed:float = 5 29 | @export var invertX:bool = false 30 | @export var invertY:bool = false 31 | -------------------------------------------------------------------------------- /addons/g4climbnglide/StaminaBar.gd: -------------------------------------------------------------------------------- 1 | extends TextureProgressBar 2 | @onready var player_container = $"../.." 3 | 4 | @onready var max_stamina:float = player_container.max_stamina 5 | @onready var current_stamina:float = player_container.current_stamina 6 | @onready var drain_rates:Dictionary = player_container.drain_rates 7 | @onready var recovery_rate:float = player_container.recovery_rate 8 | @onready var recovery_wait_time:float = player_container.recovery_wait_time 9 | @onready var hide_wait_time:float = player_container.hide_wait_time 10 | 11 | signal lockout(Array) 12 | 13 | var wait:float = 0 14 | var hide_wait:float = 0 15 | var current_state = null 16 | var draining:float = 0 17 | var last_stamina = 100 18 | # Called when the node enters the scene tree for the first time. 19 | func _ready(): 20 | if drain_rates.size() > 0: 21 | for key in drain_rates.keys(): 22 | assert (key is String) 23 | assert (key in CharacterController.state.keys()) 24 | assert (drain_rates[key] is float) 25 | pass # Replace with function body. 26 | 27 | func change_state(new_state:CharacterController.state): 28 | current_state = new_state 29 | if CharacterController.state.keys()[current_state] in drain_rates.keys(): 30 | draining = drain_rates[CharacterController.state.keys()[current_state]] 31 | else: 32 | draining = 0 33 | 34 | # Called every frame. 'delta' is the elapsed time since the previous frame. 35 | func _process(delta): 36 | if draining>0: 37 | current_stamina -= draining*delta 38 | wait = recovery_wait_time 39 | else: 40 | if wait > 0: 41 | wait -= delta 42 | else: 43 | current_stamina += recovery_rate*delta 44 | current_stamina = clamp(current_stamina,0,max_stamina) 45 | 46 | if current_stamina >= max_stamina: 47 | if hide_wait <= 0: 48 | hide() 49 | else: 50 | hide_wait -= delta 51 | else: 52 | hide_wait = hide_wait_time 53 | show() 54 | 55 | 56 | if current_stamina <= 0 && last_stamina > 0: 57 | emit_signal("lockout",drain_rates.keys()) 58 | if last_stamina <= 0 && current_stamina > 0: 59 | emit_signal("lockout",[]) 60 | 61 | last_stamina = current_stamina 62 | value = int((current_stamina/max_stamina)*100) 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /addons/g4climbnglide/alpha-e-box.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://b0narno8d8f8s" 6 | path="res://.godot/imported/alpha-e-box.svg-5286e91c7d674e6443e1eb5b66593c03.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/g4climbnglide/alpha-e-box.svg" 14 | dest_files=["res://.godot/imported/alpha-e-box.svg-5286e91c7d674e6443e1eb5b66593c03.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 | -------------------------------------------------------------------------------- /addons/g4climbnglide/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acegiak/g4-climb-n-glide-character-controller/d11dbb2d5e4782e3a471a0c53dbc8ce775686ff2/addons/g4climbnglide/icon.png -------------------------------------------------------------------------------- /addons/g4climbnglide/icon.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://bsqeq7gnr0e0d" 6 | path="res://.godot/imported/icon.png-6a4c13b3e174ba48475788bee9593ae1.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/g4climbnglide/icon.png" 14 | dest_files=["res://.godot/imported/icon.png-6a4c13b3e174ba48475788bee9593ae1.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 | -------------------------------------------------------------------------------- /addons/g4climbnglide/vertbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acegiak/g4-climb-n-glide-character-controller/d11dbb2d5e4782e3a471a0c53dbc8ce775686ff2/addons/g4climbnglide/vertbar.png -------------------------------------------------------------------------------- /addons/g4climbnglide/vertbar.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://djdso12qau72i" 6 | path="res://.godot/imported/vertbar.png-832624b6d24e3c6ecc4382f5ea6990fb.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/g4climbnglide/vertbar.png" 14 | dest_files=["res://.godot/imported/vertbar.png-832624b6d24e3c6ecc4382f5ea6990fb.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 | -------------------------------------------------------------------------------- /screenshots/.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acegiak/g4-climb-n-glide-character-controller/d11dbb2d5e4782e3a471a0c53dbc8ce775686ff2/screenshots/.gdignore -------------------------------------------------------------------------------- /screenshots/Recording 2022-09-23 at 06.43.38.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acegiak/g4-climb-n-glide-character-controller/d11dbb2d5e4782e3a471a0c53dbc8ce775686ff2/screenshots/Recording 2022-09-23 at 06.43.38.gif --------------------------------------------------------------------------------