class_name Dartboard extends StaticBody3D ## The board. 22 material slots: ## sector_1 .. sector_20 (20) ← the scoring wedges ## bulls (1) ← inner AND outer bull, they move together ## flat_tire (1) ← the outer band ## ## 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. signal slot_painted(slot: StringName) const BULL_SLOT := &"bulls" const FLAT_TIRE_SLOT := &"flat_tire" const SECTOR_PREFIX := "sector_" @onready var mesh_instance: MeshInstance3D = $Model/dartboard ## StringName -> surface index var _slot_to_surface: Dictionary = {} ## global trimesh face index -> StringName var _face_to_slot: Dictionary = {} ## surface index -> [first_face, last_face], for debugging var _surface_face_range: 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. Check the scene tree — expected $Model/dartboard.") 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 — it can't be hit or skinned." % i) continue var slot := StringName(mat.resource_name) if slot == &"": push_warning("Surface %d's material has no name." % i) continue if _slot_to_surface.has(slot): push_warning("Duplicate slot name '%s' (surfaces %d and %d)." % [slot, _slot_to_surface[slot], i]) _slot_to_surface[slot] = i # Sanity: we expect exactly 20 sectors. var sectors := 0 for slot in _slot_to_surface: if String(slot).begins_with(SECTOR_PREFIX): sectors += 1 if sectors != 20: push_error("Expected 20 sector slots, found %d. Check the Blender material names." % sectors) ## Build a global-face-index -> slot map. ## ## The physics engine reports face_index as an index into the WHOLE trimesh. ## The mesh stores faces per-surface. create_trimesh_shape() concatenates the ## surfaces in order, so we can walk them accumulating a running face count. ## ## ⚠️ This assumes surface order == trimesh face order. Verified by ## debug_verify_faces(), not assumed. If that assumption ever breaks (a Godot ## version change, a different collision generator), THIS is where it breaks. 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] var tri_count: int = (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 _surface_face_range[i] = [running, running + tri_count - 1] running += tri_count func _cache_radius() -> void: var aabb := mesh_instance.get_aabb() 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? &"" if unknown. func slot_for_face(face_index: int) -> StringName: return _face_to_slot.get(face_index, &"") ## Paint a texture onto one slot. 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] # get_active_material returns the override if one exists, else the mesh's. var mat := mesh_instance.get_active_material(surface) if mat == null: push_warning("Slot '%s' has no material to duplicate." % slot) return # ⚠️ Duplicate, or we mutate the SHARED material from the .glb — which leaks # the change to every other slot using that material, AND can get written # back to disk in the editor. var unique := mat.duplicate() as StandardMaterial3D if unique == null: push_warning("Slot '%s' material isn't a StandardMaterial3D." % slot) return unique.albedo_texture = texture unique.albedo_color = Color.WHITE # don't tint the texture mesh_instance.set_surface_override_material(surface, unique) slot_painted.emit(slot) ## 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 clear_all_slots() -> void: for slot in _slot_to_surface: clear_slot(slot) func has_slot(slot: StringName) -> bool: return _slot_to_surface.has(slot) func is_sector(slot: StringName) -> bool: return String(slot).begins_with(SECTOR_PREFIX) ## &"sector_20" -> 20. Returns 0 for bulls, flat_tire, or unknown. func sector_number(slot: StringName) -> int: if not is_sector(slot): return 0 return String(slot).trim_prefix(SECTOR_PREFIX).to_int() func get_slots() -> Array[StringName]: var out: Array[StringName] = [] out.assign(_slot_to_surface.keys()) return out func outer_radius() -> float: return _outer_radius ## Project a LOCAL point onto the board's face plane. ## Returns 2D coords where +Y is "up" on the board. ## ## Your board's AABB is (0.451, 0.451, 0.029) — thin on Z — so the disc lies in ## the X/Y plane. This picks the two fat axes automatically. func planar_coords(local_point: Vector3) -> Vector2: var aabb := mesh_instance.get_aabb() var center := aabb.position + aabb.size * 0.5 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) ## Distance from board centre as a fraction of the outer radius. func normalized_radius(world_point: Vector3) -> float: var planar := planar_coords(to_local(world_point)) return planar.length() / _outer_radius if _outer_radius > 0.0 else 999.0 # --- Debug -------------------------------------------------------------- func print_slots() -> void: var mesh := mesh_instance.mesh print("=== Dartboard: %d slots, %d faces ===" % [_slot_to_surface.size(), _face_to_slot.size()]) for i in mesh.get_surface_count(): var mat := mesh.surface_get_material(i) var name_str: String = mat.resource_name if mat else "(none)" var range_arr: Array = _surface_face_range.get(i, [-1, -1]) print(" surface %2d: %-12s faces %4d–%4d" % [i, name_str, range_arr[0], range_arr[1]]) print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size]) print("=====================================") ## ⚠️ VERIFY THE CORE ASSUMPTION. ## ## Fires a ray at each sector's known centre and checks that face_index maps ## back to the right slot. If surface order != trimesh face order, this catches ## it — and the whole face-index approach is invalid until it's fixed. ## ## Call once from main.gd, read the output, then delete the call. func debug_verify_faces(camera: Camera3D) -> void: print("=== Face-index verification ===") var space := get_world_3d().direct_space_state var errors := 0 var checks := 0 # March a grid across the board and confirm every hit resolves to a slot # that actually exists. const STEPS := 24 for iy in STEPS: for ix in STEPS: var u := (float(ix) / (STEPS - 1)) * 2.0 - 1.0 # -1..1 var v := (float(iy) / (STEPS - 1)) * 2.0 - 1.0 # Skip outside the disc if Vector2(u, v).length() > 0.95: continue # A point on the board's face, in local space var local := Vector3(u * _outer_radius, v * _outer_radius, 0.0) var world := to_global(local) # Ray straight at it, from in front var from := world + global_transform.basis.z * 0.5 var params := PhysicsRayQueryParameters3D.create(from, world - global_transform.basis.z * 0.1) params.collide_with_bodies = true var hit := space.intersect_ray(params) if hit.is_empty(): continue checks += 1 var face: int = hit.get("face_index", -1) var slot := slot_for_face(face) if slot == &"": errors += 1 if errors <= 5: print(" ✗ face %d -> no slot (local %.3f, %.3f)" % [face, local.x, local.y]) print(" %d rays hit, %d unmapped faces" % [checks, errors]) if errors == 0: print(" ✓ Every hit face maps to a slot.") else: push_error("Face mapping is BROKEN. %d faces don't map." % errors) print("===============================")