Fix sector drag and drop

This commit is contained in:
Johan Sandoval
2026-07-15 20:40:43 -05:00
parent 69f2028ce3
commit c221ec6c2a
12 changed files with 564 additions and 345 deletions
+143 -37
View File
@@ -1,22 +1,29 @@
class_name Dartboard
extends StaticBody3D
## The board. 21 material slots: sector_1 .. sector_20, plus "bulls".
## 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.
## Every slot on the board, in the order Blender exported them (order doesn't
## matter — we index by name).
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 face index -> StringName
## 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
@@ -32,13 +39,13 @@ func _ready() -> void:
func _index_slots() -> void:
var mesh := mesh_instance.mesh
if mesh == null:
push_error("Dartboard has no mesh.")
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 — can't be skinned or hit." % i)
push_warning("Surface %d has no material — it can't be hit or skinned." % i)
continue
var slot := StringName(mat.resource_name)
@@ -46,21 +53,32 @@ func _index_slots() -> void:
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
print("Dartboard: indexed %d slots." % _slot_to_surface.size())
if _slot_to_surface.size() != 21:
push_warning("Expected 21 slots (20 sectors + bulls), got %d." % _slot_to_surface.size())
# 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,
## but the mesh stores faces per-surface. So we walk the surfaces in order,
## accumulating a running face count, and record which range belongs to which.
## 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 the ConcavePolygonShape3D was built from the mesh with surfaces
## in the same order — which is what create_trimesh_shape() does.
## ⚠️ 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:
@@ -75,8 +93,7 @@ func _index_faces() -> void:
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
# Triangle count: from the index buffer if present, else from vertices.
var tri_count := (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3)
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 &""
@@ -84,14 +101,12 @@ func _index_faces() -> void:
for f in tri_count:
_face_to_slot[running + f] = slot
_surface_face_range[i] = [running, running + tri_count - 1]
running += tri_count
print("Dartboard: mapped %d faces." % _face_to_slot.size())
func _cache_radius() -> void:
var aabb := mesh_instance.get_aabb()
# The disc's two large axes. The third is the board's thickness.
var sizes := [aabb.size.x, aabb.size.y, aabb.size.z]
sizes.sort()
_outer_radius = sizes[2] * 0.5 # largest dimension = diameter
@@ -99,29 +114,39 @@ func _cache_radius() -> void:
# --- Public -------------------------------------------------------------
## Which slot does this trimesh face belong to?
## 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. Used for both skins and modifier overlays.
## 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
# to every instance AND can get written back to disk in the editor.
# ⚠️ 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:
@@ -130,30 +155,46 @@ func clear_slot(slot: StringName) -> void:
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
## Board radius in local units.
func outer_radius() -> float:
return _outer_radius
## Project a LOCAL point onto the board's face plane, returning 2D coords
## where +Y is "up" on the board (toward the 20).
## Project a LOCAL point onto the board's face plane.
## Returns 2D coords where +Y is "up" on the board.
##
## Which two axes those are depends on how the .glb imported. glTF is +Y up,
## so a board modeled facing +Z in Blender usually lands facing +Z here, with
## the disc in the X/Y plane. If sector detection is 90 degrees off, this is
## the function to fix.
## 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
# Identify the thin axis — that's the board's normal.
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:
@@ -162,12 +203,77 @@ func planar_coords(local_point: Vector3) -> Vector2:
return Vector2(local_point.z - center.z, local_point.y - center.y)
## Debug: what did Blender actually export?
## 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 slots ===")
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)
print(" surface %2d: '%s'" % [i, mat.resource_name if mat else "(none)"])
print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size])
print("=======================")
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("===============================")
+97 -162
View File
@@ -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)
+59 -35
View File
@@ -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()
+38 -20
View File
@@ -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()