class_name Dartboard extends StaticBody3D ## The board. 21 material slots: sector_1 .. sector_20, plus "bulls". ## ## Owns the face -> surface -> slot mapping, which is what makes hit detection ## survive the board moving, rotating, or having its sectors remapped. Ask the ## geometry which sector was hit; don't calculate it from an angle. ## Every slot on the board, in the order Blender exported them (order doesn't ## matter — we index by name). const BULL_SLOT := &"bulls" @onready var mesh_instance: MeshInstance3D = $Model/dartboard ## StringName -> surface index var _slot_to_surface: Dictionary = {} ## global face index -> StringName var _face_to_slot: Dictionary = {} var _outer_radius: float = 0.0 func _ready() -> void: _index_slots() _index_faces() _cache_radius() # --- Indexing ----------------------------------------------------------- func _index_slots() -> void: var mesh := mesh_instance.mesh if mesh == null: push_error("Dartboard has no mesh.") return for i in mesh.get_surface_count(): var mat := mesh.surface_get_material(i) if mat == null: push_warning("Surface %d has no material — can't be skinned or hit." % i) continue var slot := StringName(mat.resource_name) if slot == &"": push_warning("Surface %d's material has no name." % i) continue _slot_to_surface[slot] = i print("Dartboard: indexed %d slots." % _slot_to_surface.size()) if _slot_to_surface.size() != 21: push_warning("Expected 21 slots (20 sectors + bulls), got %d." % _slot_to_surface.size()) ## Build a global-face-index -> slot map. ## ## The physics engine reports face_index as an index into the WHOLE trimesh, ## but the mesh stores faces per-surface. So we walk the surfaces in order, ## accumulating a running face count, and record which range belongs to which. ## ## This assumes the ConcavePolygonShape3D was built from the mesh with surfaces ## in the same order — which is what create_trimesh_shape() does. func _index_faces() -> void: var mesh := mesh_instance.mesh if mesh == null: return var running := 0 for i in mesh.get_surface_count(): var arrays := mesh.surface_get_arrays(i) if arrays.is_empty(): continue var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] # Triangle count: from the index buffer if present, else from vertices. var tri_count := (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3) var mat := mesh.surface_get_material(i) var slot := StringName(mat.resource_name) if mat else &"" for f in tri_count: _face_to_slot[running + f] = slot running += tri_count print("Dartboard: mapped %d faces." % _face_to_slot.size()) func _cache_radius() -> void: var aabb := mesh_instance.get_aabb() # The disc's two large axes. The third is the board's thickness. var sizes := [aabb.size.x, aabb.size.y, aabb.size.z] sizes.sort() _outer_radius = sizes[2] * 0.5 # largest dimension = diameter # --- Public ------------------------------------------------------------- ## Which slot does this trimesh face belong to? func slot_for_face(face_index: int) -> StringName: return _face_to_slot.get(face_index, &"") ## Paint a texture onto one slot. Used for both skins and modifier overlays. func paint_slot(slot: StringName, texture: Texture2D) -> void: if not _slot_to_surface.has(slot): push_warning("No slot named '%s'." % slot) return var surface: int = _slot_to_surface[slot] var mat := mesh_instance.get_active_material(surface) if mat == null: return # Duplicate, or we mutate the shared material from the .glb — which leaks # to every instance AND can get written back to disk in the editor. var unique := mat.duplicate() as StandardMaterial3D unique.albedo_texture = texture unique.albedo_color = Color.WHITE # don't tint the texture mesh_instance.set_surface_override_material(surface, unique) ## Reset a slot to its imported material. func clear_slot(slot: StringName) -> void: if not _slot_to_surface.has(slot): return mesh_instance.set_surface_override_material(_slot_to_surface[slot], null) func get_slots() -> Array[StringName]: var out: Array[StringName] = [] out.assign(_slot_to_surface.keys()) return out ## Board radius in local units. func outer_radius() -> float: return _outer_radius ## Project a LOCAL point onto the board's face plane, returning 2D coords ## where +Y is "up" on the board (toward the 20). ## ## Which two axes those are depends on how the .glb imported. glTF is +Y up, ## so a board modeled facing +Z in Blender usually lands facing +Z here, with ## the disc in the X/Y plane. If sector detection is 90 degrees off, this is ## the function to fix. func planar_coords(local_point: Vector3) -> Vector2: var aabb := mesh_instance.get_aabb() var center := aabb.position + aabb.size * 0.5 # Identify the thin axis — that's the board's normal. var s := aabb.size if s.z <= s.x and s.z <= s.y: return Vector2(local_point.x - center.x, local_point.y - center.y) elif s.y <= s.x and s.y <= s.z: return Vector2(local_point.x - center.x, local_point.z - center.z) else: return Vector2(local_point.z - center.z, local_point.y - center.y) ## Debug: what did Blender actually export? func print_slots() -> void: var mesh := mesh_instance.mesh print("=== Dartboard slots ===") for i in mesh.get_surface_count(): var mat := mesh.surface_get_material(i) print(" surface %2d: '%s'" % [i, mat.resource_name if mat else "(none)"]) print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size]) print("=======================")