extends Control ## Resolves a screen position (or a dart's world position) to a board sector, ## and applies modifiers there. ## ## THE BOARD HAS 21 MATERIAL SLOTS: sector_1 .. sector_20, plus "bulls". ## The bull slot covers inner AND outer bull — they move together. ## ## Two ways to find which sector was hit: ## ## A) BY FACE INDEX (default, robust) ## Raycast returns face_index. Map face -> surface. The surface's material ## name IS the sector. No trigonometry. ## Survives the board rotating, tilting, or having its sectors remapped, ## because we're asking the geometry, not calculating from an angle. ## Requires ConcavePolygonShape3D (trimesh) collision. ## ## B) BY ANGLE (fallback) ## atan2 on the local hit point. Cheap, works with a box collider. ## Survives rotation (to_local undoes the transform) but NOT sector ## remapping — if Mjolnir makes every sector a 20, the angle still says ## "this is the 6". ## ## Use A. It's the only one that supports a moving/mutating board, which is the ## whole point of the modifier system. const RAY_LENGTH := 1000.0 enum Ring { MISS, INNER_BULL, OUTER_BULL, INNER_SINGLE, TRIPLE, OUTER_SINGLE, DOUBLE } enum Method { FACE_INDEX, ANGLE } @export var camera: Camera3D @export var dartboard: Dartboard @export_group("Resolution") @export var method: Method = Method.FACE_INDEX @export_group("Angle fallback (only used if method = ANGLE)") ## Clockwise from 12 o'clock. const DART_NUMBERS := [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5] @export_range(0, 19) var sector_offset := 0 @export var reverse_direction := false @export_group("Ring radii (fraction of outer radius)") ## Rings are ALWAYS resolved by radius, even in FACE_INDEX mode — the surface ## tells us WHICH sector, the radius tells us WHICH RING within it. @export var bull_inner_max := 0.04 @export var bull_outer_max := 0.09 @export var inner_max := 0.45 @export var triple_max := 0.55 @export var outer_max := 0.80 ## sector number (1-20), or 0 for bull -> Modifier var applied: Dictionary = {} func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE # --- Public ------------------------------------------------------------- ## Drop a modifier at a screen position. True if it landed on the board. func apply_modifier_at_screen_pos(screen_pos: Vector2, modifier: Modifier) -> bool: if modifier == null: return false if camera == null or dartboard == null: push_error("DropCatcher: camera and dartboard exports must be set.") return false var hit := _raycast(screen_pos) if hit.is_empty(): return false var slot := _slot_from_hit(hit) if slot == &"": return false dartboard.paint_slot(slot, modifier.texture) applied[slot] = modifier print("applied '%s' to %s" % [modifier.id, slot]) return true ## Score a dart that landed at a world point. func score_dart(world_point: Vector3, face_index: int = -1) -> int: var slot := _slot_from_world(world_point, face_index) var ring := _ring_from_radius(_norm_radius(world_point)) var base := _base_score(slot, ring) if applied.has(slot): return (applied[slot] as Modifier).apply_score(base) return base ## Full breakdown of a hit. This is what Phase 2's ScoringEngine will consume. func resolve_hit(world_point: Vector3, face_index: int = -1) -> Dictionary: var slot := _slot_from_world(world_point, face_index) var ring := _ring_from_radius(_norm_radius(world_point)) return { "slot": slot, # &"sector_20" / &"bulls" / &"" "sector": _sector_number(slot), # 1-20, or 0 for bull/miss "ring": ring, "base_score": _base_score(slot, ring), "modifier": applied.get(slot), } # --- Slot resolution ---------------------------------------------------- func _slot_from_hit(hit: Dictionary) -> StringName: var point: Vector3 = hit.get("position", Vector3.ZERO) var face: int = hit.get("face_index", -1) return _slot_from_world(point, face) func _slot_from_world(world_point: Vector3, face_index: int) -> StringName: # The bull is a slot in its own right — check radius first, since the bull # surface may be small enough that face lookup is fiddly at the very center. var norm := _norm_radius(world_point) if norm > 1.0: return &"" if norm <= bull_outer_max: return &"bulls" if method == Method.FACE_INDEX and face_index >= 0: var slot := dartboard.slot_for_face(face_index) if slot != &"": return slot push_warning("Face %d didn't map to a slot; falling back to angle." % face_index) return _slot_from_angle(world_point) ## Fallback. Works under rotation, NOT under sector remapping. func _slot_from_angle(world_point: Vector3) -> StringName: var local := dartboard.to_local(world_point) var planar := dartboard.planar_coords(local) var t := fposmod(atan2(planar.x, planar.y) / TAU, 1.0) if reverse_direction: t = 1.0 - t var idx := int(floor(t * 20.0 + 0.5)) % 20 idx = (idx + sector_offset) % 20 return StringName("sector_%d" % DART_NUMBERS[idx]) # --- Rings & scoring ---------------------------------------------------- ## Distance from board center, as a fraction of the outer radius. func _norm_radius(world_point: Vector3) -> float: var local := dartboard.to_local(world_point) var planar := dartboard.planar_coords(local) var outer := dartboard.outer_radius() return planar.length() / outer if outer > 0.0 else 999.0 func _ring_from_radius(norm: float) -> Ring: if norm > 1.0: return Ring.MISS if norm <= bull_inner_max: return Ring.INNER_BULL if norm <= bull_outer_max: return Ring.OUTER_BULL if norm <= inner_max: return Ring.INNER_SINGLE if norm <= triple_max: return Ring.TRIPLE if norm <= outer_max: return Ring.OUTER_SINGLE return Ring.DOUBLE func _sector_number(slot: StringName) -> int: var s := String(slot) if not s.begins_with("sector_"): return 0 return s.trim_prefix("sector_").to_int() func _base_score(slot: StringName, ring: Ring) -> int: match ring: Ring.MISS: return 0 Ring.INNER_BULL: return 50 Ring.OUTER_BULL: return 25 var n := _sector_number(slot) if n == 0: return 0 match ring: Ring.TRIPLE: return n * 3 Ring.DOUBLE: return n * 2 _: return n # --- Raycast ------------------------------------------------------------ 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 return get_viewport().world_3d.direct_space_state.intersect_ray(params)