Files
DartGame/game/core/board/dartboard.gd
T

280 lines
8.7 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("===============================")