Fix sector drag and drop
This commit is contained in:
+97
-162
@@ -1,208 +1,139 @@
|
||||
extends Control
|
||||
|
||||
## Resolves a screen position (or a dart's world position) to a board sector,
|
||||
## and applies modifiers there.
|
||||
## Screen position -> board slot. That's the whole job.
|
||||
##
|
||||
## THE BOARD HAS 21 MATERIAL SLOTS: sector_1 .. sector_20, plus "bulls".
|
||||
## The bull slot covers inner AND outer bull — they move together.
|
||||
## 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.
|
||||
##
|
||||
## Two ways to find which sector was hit:
|
||||
## 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.
|
||||
##
|
||||
## 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.
|
||||
## 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.
|
||||
##
|
||||
## 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.
|
||||
## 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
|
||||
|
||||
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("Debug")
|
||||
## Print every hover. Noisy but useful when calibrating.
|
||||
@export var log_hovers := false
|
||||
|
||||
@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
|
||||
## 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 -------------------------------------------------------------
|
||||
|
||||
## 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
|
||||
## 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:
|
||||
push_error("DropCatcher: camera and dartboard exports must be set.")
|
||||
return false
|
||||
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_from_hit(hit)
|
||||
var slot := slot_at_screen_pos(screen_pos)
|
||||
if slot == &"":
|
||||
return false
|
||||
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
|
||||
print("applied '%s' to %s" % [modifier.id, slot])
|
||||
slot_dropped.emit(slot, modifier)
|
||||
|
||||
print("applied '%s' -> %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
|
||||
## 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
|
||||
|
||||
|
||||
## 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),
|
||||
}
|
||||
func modifier_at(slot: StringName) -> Modifier:
|
||||
return 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 clear(slot: StringName) -> void:
|
||||
applied.erase(slot)
|
||||
dartboard.clear_slot(slot)
|
||||
|
||||
|
||||
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)
|
||||
func clear_all() -> void:
|
||||
applied.clear()
|
||||
dartboard.clear_all_slots()
|
||||
|
||||
|
||||
## 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 ------------------------------------------------------------
|
||||
# --- Internals ----------------------------------------------------------
|
||||
|
||||
func _raycast(screen_pos: Vector2) -> Dictionary:
|
||||
var origin := camera.project_ray_origin(screen_pos)
|
||||
@@ -212,4 +143,8 @@ func _raycast(screen_pos: Vector2) -> Dictionary:
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,49 +1,69 @@
|
||||
extends Control
|
||||
|
||||
## Offers a set of random modifiers each round as draggable pieces.
|
||||
## Dropping one on the board applies it and consumes the whole set.
|
||||
|
||||
const SectorSwatch := preload("res://core/ui/sector_swatch.gd")
|
||||
## Spawns draggable modifier pieces along the bottom of the screen.
|
||||
##
|
||||
## For now it spawns ALL modifiers in the registry, one of each, and they never
|
||||
## run out. That's a debug affordance — the real economy (random draws, rounds,
|
||||
## consumption) is a later phase. Right now we just want to prove drag-and-drop
|
||||
## works.
|
||||
|
||||
@export var drop_catcher: Control
|
||||
@export var pieces_per_round := 3
|
||||
|
||||
@export_group("Layout")
|
||||
@export var piece_size := Vector2(90, 110)
|
||||
@export var spacing := 24.0
|
||||
@export var bottom_margin := 30.0
|
||||
|
||||
@export_group("Behaviour")
|
||||
## If false, pieces stay in the tray after a successful drop. Useful while
|
||||
## testing; set true when the real economy lands.
|
||||
@export var consume_on_use := false
|
||||
|
||||
const SectorSwatch := preload("res://core/ui/sector_swatch.gd")
|
||||
|
||||
var _pieces: Array[Control] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# The tray itself must not eat mouse events — only its pieces should.
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
spawn_round()
|
||||
|
||||
|
||||
func spawn_round() -> void:
|
||||
_clear_pieces()
|
||||
|
||||
# ModRegistry is the autoload. (Was "ModifierCatalog" — renamed.)
|
||||
var choices: Array = ModRegistry.pick_random(pieces_per_round)
|
||||
if choices.is_empty():
|
||||
push_warning("ModifierTray: registry returned no modifiers.")
|
||||
if drop_catcher == null:
|
||||
push_error("ModifierTray: `drop_catcher` export is not set.")
|
||||
return
|
||||
|
||||
for mod in choices:
|
||||
var piece := Control.new()
|
||||
piece.set_script(SectorSwatch)
|
||||
piece.custom_minimum_size = piece_size
|
||||
piece.size = piece_size
|
||||
piece.modifier = mod
|
||||
piece.paint_texture = mod.texture
|
||||
piece.drop_catcher = drop_catcher
|
||||
piece.tray = self
|
||||
add_child(piece)
|
||||
_pieces.append(piece)
|
||||
|
||||
_layout_pieces()
|
||||
spawn_all()
|
||||
|
||||
|
||||
func _layout_pieces() -> void:
|
||||
## Spawn one piece per registered modifier.
|
||||
func spawn_all() -> void:
|
||||
_clear_pieces()
|
||||
|
||||
var mods: Array[Modifier] = ModRegistry.modifiers
|
||||
if mods.is_empty():
|
||||
push_warning("ModifierTray: registry has no modifiers. Nothing to spawn.")
|
||||
return
|
||||
|
||||
for mod in mods:
|
||||
_spawn_piece(mod)
|
||||
|
||||
_layout()
|
||||
|
||||
|
||||
func _spawn_piece(mod: Modifier) -> void:
|
||||
var piece := Control.new()
|
||||
piece.set_script(SectorSwatch)
|
||||
piece.custom_minimum_size = piece_size
|
||||
piece.size = piece_size
|
||||
piece.modifier = mod
|
||||
piece.paint_texture = mod.texture
|
||||
piece.drop_catcher = drop_catcher
|
||||
piece.tray = self
|
||||
add_child(piece)
|
||||
_pieces.append(piece)
|
||||
|
||||
|
||||
func _layout() -> void:
|
||||
var count := _pieces.size()
|
||||
if count == 0:
|
||||
return
|
||||
@@ -57,9 +77,14 @@ func _layout_pieces() -> void:
|
||||
_pieces[i].position = Vector2(start_x + i * (piece_size.x + spacing), y)
|
||||
|
||||
|
||||
## Called by a piece when it lands successfully — the whole set is spent.
|
||||
func consume() -> void:
|
||||
_clear_pieces()
|
||||
## Called by a piece after a successful drop.
|
||||
func on_piece_used(piece: Control) -> void:
|
||||
if not consume_on_use:
|
||||
return # piece snaps home; it's reusable
|
||||
|
||||
_pieces.erase(piece)
|
||||
piece.queue_free()
|
||||
_layout()
|
||||
|
||||
|
||||
func _clear_pieces() -> void:
|
||||
@@ -69,7 +94,6 @@ func _clear_pieces() -> void:
|
||||
_pieces.clear()
|
||||
|
||||
|
||||
# TESTING ONLY — SPACE rolls a new set.
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_SPACE:
|
||||
spawn_round()
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
_layout()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
extends Control
|
||||
|
||||
## A draggable modifier piece in the tray. Follows the cursor directly (no
|
||||
## ghost). On release over the board it asks DropCatcher to apply itself.
|
||||
## A draggable modifier piece. Follows the cursor directly — no ghost, no
|
||||
## Godot drag-and-drop API (which fights with 3D raycasting).
|
||||
##
|
||||
## Was TrapezoidSwatch.gd — renamed because modifiers now attach to whole
|
||||
## sectors, not individual segments.
|
||||
## While dragging it asks the DropCatcher which slot is under the cursor, so
|
||||
## the player can SEE what they're about to hit before committing.
|
||||
|
||||
@export var paint_texture: Texture2D:
|
||||
set(value):
|
||||
@@ -13,7 +13,7 @@ extends Control
|
||||
|
||||
@export_range(0.0, 1.0) var top_width_ratio := 0.55
|
||||
|
||||
# Set by the tray when it spawns this piece.
|
||||
# Injected by the tray on spawn.
|
||||
var drop_catcher: Control
|
||||
var tray: Node
|
||||
var modifier: Modifier
|
||||
@@ -21,6 +21,7 @@ var modifier: Modifier
|
||||
var _dragging := false
|
||||
var _home_position := Vector2.ZERO
|
||||
var _drag_offset := Vector2.ZERO
|
||||
var _hovered_slot: StringName = &""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -50,9 +51,17 @@ func _draw() -> void:
|
||||
else:
|
||||
draw_colored_polygon(points, Color(0.8, 0.8, 0.8))
|
||||
|
||||
# Outline turns green when hovering a valid slot — the player's only cue
|
||||
# that the drop will land.
|
||||
var outline_col := Color(0, 0, 0, 0.6)
|
||||
var outline_w := 2.0
|
||||
if _dragging and _hovered_slot != &"":
|
||||
outline_col = Color(0.3, 1.0, 0.4, 0.9)
|
||||
outline_w = 4.0
|
||||
|
||||
var outline := points
|
||||
outline.append(points[0])
|
||||
draw_polyline(outline, Color(0, 0, 0, 0.6), 2.0)
|
||||
draw_polyline(outline, outline_col, outline_w)
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
@@ -67,6 +76,7 @@ func _start_drag() -> void:
|
||||
_dragging = true
|
||||
_home_position = global_position
|
||||
_drag_offset = global_position - get_global_mouse_position()
|
||||
|
||||
# top_level lets us move by global_position without reparenting.
|
||||
# Reparenting mid-drag breaks the release event.
|
||||
top_level = true
|
||||
@@ -77,31 +87,39 @@ func _start_drag() -> void:
|
||||
func _end_drag() -> void:
|
||||
if not _dragging:
|
||||
return
|
||||
|
||||
_dragging = false
|
||||
_hovered_slot = &""
|
||||
modulate.a = 1.0
|
||||
z_index = 0
|
||||
|
||||
var applied := false
|
||||
if drop_catcher == null:
|
||||
push_error("SectorSwatch has no drop_catcher (tray export not set?).")
|
||||
elif not drop_catcher.has_method(&"apply_modifier_at_screen_pos"):
|
||||
push_error("drop_catcher has no apply_modifier_at_screen_pos method.")
|
||||
push_error("SectorSwatch has no drop_catcher.")
|
||||
else:
|
||||
applied = drop_catcher.apply_modifier_at_screen_pos(
|
||||
get_global_mouse_position(), modifier
|
||||
)
|
||||
|
||||
if applied:
|
||||
if tray != null and tray.has_method(&"consume"):
|
||||
tray.consume()
|
||||
else:
|
||||
queue_free()
|
||||
else:
|
||||
# Missed — snap home.
|
||||
top_level = false
|
||||
global_position = _home_position
|
||||
# Always snap home. The tray decides whether to consume the piece.
|
||||
top_level = false
|
||||
global_position = _home_position
|
||||
queue_redraw()
|
||||
|
||||
if applied and tray != null and tray.has_method(&"on_piece_used"):
|
||||
tray.on_piece_used(self)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _dragging:
|
||||
global_position = get_global_mouse_position() + _drag_offset
|
||||
if not _dragging:
|
||||
return
|
||||
|
||||
var mouse := get_global_mouse_position()
|
||||
global_position = mouse + _drag_offset
|
||||
|
||||
# Live hover feedback — which slot would this land on right now?
|
||||
if drop_catcher != null and drop_catcher.has_method(&"hover"):
|
||||
var slot: StringName = drop_catcher.hover(mouse)
|
||||
if slot != _hovered_slot:
|
||||
_hovered_slot = slot
|
||||
queue_redraw()
|
||||
|
||||
Reference in New Issue
Block a user