extends Control ## Screen position -> board slot. That's the whole job. ## ## Scoring lives in Phase 2's ScoringEngine. This resolves WHERE, not WHAT IT'S ## WORTH. Keeping them separate means the modifier UI can ship before the ## scoring pipeline exists. ## ## HOW IT WORKS ## ------------ ## The raycast returns a `face_index` — which triangle of the trimesh was hit. ## Dartboard maps that face to a material slot name. The material name IS the ## sector. ## ## No trigonometry. No calibration constants. Survives the board being rotated, ## tilted, moved, or having its sectors remapped — because we're reading the ## geometry, not calculating from an assumed layout. ## ## This is only possible because the board has a ConcavePolygonShape3D (trimesh) ## collider. A box or cylinder gives you no face_index. signal slot_hovered(slot: StringName) signal slot_dropped(slot: StringName, modifier: Modifier) const RAY_LENGTH := 1000.0 @export var camera: Camera3D @export var dartboard: Dartboard @export_group("Debug") ## Print every hover. Noisy but useful when calibrating. @export var log_hovers := false ## slot -> Modifier var applied: Dictionary = {} var _last_hovered: StringName = &"" func _ready() -> void: # The tray's pieces need to receive mouse events. This node is just a # service — it must never eat them. mouse_filter = Control.MOUSE_FILTER_IGNORE if camera == null: push_error("DropCatcher: `camera` export is not set.") if dartboard == null: push_error("DropCatcher: `dartboard` export is not set.") # --- Public ------------------------------------------------------------- ## Which slot is under this screen position? &"" if the board isn't there. func slot_at_screen_pos(screen_pos: Vector2) -> StringName: if camera == null or dartboard == null: return &"" var hit := _raycast(screen_pos) if hit.is_empty(): return &"" # Only the board counts. The SpiderBody (layer 4) is a bounce surface, not # a drop target. if hit.get("collider") != dartboard: return &"" var face: int = hit.get("face_index", -1) if face < 0: push_warning("Raycast returned no face_index. Is the collider a trimesh?") return &"" var slot := dartboard.slot_for_face(face) if slot == &"": push_warning("Face %d didn't map to any slot." % face) return slot ## Called every frame by a piece being dragged, so it can highlight. func hover(screen_pos: Vector2) -> StringName: var slot := slot_at_screen_pos(screen_pos) if slot != _last_hovered: _last_hovered = slot slot_hovered.emit(slot) if log_hovers and slot != &"": print("hover: %s" % slot) return slot ## Drop a modifier at a screen position. True if it landed on a valid target. func apply_modifier_at_screen_pos(screen_pos: Vector2, modifier: Modifier) -> bool: if modifier == null: push_warning("DropCatcher: null modifier.") return false var slot := slot_at_screen_pos(screen_pos) if slot == &"": return false # missed the board if not _is_valid_target(slot): return false # hit the board, but not somewhere a modifier can go dartboard.paint_slot(slot, modifier.texture) applied[slot] = modifier slot_dropped.emit(slot, modifier) print("applied '%s' -> %s" % [modifier.id, slot]) return true ## Which slots can hold a modifier? ## ## Sectors: yes. Bulls: yes (it's a slot in its own right — the bull modifier ## affects inner and outer together). Flat tire: no — it's the outer band, not ## a scoring region. func _is_valid_target(slot: StringName) -> bool: return dartboard.is_sector(slot) or slot == Dartboard.BULL_SLOT func modifier_at(slot: StringName) -> Modifier: return applied.get(slot) func clear(slot: StringName) -> void: applied.erase(slot) dartboard.clear_slot(slot) func clear_all() -> void: applied.clear() dartboard.clear_all_slots() # --- Internals ---------------------------------------------------------- func _raycast(screen_pos: Vector2) -> Dictionary: var origin := camera.project_ray_origin(screen_pos) var params := PhysicsRayQueryParameters3D.create( origin, origin + camera.project_ray_normal(screen_pos) * RAY_LENGTH ) params.collide_with_areas = false params.collide_with_bodies = true # Don't let the dart block the drop. It's on layer 2; the board is layer 1. params.collision_mask = 1 return get_viewport().world_3d.direct_space_state.intersect_ray(params)