class_name PropLibrary extends RefCounted const PROP_UTILS := preload("res://scripts/props/prop_utils.gd") const PROP_BLOCK := preload("res://scripts/props/prop_block.gd") const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd") const PROP_VISUALS := preload("res://scripts/props/prop_visuals.gd") const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd") static var _templates: Array[Dictionary] = [] static func get_entries() -> Array[Dictionary]: if _templates.is_empty(): _build_templates() return _templates static func get_ids() -> Array[String]: var ids: Array[String] = [] for t: Dictionary in get_entries(): ids.append(String(t["id"])) return ids static func get_entry(id: String) -> Dictionary: for t: Dictionary in get_entries(): if String(t["id"]) == id: return t return make_custom_entry(id) if is_custom_path(id) else {} static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]: var entries: Array[Dictionary] = get_entries().duplicate(true) if not DirAccess.dir_exists_absolute(dir_path): push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path) return entries var directory: DirAccess = DirAccess.open(dir_path) if directory == null: return entries var paths: Array[String] = [] directory.list_dir_begin() var file_name: String = directory.get_next() while not file_name.is_empty(): if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT): paths.append(dir_path.path_join(file_name)) file_name = directory.get_next() directory.list_dir_end() paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0) for path: String in paths: var entry := make_custom_entry(path) if not entry.is_empty(): entries.append(entry) entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false)) var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower() return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0) return entries static func make_custom_entry(path: String) -> Dictionary: return _make_custom_entry(path, false) static func make_visual_entry(path: String) -> Dictionary: return _make_custom_entry(path, true) static func _make_custom_entry(path: String, allow_visual_only: bool) -> Dictionary: var normalized_path: String = canonical_path(path) if not is_custom_path(normalized_path): return {} var raw: Dictionary = PROP_FILE.load(normalized_path) if raw.is_empty(): push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path) return {} var parsed: Dictionary = PROP_FILE.from_file_dict(raw) if parsed.is_empty(): push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path) return {} var shapes: Array[Dictionary] = parsed.get("shapes", []) var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes) if shapes.is_empty() or (not allow_visual_only and hull.size() < 3): push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path) return {} var name := String(parsed.get("prop_name", "")).strip_edges() if name.is_empty(): name = normalized_path.get_file().get_basename() var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE)) var tags: Array[String] = parsed.get("tags", []) as Array[String] if tags.is_empty(): tags = _legacy_tags_for_path(normalized_path) return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {}), "tags": tags, "visual_only": hull.size() < 3} static func scan_visual_entries(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]: var entries: Array[Dictionary] = [] if not DirAccess.dir_exists_absolute(dir_path): return entries var directory: DirAccess = DirAccess.open(dir_path) if directory == null: return entries var paths: Array[String] = [] directory.list_dir_begin() var file_name: String = directory.get_next() while not file_name.is_empty(): if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT): paths.append(dir_path.path_join(file_name)) file_name = directory.get_next() directory.list_dir_end() paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0) for path: String in paths: var entry: Dictionary = make_visual_entry(path) if not entry.is_empty(): entries.append(entry) return entries static func _legacy_tags_for_path(path: String) -> Array[String]: var basename: String = path.get_file().get_basename().to_lower() if basename.contains("tree") or basename.contains("plant"): return ["nature", "objects"] if basename.contains("plant") else ["nature"] if basename.contains("skyline") or basename.contains("storefront") or basename.contains("house"): return ["buildings"] if basename.contains("window") or basename.contains("fireplace") or basename.contains("clock"): return ["walls", "objects"] if basename.contains("bookshelf") or basename.contains("picture") or basename.contains("rug") or basename.contains("couch") or basename.contains("armchair") or basename.contains("table"): return ["objects"] return [] static func is_custom_path(path: String) -> bool: return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path()) static func canonical_path(path: String) -> String: return CONTENT_PATHS.canonicalize(path.replace("\\", "/").simplify_path()) static func get_default_id() -> String: return "crate" static func _build_templates() -> void: _templates = [ _make("crate", "Crate", PROP_BLOCK.MaterialPreset.WOOD, "Wood", PROP_UTILS.create_box()), _make("ball", "Ball", PROP_BLOCK.MaterialPreset.RUBBER, "Rubber", PROP_UTILS.create_ball()), _make("plank", "Plank", PROP_BLOCK.MaterialPreset.METAL, "Metal", PROP_UTILS.create_plank()), _make("triangle", "Triangle", PROP_BLOCK.MaterialPreset.CARDBOARD, "Cardboard", PROP_UTILS.create_triangle()), ] static func _make(id: String, name: String, preset: int, label: String, payload: Dictionary) -> Dictionary: return { "id": id, "name": name, "material_preset": preset, "material_label": label, "payload": payload, "is_custom": false, "path": "", "shapes": [], "collision_hull": PackedVector2Array(), }