@tool class_name MeshColliderSettings extends RefCounted ## Configuration for Mesh Collider. ## ## Everything lives in `project.godot` under `mesh_collider/*`, so it is ## per-project and shows up in **Project Settings → Mesh Collider**. const ROOT := "mesh_collider/" ## Names mirrored from `MeshCollider`'s enums, kept here as plain data. ## ## The settings script deliberately does NOT reference the `MeshCollider` class: ## annotating a return type as `MeshCollider.ShapeType` made GDScript fail to ## parse the plugin with "Could not resolve external class member" when the ## settings script was preloaded. Keeping the dependency one-way (plugin -> ## settings, plugin -> core, settings -> nothing) removes that whole class of ## resolution-order problem. ## ## KEEP IN STEP with the enum order in mesh_collider.gd. const SHAPE_COUNT := 6 const BODY_COUNT := 4 const SHAPE_NAMES: PackedStringArray = [ "convex_hull", "trimesh", "box", "sphere", "capsule", "cylinder", ] const BODY_NAMES: PackedStringArray = [ "static", "rigid", "area", "character", ] const DEFAULT_MODEL_EXTENSIONS: PackedStringArray = [ "fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm", ] const KEY_AUTO := ROOT + "auto_add_collision" const KEY_SHAPE := ROOT + "collision_shape" const KEY_BODY := ROOT + "collision_body" const KEY_SKIP_EXISTING := ROOT + "skip_meshes_with_collision" const KEY_LAYER := ROOT + "collision_layer" const KEY_MASK := ROOT + "collision_mask" const KEY_EXTENSIONS := ROOT + "model_extensions" const KEY_SKIP_DIRS := ROOT + "skip_dirs" ## The shape this plugin shipped as its default before 2.0 finalised. ## ## `install()` only writes a default when the setting does not exist yet, so a ## project that already ran an earlier build keeps the old value forever - which ## is exactly what happened when this default moved from Convex Hull to Trimesh. ## A stored value still equal to this legacy default is treated as "untouched" ## and migrated. const LEGACY_SHAPE_DEFAULT := 0 # MeshCollider.ShapeType.CONVEX_HULL ## NOTE: a function rather than a `const` dictionary on purpose. GDScript ## requires a const dictionary's *values* to be constant expressions, so a ## PackedStringArray cannot be built inside one ("isn't a constant expression"). ## ## The enum-backed defaults below are written as literal indices for the same ## reason applied one level out: they must not depend on another script's enum ## resolving. Keep them in step with MeshCollider's enum order. ## ## Each entry carries two kinds of text, and they are NOT interchangeable: ## * `hint_string` - consumed by ProjectSettings / the built-in inspector. An ## enum uses it for its option list, so it cannot hold prose. ## * `label` / `tooltip` - consumed by this plugin's own settings panel, which ## labels each row and shows the tooltip on hover. static func project_info() -> Dictionary: return { KEY_AUTO: { "name": KEY_AUTO, "type": TYPE_BOOL, "default": false, "hint_string": "Add collision to every pending model found by a scan", "label": "扫描后自动添加", "tooltip": "打开后,点『扫描场景中的模型』会直接把找到的模型全部加上碰撞。\n" + "关闭(默认):扫描只列出结果,由你决定是否处理。\n" + "插件不做任何后台轮询,只有你点按钮时才扫描。", }, KEY_SHAPE: { "name": KEY_SHAPE, "type": TYPE_INT, "default": 1, # MeshCollider.ShapeType.TRIMESH "hint": PROPERTY_HINT_ENUM, "hint_string": "Convex Hull,Trimesh,Box,Sphere,Capsule,Cylinder", "label": "碰撞形状", "tooltip": "Trimesh:逐三角面精确,零简化。静态物体的最佳选择。\n" + "Convex Hull:凸包,凹形会被填平,可用于会移动的物体。\n" + "Box / Sphere / Capsule / Cylinder:单个包围体,最省性能。", }, KEY_BODY: { "name": KEY_BODY, "type": TYPE_INT, "default": 0, # MeshCollider.BodyType.STATIC "hint": PROPERTY_HINT_ENUM, "hint_string": "StaticBody3D,RigidBody3D,Area3D,CharacterBody3D", "label": "刚体类型", "tooltip": "碰撞体挂在哪种物理节点下。\n" + "StaticBody3D:静态场景物件。\n" + "RigidBody3D:参与物理模拟的道具。\n" + "Area3D:只做触发检测,不阻挡。\n" + "CharacterBody3D:玩家/NPC,移动由脚本接管。", }, KEY_SKIP_EXISTING: { "name": KEY_SKIP_EXISTING, "type": TYPE_BOOL, "default": false, "hint_string": "Skip meshes already under a physics body", "label": "跳过已有碰撞的网格", "tooltip": "打开后,已经位于物理体下方的网格不再重复生成碰撞。\n" + "模型自带碰撞时(例如用 -col 后缀导入)应该打开。", }, KEY_LAYER: { "name": KEY_LAYER, "type": TYPE_INT, "default": 1, "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, "label": "碰撞层", "tooltip": "生成的碰撞体属于哪些层。用掩码表示,1 = 第 1 层。", }, KEY_MASK: { "name": KEY_MASK, "type": TYPE_INT, "default": 1, "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, "label": "碰撞掩码", "tooltip": "生成的碰撞体会和哪些层发生碰撞。用掩码表示。", }, KEY_EXTENSIONS: { "name": KEY_EXTENSIONS, "type": TYPE_PACKED_STRING_ARRAY, "default": PackedStringArray([ "fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm", ]), "hint": PROPERTY_HINT_ARRAY_TYPE, "hint_string": "String", "label": "识别为模型的后缀", "tooltip": "逗号分隔,不带点。只有这些后缀的文件会被当作模型处理。", }, KEY_SKIP_DIRS: { "name": KEY_SKIP_DIRS, "type": TYPE_PACKED_STRING_ARRAY, "default": PackedStringArray(["res://addons/", "res://.godot/"]), "hint": PROPERTY_HINT_ARRAY_TYPE, "hint_string": "String", "label": "永不扫描的目录", "tooltip": "逗号分隔。这些目录下的模型不会被自动处理。", }, } ## Cached copy of `project_info()`. The dictionary is rebuilt from scratch on ## every call, and the getters below are called on every scan, so keeping one ## instance avoids pointless allocation. static var _cached_info: Dictionary = {} static func _all_info() -> Dictionary: if _cached_info.is_empty(): _cached_info = project_info() return _cached_info static func _info(key: String) -> Dictionary: var all := _all_info() if all.has(key): return all[key] return {"name": key, "type": TYPE_NIL, "default": null} ## Register every setting with ProjectSettings, so the values appear in the UI ## and carry a usable default even before the user touches anything. ## ## Existing values are never overwritten, with one narrow exception: the shape ## default this plugin changed after shipping, migrated below. static func install() -> void: var all := _all_info() for key in all: var info: Dictionary = all[key] if not ProjectSettings.has_setting(key): ProjectSettings.set_setting(key, info["default"]) ProjectSettings.add_property_info(info) # Record the default as the initial value, so an untouched setting is not # reported as a modified project setting. ProjectSettings.set_initial_value(key, info["default"]) # `install()` only writes a default when the setting is absent, so a project # that already ran an earlier build would keep the old default forever. Only # a value still equal to that old default is treated as untouched and # migrated; a genuinely chosen value is left alone. var stored := int(ProjectSettings.get_setting(KEY_SHAPE, all[KEY_SHAPE]["default"])) var wanted := int(all[KEY_SHAPE]["default"]) if stored != LEGACY_SHAPE_DEFAULT or wanted == LEGACY_SHAPE_DEFAULT: return ProjectSettings.set_setting(KEY_SHAPE, wanted) print(("[MeshCollider] collision_shape was still the old default " + "(Convex Hull) - updated to '%s'. Set it back in Project Settings " + "> mesh_collider if that was intentional.") % SHAPE_NAMES[clampi(wanted, 0, SHAPE_NAMES.size() - 1)]) # Only the migration is persisted. Writing on every install would spill all of # the plugin's defaults into project.godot for no benefit. var err := ProjectSettings.save() if err != OK: push_warning("[MeshCollider] could not save project settings (err %d)" % err) static func get_bool(key: String) -> bool: return bool(ProjectSettings.get_setting(key, _info(key)["default"])) static func get_int(key: String) -> int: return int(ProjectSettings.get_setting(key, _info(key)["default"])) static func get_strings(key: String) -> PackedStringArray: var value = ProjectSettings.get_setting(key, _info(key)["default"]) if value is PackedStringArray: return value var out := PackedStringArray() if value is Array: for v in value: out.append(String(v)) return out static func set_value(key: String, value) -> void: ProjectSettings.set_setting(key, value) ## Restore every setting this plugin owns back to its registered default. ## Returns the number of settings that actually changed. static func reset_to_defaults() -> int: var all := _all_info() var changed := 0 for key in all: var info: Dictionary = all[key] if ProjectSettings.get_setting(key) != info["default"]: ProjectSettings.set_setting(key, info["default"]) changed += 1 return changed ## The configured shape type, clamped to the valid enum range. ## ## Returns a plain int rather than `MeshCollider.ShapeType` on purpose; see ## SHAPE_NAMES. Callers use it as an enum value. static func shape_type() -> int: return clampi(get_int(KEY_SHAPE), 0, SHAPE_COUNT - 1) ## The configured body type, clamped to the valid enum range. static func body_type() -> int: return clampi(get_int(KEY_BODY), 0, BODY_COUNT - 1) ## True when `path` looks like a model, according to the configured extensions. static func is_configured_model(path: String) -> bool: if path.is_empty(): return false var ext := path.get_extension().to_lower() if ext.is_empty(): return false var configured := get_strings(KEY_EXTENSIONS) if configured.is_empty(): configured = DEFAULT_MODEL_EXTENSIONS for e in configured: if String(e).to_lower().trim_prefix(".") == ext: return true return false ## True when `path` is inside a directory the plugin must never scan. static func should_skip(path: String) -> bool: var p := path.replace("\\", "/") for dir in get_strings(KEY_SKIP_DIRS): var d := String(dir).replace("\\", "/") if d.is_empty(): continue if not d.ends_with("/"): d += "/" if p.begins_with(d): return true return false