283 lines
10 KiB
GDScript
283 lines
10 KiB
GDScript
@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
|