extends Node ## Scans res://plugins/ for plugin manifests (plugin.cfg) and provides ## access to tile definitions. Instantiate tiles by tile_id. signal plugin_loaded(plugin_id: String, plugin_name: String) signal plugin_load_failed(plugin_id: String, reason: String) signal plugin_active_changed(plugin_id: String, active: bool) signal plugin_setting_changed(plugin_id: String, key: String, value: Variant) ## All loaded plugin data: plugin_id -> Dictionary var _plugins: Dictionary = {} ## Tracks which plugins are active (plugin_id -> bool). Default true. var _plugin_active: Dictionary = {} ## All tile definitions: tile_id -> Dictionary var _tiles: Dictionary = {} ## Plugin-id -> Array[tile_id] for reverse lookup var _plugin_tiles: Dictionary = {} ## Per-plugin settings: plugin_id -> { key -> value } var _plugin_settings: Dictionary = {} const PLUGIN_SETTINGS_PATH: String = "res://config/plugin_settings.cfg" func _ready() -> void: _scan_plugins() _load_plugin_settings() ## Scan res://plugins/*/plugin.cfg and load all manifests. func _scan_plugins() -> void: var dir := DirAccess.open("res://plugins") if dir == null: return dir.list_dir_begin() var entry: String = dir.get_next() while entry != "": if entry.begins_with("."): entry = dir.get_next() continue if dir.current_is_dir(): var manifest_path := "res://plugins/%s/plugin.cfg" % entry if FileAccess.file_exists(manifest_path): _load_manifest(entry, manifest_path) else: plugin_load_failed.emit(entry, "Missing plugin.cfg") entry = dir.get_next() dir.list_dir_end() func _load_manifest(plugin_id: String, path: String) -> void: var cfg := ConfigFile.new() if cfg.load(path) != OK: plugin_load_failed.emit(plugin_id, "Failed to parse plugin.cfg") return var plugin_info := { id = plugin_id, name = cfg.get_value("plugin", "name", plugin_id), version = cfg.get_value("plugin", "version", "0.1.0"), description = cfg.get_value("plugin", "description", ""), author = cfg.get_value("plugin", "author", ""), base_path = path.get_base_dir(), } _plugins[plugin_id] = plugin_info _plugin_active[plugin_id] = true # Load tile definitions var tile_count: int = cfg.get_value("tiles", "count", 0) for i in range(tile_count): var section := "tile_%d" % i var tile_id: String = cfg.get_value(section, "id", "") if tile_id.is_empty(): continue # Scoped tile-id: "plugin_id/tile_id" ensures global uniqueness var scoped_id := "%s/%s" % [plugin_id, tile_id] if _tiles.has(scoped_id): plugin_load_failed.emit(plugin_id, "Duplicate tile id: %s" % scoped_id) continue var tile_def := { id = scoped_id, plugin_id = plugin_id, name = cfg.get_value(section, "name", tile_id), scene = cfg.get_value(section, "scene", ""), min_w = cfg.get_value(section, "min_w", 1), min_h = cfg.get_value(section, "min_h", 1), max_w = cfg.get_value(section, "max_w", 4), max_h = cfg.get_value(section, "max_h", 4), default_w = cfg.get_value(section, "default_w", 1), default_h = cfg.get_value(section, "default_h", 1), } _tiles[scoped_id] = tile_def if not _plugin_tiles.has(plugin_id): _plugin_tiles[plugin_id] = [] _plugin_tiles[plugin_id].append(scoped_id) # Load setting definitions var setting_defs: Array[Dictionary] = [] var setting_count: int = cfg.get_value("settings", "count", 0) for i in range(setting_count): var s_section := "setting_%d" % i var skey: String = cfg.get_value(s_section, "key", "") if skey.is_empty(): continue var sd: Dictionary = { key = skey, label = cfg.get_value(s_section, "label", skey), type = cfg.get_value(s_section, "type", "string"), } # Read optional fields that don't have a default value if cfg.has_section_key(s_section, "default"): sd.default = cfg.get_value(s_section, "default") if cfg.has_section_key(s_section, "min"): sd.min = cfg.get_value(s_section, "min") if cfg.has_section_key(s_section, "max"): sd.max = cfg.get_value(s_section, "max") if cfg.has_section_key(s_section, "step"): sd.step = cfg.get_value(s_section, "step") setting_defs.append(sd) plugin_info.settings = setting_defs plugin_loaded.emit(plugin_id, plugin_info.name) ## Returns a list of all loaded plugin IDs. func get_plugin_ids() -> PackedStringArray: var result: PackedStringArray = [] for pid in _plugins: result.append(pid) return result ## Returns plugin info Dictionary for the given plugin_id, or null. func get_plugin(plugin_id: String) -> Dictionary: return _plugins.get(plugin_id, {}) ## Returns true if the plugin is active (tiles can be instantiated). func is_plugin_active(plugin_id: String) -> bool: return _plugin_active.get(plugin_id, true) ## Enable or disable a plugin. When disabled, tiles cannot be instantiated. func set_plugin_active(plugin_id: String, active: bool) -> void: if _plugin_active.get(plugin_id, true) == active: return _plugin_active[plugin_id] = active plugin_active_changed.emit(plugin_id, active) ## Returns setting definitions for a plugin (from its manifest). func get_plugin_settings(plugin_id: String) -> Array[Dictionary]: var info: Dictionary = _plugins.get(plugin_id, {}) var defs: Array = info.get("settings", []) return defs.duplicate() ## Read a plugin setting value, falling back to the default from the manifest. func get_plugin_setting(plugin_id: String, key: String, default_value: Variant = null) -> Variant: if _plugin_settings.has(plugin_id) and _plugin_settings[plugin_id].has(key): return _plugin_settings[plugin_id][key] # Fall back to manifest default var info: Dictionary = _plugins.get(plugin_id, {}) for sd in info.get("settings", []): if sd.get("key", "") == key: return sd.get("default", default_value) return default_value ## Persist a plugin setting and emit change signal. func set_plugin_setting(plugin_id: String, key: String, value: Variant) -> void: if not _plugin_settings.has(plugin_id): _plugin_settings[plugin_id] = {} if _plugin_settings[plugin_id].get(key) == value: return _plugin_settings[plugin_id][key] = value _save_plugin_settings() plugin_setting_changed.emit(plugin_id, key, value) func _load_plugin_settings() -> void: var cfg := ConfigFile.new() if cfg.load(PLUGIN_SETTINGS_PATH) != OK: return for section in cfg.get_sections(): _plugin_settings[section] = {} for key in cfg.get_section_keys(section): _plugin_settings[section][key] = cfg.get_value(section, key) func _save_plugin_settings() -> void: var cfg := ConfigFile.new() for pid in _plugin_settings: for key in _plugin_settings[pid]: cfg.set_value(pid, key, _plugin_settings[pid][key]) # Only write if there's data if cfg.get_sections().is_empty(): return DirAccess.make_dir_recursive_absolute("res://config") cfg.save(PLUGIN_SETTINGS_PATH) ## Returns all tile definitions across all plugins. func get_all_tile_defs() -> Array[Dictionary]: var result: Array[Dictionary] = [] for tid in _tiles: result.append(_tiles[tid]) return result ## Returns a single tile definition by scoped tile_id, or null. func get_tile_def(tile_id: String) -> Dictionary: return _tiles.get(tile_id, {}) ## Returns tile IDs belonging to a specific plugin. func get_plugin_tile_ids(plugin_id: String) -> PackedStringArray: var result: PackedStringArray = [] for tid in _plugin_tiles.get(plugin_id, []): result.append(tid) return result ## Instantiate a tile by its scoped tile_id. ## Returns the root Control node, or null on failure. func instantiate_tile(tile_id: String) -> Control: var def: Dictionary = _tiles.get(tile_id, {}) if def.is_empty(): return null # Don't instantiate tiles from inactive plugins var pid: String = def.get("plugin_id", "") if not pid.is_empty() and not is_plugin_active(pid): return null var scene_path: String = def.get("scene", "") if scene_path.is_empty(): return null var scene := load(scene_path) as PackedScene if scene == null: return null var instance: Control = scene.instantiate() as Control if instance == null: return null # Tag the instance so the layout manager can correlate it if instance.has_method("set_tile_id"): instance.set_tile_id(tile_id) if instance.has_method("set_tile_config"): instance.set_tile_config(def) return instance