├── .editorconfig ├── .gdignore └── addons └── const_generator ├── LICENSE ├── const_generator.gd └── plugin.cfg /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | -------------------------------------------------------------------------------- /.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/game-gems/godot-const-generator/52e47c749886f49c6c39a2a07ec5bf7c530f009f/.gdignore -------------------------------------------------------------------------------- /addons/const_generator/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Game Gems 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 | -------------------------------------------------------------------------------- /addons/const_generator/const_generator.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | ## This is how often the project is scanned for changes (in seconds) 5 | const GENERATION_FREQUENCY := 5.0 6 | 7 | ## The addons directory is excluded by default 8 | const ADDONS_PATH := "res://addons" 9 | 10 | ## The plugin name is used to create the output directory 11 | const PLUGIN_NAME := "const_generator" 12 | 13 | ## The class name that will be generated 14 | const GENERATED_CLASS_NAME := "ProjectFiles" 15 | 16 | ## Print debug messages 17 | const DEBUG := false 18 | 19 | ## Paths that should be excluded from generation 20 | const EXCLUDED_PATHS: Array[String] = [ 21 | ADDONS_PATH, 22 | "res://export", 23 | "res://tmp" 24 | ] 25 | 26 | ## Generated classnames to file extensions 27 | const FILETYPES_TO_EXTENSIONS: Dictionary[String, Array] = { 28 | "Scripts": ["gd"], 29 | "Scenes": ["tscn", "scn"], 30 | "Resources": ["tres", "res"], 31 | "Images": ["png", "jpg", "jpeg", "gif", "bmp"], 32 | "Audio": ["wav", "ogg", "mp3"], 33 | "Fonts": ["ttf", "otf"], 34 | "Shaders": ["gdshader"], 35 | } 36 | 37 | var extensions_to_filetypes: Dictionary[String, String] 38 | var illegal_symbols_regex: RegEx 39 | var previous_filetypes_to_filepaths: Dictionary[String, PackedStringArray] 40 | var persisted_actions: PackedStringArray 41 | var persisted_groups: PackedStringArray 42 | 43 | var mutex: Mutex 44 | var config_modified_time: int = 0 45 | 46 | func _enter_tree() -> void: 47 | if not Engine.is_editor_hint(): return 48 | 49 | mutex = Mutex.new() 50 | illegal_symbols_regex = RegEx.create_from_string("[^\\p{L}\\p{N}_]") 51 | 52 | extensions_to_filetypes = {} 53 | for filetype in FILETYPES_TO_EXTENSIONS: 54 | for extension in FILETYPES_TO_EXTENSIONS[filetype]: 55 | extensions_to_filetypes[extension] = filetype 56 | 57 | var timer := Timer.new() 58 | timer.name = PLUGIN_NAME.to_pascal_case() + "Timer" 59 | timer.wait_time = GENERATION_FREQUENCY 60 | timer.one_shot = false 61 | timer.autostart = true 62 | timer.timeout.connect( 63 | WorkerThreadPool.add_task.bind(generate_filepath_class, false, "Generating filepaths") 64 | ) 65 | add_child(timer) 66 | 67 | on_settings_changed() 68 | project_settings_changed.connect(on_settings_changed) 69 | 70 | func on_settings_changed(): 71 | var project_settings := ConfigFile.new() 72 | if project_settings.load("res://project.godot"): 73 | push_warning("Couldn't load project.godot") 74 | return 75 | 76 | save_input_actions_class(project_settings) 77 | save_groups_class(project_settings) 78 | save_layers_enum(project_settings, "Avoidance", "avoidance") 79 | save_layers_enum(project_settings, "Physics2D", "2d_physics") 80 | save_layers_enum(project_settings, "Render2D", "2d_render") 81 | save_layers_enum(project_settings, "Navigation2D", "2d_navigation") 82 | save_layers_enum(project_settings, "Physics3D", "3d_physics") 83 | save_layers_enum(project_settings, "Render3D", "3d_render") 84 | save_layers_enum(project_settings, "Navigation3D", "3d_navigation") 85 | 86 | 87 | func save_input_actions_class(project_settings: ConfigFile): 88 | var custom_actions: PackedStringArray = project_settings.get_section_keys("input") if project_settings.has_section("input") else PackedStringArray() 89 | if persisted_actions == custom_actions: 90 | return 91 | 92 | var all_actions := PackedStringArray(custom_actions) 93 | for action in InputMap.get_actions(): 94 | all_actions.append(action) 95 | 96 | generate_class("InputActions", all_actions) 97 | persisted_actions = custom_actions 98 | 99 | func save_groups_class(project_settings: ConfigFile): 100 | var groups: PackedStringArray = project_settings.get_section_keys("global_group") if project_settings.has_section("global_group") else PackedStringArray() 101 | if persisted_groups == groups: 102 | return 103 | 104 | generate_class("Groups", groups) 105 | 106 | persisted_groups = groups 107 | 108 | func save_layers_enum(project_settings: ConfigFile, classname: String, layer_prefix: String): 109 | var layer_names: PackedStringArray = project_settings.get_section_keys("layer_names") if project_settings.has_section("layer_names") else PackedStringArray() 110 | 111 | var found := false 112 | for layer in layer_names: if layer.begins_with(layer_prefix): 113 | found = true 114 | if not found: return 115 | 116 | var output_path = ADDONS_PATH.path_join(PLUGIN_NAME).path_join(classname.to_snake_case()) + ".gd" 117 | 118 | var generated_file := FileAccess.open(output_path, FileAccess.WRITE) 119 | generated_file.store_line("class_name " + classname) 120 | 121 | generated_file.store_line("") 122 | generated_file.store_line("enum Layer {") 123 | 124 | var number_offset := (layer_prefix + "/layer_").length() 125 | for layer in layer_names: if layer.begins_with(layer_prefix): 126 | var name: String = project_settings.get_value("layer_names", layer).to_upper() 127 | var number: int = int(layer.substr(number_offset)) 128 | generated_file.store_line("\t%s = %d," % [name, number]) 129 | 130 | generated_file.store_line("}") 131 | generated_file.close() 132 | 133 | 134 | func debug(message: String): 135 | if DEBUG: print_debug(Time.get_time_string_from_system(), " [", PLUGIN_NAME, "] ", message) 136 | 137 | func generate_class(name: String, values: Array[StringName], derive_const_name: Callable = func(value): return value): 138 | var file_name := name.to_snake_case().to_lower() + ".gd" 139 | debug("Generating %s" % file_name) 140 | 141 | var path := ADDONS_PATH.path_join(PLUGIN_NAME).path_join(file_name) 142 | var file := FileAccess.open(path, FileAccess.WRITE) 143 | if not file: 144 | push_warning("Couldn't open file %s: %s" % [path, FileAccess.get_open_error()]) 145 | return 146 | 147 | file.store_line("class_name " + name) 148 | file.store_line("") 149 | for value in values: 150 | var const_name = derive_const_name.call(value.to_upper().strip_edges()) 151 | const_name = illegal_symbols_regex.sub(const_name, "_", true) 152 | file.store_line("const %s = &\"%s\"" % [const_name, value]) 153 | file.close() 154 | 155 | func generate_filepath_class() -> void: 156 | if not mutex.try_lock(): return 157 | var walking_started := Time.get_ticks_usec() 158 | 159 | var filetypes_to_filepaths := walk("res://") 160 | if previous_filetypes_to_filepaths == filetypes_to_filepaths: 161 | return 162 | 163 | debug("Generating " + GENERATED_CLASS_NAME + " class...") 164 | var output_path = ADDONS_PATH.path_join(PLUGIN_NAME).path_join(GENERATED_CLASS_NAME.to_snake_case()) + ".gd" 165 | 166 | var generated_file := FileAccess.open(output_path, FileAccess.WRITE) 167 | generated_file.store_line("class_name " + GENERATED_CLASS_NAME) 168 | for filetype in filetypes_to_filepaths: 169 | write_section(generated_file, filetype, filetypes_to_filepaths[filetype]) 170 | generated_file.close() 171 | 172 | debug("Finished in %dms" % ((Time.get_ticks_usec() - walking_started) / 1000)) 173 | previous_filetypes_to_filepaths = filetypes_to_filepaths 174 | mutex.unlock() 175 | 176 | func write_section(generated_file: FileAccess, classname: String, filepaths: PackedStringArray) -> void: 177 | if filepaths.is_empty(): return 178 | 179 | var sorted_filepaths := Array(filepaths) 180 | sorted_filepaths.sort_custom(func(a: String, b: String) -> bool: 181 | return a.get_file().to_lower() < b.get_file().to_lower() 182 | ) 183 | 184 | generated_file.store_line("\nclass %s:" % classname) 185 | for filepath: String in sorted_filepaths: 186 | var constant_name := filepath.get_file().get_basename().to_snake_case().to_upper() 187 | constant_name = illegal_symbols_regex.sub(constant_name, "_", true) 188 | generated_file.store_line("\tconst %s = \"%s\"" % [constant_name, filepath]) 189 | 190 | func walk(path: String) -> Dictionary[String, PackedStringArray]: 191 | var filetypes_to_filepaths: Dictionary[String, PackedStringArray] = {} 192 | for filetype in FILETYPES_TO_EXTENSIONS: 193 | filetypes_to_filepaths[filetype] = PackedStringArray() 194 | 195 | var walker := DirAccess.open(path) 196 | _walk(walker, filetypes_to_filepaths) 197 | return filetypes_to_filepaths 198 | 199 | func _walk(walker: DirAccess, collected_paths: Dictionary[String, PackedStringArray]) -> void: 200 | walker.list_dir_begin() 201 | 202 | var current_dir := walker.get_current_dir() 203 | for file in walker.get_files(): 204 | var file_path := current_dir.path_join(file) 205 | if file_path in EXCLUDED_PATHS: continue 206 | 207 | var extension := file.get_extension() 208 | if extension in extensions_to_filetypes: 209 | collected_paths[extensions_to_filetypes[extension]].append(file_path) 210 | 211 | for dir in walker.get_directories(): 212 | var dir_path := current_dir.path_join(dir) 213 | if dir_path in EXCLUDED_PATHS: continue 214 | 215 | walker.change_dir(dir_path) 216 | _walk(walker, collected_paths) 217 | 218 | walker.list_dir_end() 219 | -------------------------------------------------------------------------------- /addons/const_generator/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Const Generator" 4 | description="Generates classes with constants/enums for everything usually accessed by strings: filepaths, groups, input actions, physics/render/navigation layer names." 5 | author="Bogdan" 6 | version="0.1.0" 7 | script="const_generator.gd" 8 | --------------------------------------------------------------------------------