151 lines
4.3 KiB
GDScript
151 lines
4.3 KiB
GDScript
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)
|