280 lines
8.7 KiB
GDScript
280 lines
8.7 KiB
GDScript
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("===============================")
|