fix: various board edge conditions

This commit is contained in:
tiger tiger tiger 2026-01-16 20:46:16 +01:00
parent f2ab1d7689
commit de44d4aea7
8 changed files with 329 additions and 99 deletions

View File

@ -138,6 +138,11 @@ func _on_board_focused() -> void:
if is_node_ready(): if is_node_ready():
process_mode = Node.PROCESS_MODE_INHERIT process_mode = Node.PROCESS_MODE_INHERIT
# Check board state and give lore feedback when presented
if is_board_complete():
board_was_completed = true
give_lore_feedback()
## Called when board loses focus ## Called when board loses focus
func _on_board_unfocused() -> void: func _on_board_unfocused() -> void:
@ -204,91 +209,99 @@ func is_in_dropzone(to_check: Node) -> bool:
# Called by notes when a mouse event needs handling # Called by notes when a mouse event needs handling
func handle_mouse_button(input: InputEventMouseButton, to_handle = currently_active_node) -> void: func handle_mouse_button(input: InputEventMouseButton, to_handle = currently_active_node) -> void:
# Prevent dragging multiple nodes at once
# Makes sure that only the same area is dragged.
# Otherwise overlapping areas are dragged at the same time.
if current_context == DRAG and to_handle != currently_active_node: if current_context == DRAG and to_handle != currently_active_node:
return return
if input.button_index == MOUSE_BUTTON_MASK_LEFT and input.pressed: # === DRAG START ===
currently_active_node = to_handle if input.button_index == MOUSE_BUTTON_LEFT and input.pressed:
to_handle.is_dragged = true _start_drag(to_handle)
if to_handle is StickyNote: return
if not to_handle.on_board:
to_handle.reparent(dropzone) # === DRAG END ===
to_handle.on_board = true if input.button_index == MOUSE_BUTTON_LEFT and not input.pressed:
to_handle.attached_to = self _end_drag(to_handle)
current_context = DRAG return
# when Drag stops ... ## Starts a drag operation for the given draggable
if input.button_index == MOUSE_BUTTON_MASK_LEFT and not input.pressed: func _start_drag(draggable: Draggable) -> void:
to_handle.is_dragged = false currently_active_node = draggable
if to_handle is StickyNote: current_context = DRAG
if is_in_dropzone(to_handle):
if to_handle.has_overlapping_areas():
for area in to_handle.get_overlapping_areas():
if area is Card:
focus_stickies = false
if area.has_sticky_note_attached():
to_handle = area.exchange_sticky_note_with(to_handle)
to_handle.reparent(dropzone)
to_handle.on_board = true
if sticky_note_container.get_child_count() > 0:
sticky_note_container.get_child(current_sticky_note_id).attached_sticky_note = to_handle
to_handle.attached_to = sticky_note_container.get_child(current_sticky_note_id)
else:
var new_panel = StickyNotePanel.new()
sticky_note_container.add_child(new_panel, true, Node.INTERNAL_MODE_DISABLED)
new_panel.owner = self
new_panel.attatch_sticky_note(to_handle, self, false)
current_sticky_note_id = 0
to_handle.reset_drag()
current_context = NAVIGATE
_return_sticky_notes_to_panels()
return
else:
area.attach_sticky_note(to_handle)
to_handle.z_index = 0
if sticky_note_container.get_child_count() > 0:
sticky_note_container.get_child(current_sticky_note_id).clear_if_empty()
current_context = NAVIGATE
check_board_comnpletion()
return
else:
var i: int = 0
for panel: StickyNotePanel in sticky_note_container.get_children():
i += 1
if panel.is_empty:
if panel.get_global_rect().intersects(Rect2(to_handle.global_position - Vector2(to_handle.diameter/2, 10), Vector2(to_handle.diameter/2, 10))):
panel.attatch_sticky_note(to_handle, self)
elif panel.is_gapped or i == sticky_note_container.get_child_count():
panel.collapse_gap()
var new_panel = StickyNotePanel.new()
sticky_note_container.add_child(new_panel)
sticky_note_container.move_child(new_panel, i)
new_panel.attatch_sticky_note(to_handle, self)
new_panel.owner = self
panel.clear_if_empty()
_return_sticky_notes_to_panels()
current_context = NAVIGATE
return
## Dropping Cards and Sticky Notes not causing a return condition above. var mouse_offset = get_viewport().get_mouse_position() - draggable.global_position
if not (to_handle is StickyNote and to_handle.is_sticky_note_attached()): draggable.start_drag(mouse_offset)
if to_handle.get_parent() is Card:
insert_area(to_handle.get_parent().remove_sticky_note(), to_handle)
else: ## Ends a drag operation and handles the drop
insert_area(dropzone, to_handle) func _end_drag(draggable: Draggable) -> void:
current_context = NAVIGATE draggable.end_drag()
# Let draggable find its own drop target
var drop_target = draggable.find_drop_target()
# Execute the drop
if drop_target and Draggable.is_drop_target(drop_target):
var result = drop_target.handle_drop(draggable)
# Handle exchange result (sticky swapped with card's sticky)
if result == Draggable.DropResult.EXCHANGED:
_handle_sticky_exchange(draggable, drop_target)
elif draggable is StickyNote and not is_in_dropzone(draggable):
# Sticky dropped in panel area but no empty panel found - create one
add_sticky_note(draggable)
else:
# Fallback: use default board drop
handle_drop(draggable)
# Cleanup and state update
_return_sticky_notes_to_panels()
current_context = NAVIGATE
_update_focus_after_drop(draggable)
# Check win condition if sticky was attached to card
if draggable is StickyNote and draggable.is_sticky_note_attached():
check_board_comnpletion()
## Handles the exchange when a sticky is dropped on a card that already has one
## The exchanged sticky always goes to the sticky_note_container (panel zone)
func _handle_sticky_exchange(new_sticky: StickyNote, card: Card) -> void:
var old_sticky = card.get_last_exchanged_sticky()
if not old_sticky:
push_warning("CardBoard: Exchange occurred but no sticky returned")
return
# Reset visual state for old sticky
old_sticky.rotation = old_sticky.base_rotation
old_sticky.scale = old_sticky.base_scale
old_sticky.z_index = 0
# Exchanged sticky always goes to sticky_note_container
if new_sticky._came_from_panel and sticky_note_container.get_child_count() > 0:
# New sticky came from panel - return old sticky to that panel (swap positions)
var target_panel = sticky_note_container.get_child(current_sticky_note_id)
old_sticky.reparent(dropzone)
old_sticky.on_board = true
target_panel.attached_sticky_note = old_sticky
old_sticky.attached_to = target_panel
target_panel.attatch_sticky_note(old_sticky, self, false, true)
else:
# New sticky was loose - create new panel for old sticky
add_sticky_note(old_sticky)
# Clean up empty panel if the new sticky came from one
if new_sticky._came_from_panel and sticky_note_container.get_child_count() > 0:
sticky_note_container.get_child(current_sticky_note_id).clear_if_empty()
## Updates focus and navigation state after a drop
func _update_focus_after_drop(draggable: Draggable) -> void:
# Update focus based on where the item ended up
if draggable is Card or (draggable is StickyNote and draggable.on_board):
focus_stickies = false focus_stickies = false
current_dropzone_id = dropzone.get_children().find(to_handle) current_dropzone_id = dropzone.get_children().find(draggable)
if to_handle is StickyNote:
to_handle.rotation = to_handle.base_rotation
to_handle.scale = to_handle.base_scale
if input.is_action_pressed("mouse_right") and current_context == DRAG:
to_handle.reset_drag()
func _return_sticky_notes_to_panels() -> void: func _return_sticky_notes_to_panels() -> void:
@ -394,6 +407,35 @@ func insert_area(parent: Control, node: Area2D):
node.attached_to = self node.attached_to = self
node.is_dragable = true node.is_dragable = true
# === DROP TARGET PATTERN IMPLEMENTATION ===
## Checks if this board can accept the given draggable (always true for board)
func can_accept_drop(draggable: Draggable) -> bool:
return draggable is Card or draggable is StickyNote
## Handles dropping a draggable onto the board (into the dropzone)
func handle_drop(draggable: Draggable) -> int:
if not can_accept_drop(draggable):
return Draggable.DropResult.REJECTED
if draggable is StickyNote:
# Handle sticky note drop
var sticky = draggable as StickyNote
insert_area(dropzone, sticky)
sticky.attached_to = self
sticky.on_board = true
sticky.is_dragable = true
# Reset visual state
sticky.rotation = sticky.base_rotation
sticky.scale = sticky.base_scale
elif draggable is Card:
# Handle card drop
insert_area(dropzone, draggable)
draggable.is_dragable = true
return Draggable.DropResult.ACCEPTED
# Takes the inputs for control inputs # Takes the inputs for control inputs
func _input(event) -> void: func _input(event) -> void:
@ -630,22 +672,26 @@ func initialise_from_save(savegame: SaveGame) -> void:
# Add all cards # Add all cards
print_debug(" Loading %d cards..." % card_pile["cards"].size()) print_debug(" Loading %d cards..." % card_pile["cards"].size())
for card: Card in card_pile["cards"]: for card: Card in card_pile["cards"]:
# Set position BEFORE adding to scene tree to avoid reparent position issues # Determine target position (will be set after adding to scene)
var target_position: Vector2
if savegame.board_positions.has(card.name): if savegame.board_positions.has(card.name):
card.position = savegame.board_positions[card.name] target_position = savegame.board_positions[card.name]
print_debug(" Card '%s' at %s" % [card.name, card.position]) print_debug(" Card '%s' loading at %s" % [card.name, target_position])
else: else:
card.position = _generate_random_position() target_position = _generate_random_position()
print_debug(" Card '%s' - generated random position: %s" % [card.name, card.position]) print_debug(" Card '%s' - generated random position: %s" % [card.name, target_position])
# Add to board first
add_child(card) add_child(card)
insert_area(dropzone, card)
card.set_owner(self) card.set_owner(self)
card.is_dragable = true card.is_dragable = true
cards_by_name[card.name] = card cards_by_name[card.name] = card
card.picked_random = savegame.board_randoms.has(card.card_id) card.picked_random = savegame.board_randoms.has(card.card_id)
# Move to dropzone and set position (position must be set after adding to scene)
insert_area(dropzone, card)
card.position = target_position
# Add all sticky notes # Add all sticky notes
print_debug(" Loading %d stickies..." % card_pile["sticky_notes"].size()) print_debug(" Loading %d stickies..." % card_pile["sticky_notes"].size())
for sticky: StickyNote in card_pile["sticky_notes"]: for sticky: StickyNote in card_pile["sticky_notes"]:
@ -670,27 +716,33 @@ func initialise_from_save(savegame: SaveGame) -> void:
# Sticky is loose on board # Sticky is loose on board
else: else:
# Set position BEFORE adding to scene tree to avoid reparent position issues # Determine target position (will be set after adding to scene)
var target_position: Vector2
if savegame.board_positions.has(sticky.name): if savegame.board_positions.has(sticky.name):
sticky.position = savegame.board_positions[sticky.name] target_position = savegame.board_positions[sticky.name]
print_debug(" Loose sticky '%s' at %s" % [sticky.name, sticky.position]) print_debug(" Loose sticky '%s' loading at %s" % [sticky.name, target_position])
else: else:
sticky.position = _generate_random_position() target_position = _generate_random_position()
print_debug(" Loose sticky '%s' - generated random position: %s" % [sticky.name, sticky.position]) print_debug(" Loose sticky '%s' - generated random position: %s" % [sticky.name, target_position])
# Add to board first
add_child(sticky) add_child(sticky)
insert_area(dropzone, sticky)
sticky.set_owner(self) sticky.set_owner(self)
sticky.current_handle = self # Required for input handling sticky.current_handle = self # Required for input handling
sticky.on_board = true sticky.on_board = true
sticky.attached_to = self sticky.attached_to = self
sticky.is_dragable = true sticky.is_dragable = true
# Move to dropzone and set position (position must be set after adding to scene)
insert_area(dropzone, sticky)
sticky.position = target_position
sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id) sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id)
print_debug("CardBoard: Load complete!") print_debug("CardBoard: Load complete!")
# Note: Lore feedback will be triggered when board is presented (in play())
func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool: func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool:

View File

@ -235,8 +235,7 @@ func _on_input_event(_viewport, event, _shape_idx):
func _move_card(): func _move_card():
if is_dragged: if is_dragged:
position = get_viewport().get_mouse_position() - mouse_offset update_drag_position(get_viewport().get_mouse_position())
confine_to_screen()
func has_sticky_note_attached() -> bool: func has_sticky_note_attached() -> bool:
return get_attached_sticky_note() != null return get_attached_sticky_note() != null
@ -296,3 +295,49 @@ func reclaim_sticky_note():
await current_sticky_note.transform_tween_finished await current_sticky_note.transform_tween_finished
current_sticky_note.reparent(self) current_sticky_note.reparent(self)
current_sticky_note.owner = self.owner current_sticky_note.owner = self.owner
# === DROP TARGET PATTERN IMPLEMENTATION ===
## Temporary storage for exchanged sticky during drop operation
var _last_exchanged_sticky: StickyNote = null
## Checks if this card can accept the given draggable
func can_accept_drop(draggable: Draggable) -> bool:
return draggable is StickyNote
## Handles dropping a sticky note onto this card
## Returns DropResult indicating success, rejection, or exchange
func handle_drop(draggable: StickyNote) -> int:
if not can_accept_drop(draggable):
return Draggable.DropResult.REJECTED
if has_sticky_note_attached():
# Exchange: remove current, attach new, store old for retrieval
_last_exchanged_sticky = exchange_sticky_note_with(draggable)
# Reset z_index for newly attached sticky
draggable.z_index = 0
return Draggable.DropResult.EXCHANGED
else:
# Simple attach
if attach_sticky_note(draggable):
# Reset z_index for newly attached sticky
draggable.z_index = 0
return Draggable.DropResult.ACCEPTED
else:
# Attach failed (shouldn't happen, but handle it)
return Draggable.DropResult.REJECTED
## Retrieves the sticky that was exchanged during last drop
## Clears the reference after retrieval
func get_last_exchanged_sticky() -> StickyNote:
var result = _last_exchanged_sticky
_last_exchanged_sticky = null
return result
# === DRAG LIFECYCLE OVERRIDES ===
## Cards always drop back to board dropzone
func find_drop_target() -> Node:
return owner if owner is CardBoard else get_parent()

View File

@ -27,9 +27,9 @@ anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -126.0 offset_left = -126.0
offset_top = -88.0 offset_top = -100.0
offset_right = 136.0 offset_right = 136.0
offset_bottom = 89.95834 offset_bottom = 77.95834
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme = ExtResource("3_mdi7r") theme = ExtResource("3_mdi7r")

View File

@ -4,6 +4,18 @@ extends Area2D
## Base class for draggable UI elements (Cards and StickyNotes) ## Base class for draggable UI elements (Cards and StickyNotes)
## Provides common dragging behavior and boundary protection ## Provides common dragging behavior and boundary protection
## Drop result codes for DropTarget pattern
enum DropResult {
ACCEPTED, # Drop successful, item is now owned by target
REJECTED, # Drop refused, item stays with previous owner
EXCHANGED # Swap occurred, exchanged item needs handling
}
## Static helper to check if a node implements DropTarget pattern
## DropTarget pattern requires: can_accept_drop(draggable) and handle_drop(draggable)
static func is_drop_target(node: Node) -> bool:
return node != null and node.has_method("can_accept_drop") and node.has_method("handle_drop")
var is_dragged: bool = false: var is_dragged: bool = false:
set(dragged): set(dragged):
is_dragged = dragged is_dragged = dragged
@ -12,6 +24,38 @@ var is_dragged: bool = false:
## Margin from screen edges when confining to screen bounds ## Margin from screen edges when confining to screen bounds
@export var screen_margin: float = 50.0 @export var screen_margin: float = 50.0
## Drag state tracking
var _drag_start_position: Vector2
var _mouse_drag_offset: Vector2
var _drag_source: Node = null # Where the drag started from
## === DRAG LIFECYCLE METHODS ===
## Override these in Card and StickyNote for specific behavior
## Starts a drag operation
func start_drag(mouse_offset: Vector2) -> void:
_drag_start_position = global_position
_mouse_drag_offset = mouse_offset
_drag_source = get_parent()
is_dragged = true
## Updates position during drag (call from _process or manual update)
func update_drag_position(mouse_pos: Vector2) -> void:
global_position = mouse_pos - _mouse_drag_offset
confine_to_screen()
## Finds the best drop target for this draggable
## Override in subclasses for specific drop target logic
## Returns the node that should receive the drop, or null for no valid target
func find_drop_target() -> Node:
# Base implementation: return parent (board)
return get_parent()
## Called after drop to clean up drag state
func end_drag() -> void:
is_dragged = false
_drag_source = null
## Confines this draggable element to stay within screen or container bounds ## Confines this draggable element to stay within screen or container bounds
## Skip this check if a sticky note is attached to a card ## Skip this check if a sticky note is attached to a card
func confine_to_screen() -> void: func confine_to_screen() -> void:

View File

@ -154,6 +154,7 @@ _data = {
} }
[node name="board" type="PanelContainer"] [node name="board" type="PanelContainer"]
z_index = -100
material = SubResource("ShaderMaterial_ttqei") material = SubResource("ShaderMaterial_ttqei")
clip_contents = true clip_contents = true
anchors_preset = 15 anchors_preset = 15

View File

@ -132,8 +132,7 @@ func _on_input_event(_viewport, event, _shape_idx):
func _move_sticky_note(): func _move_sticky_note():
if is_dragged: if is_dragged:
global_position = get_viewport().get_mouse_position() - mouse_offset update_drag_position(get_viewport().get_mouse_position())
confine_to_screen()
func is_sticky_note_attached() -> bool: func is_sticky_note_attached() -> bool:
# FIXME: this breaks if attatched to is previousely freed because GODOT IS FUCKING STUPID # FIXME: this breaks if attatched to is previousely freed because GODOT IS FUCKING STUPID
@ -155,3 +154,69 @@ func tween_transform_to(target: Transform2D):
await transform_tween.finished await transform_tween.finished
transform_tween_finished.emit() transform_tween_finished.emit()
# === DRAG LIFECYCLE OVERRIDES ===
## Track whether this sticky came from a panel (for exchange logic)
var _came_from_panel: bool = false
## Start drag: if in panel, immediately move to board
func start_drag(offset: Vector2) -> void:
super.start_drag(offset)
_came_from_panel = is_sticky_note_in_panel()
# If attached to a card, detach it first
if is_sticky_note_attached():
var card := attached_to as Card
if card and card.has_method("remove_sticky_note"):
card.remove_sticky_note()
# If in panel, immediately reparent to board for dragging
if _came_from_panel and current_handle:
var board := current_handle
var dropzone := board.get_node_or_null("HBoxContainer/dropzone")
if dropzone:
reparent(dropzone)
else:
reparent(board)
on_board = true
attached_to = board
## Find best drop target: Card > Panel > Board (in priority order)
func find_drop_target() -> Node:
# Priority 1: Check for overlapping cards in dropzone
for area in get_overlapping_areas():
if area is Card and Draggable.is_drop_target(area):
return area
# Priority 2: Check if dropped outside dropzone (over panel area)
if current_handle and not current_handle.is_in_dropzone(self):
var target_panel := _find_nearest_panel()
if target_panel:
return target_panel
# Priority 3: Default to board (stay loose in dropzone)
return current_handle
## Find the nearest panel that can accept this sticky
func _find_nearest_panel() -> StickyNotePanel:
if not current_handle or not current_handle.has_node("HBoxContainer/ScrollContainer/VBoxContainer"):
return null
var panel_container := current_handle.get_node("HBoxContainer/ScrollContainer/VBoxContainer")
var sticky_rect := Rect2(global_position - Vector2(diameter/2, 10), Vector2(diameter/2, 10))
# First pass: look for empty panels we're hovering over
for panel in panel_container.get_children():
if panel is StickyNotePanel:
if panel.is_empty() and panel.get_global_rect().intersects(sticky_rect):
return panel
# Second pass: if no empty panel found, find first empty panel
for panel in panel_container.get_children():
if panel is StickyNotePanel and panel.is_empty():
return panel
# No empty panels found - will need to create one (handled by board)
return null

View File

@ -9,6 +9,7 @@ radius = 48.0
height = 312.0 height = 312.0
[node name="sticky-note" type="Area2D"] [node name="sticky-note" type="Area2D"]
z_index = 1
priority = 100 priority = 100
script = ExtResource("1_yvh5n") script = ExtResource("1_yvh5n")
text = "card" text = "card"

View File

@ -100,3 +100,25 @@ func replace_sticky_note_with(new_sticky_note: StickyNote):
func is_empty() -> bool: func is_empty() -> bool:
return get_child_count() == 0 and not is_attatching return get_child_count() == 0 and not is_attatching
# === DROP TARGET PATTERN IMPLEMENTATION ===
## Checks if this panel can accept the given draggable
func can_accept_drop(draggable: Draggable) -> bool:
return draggable is StickyNote and is_empty()
## Handles dropping a sticky note onto this panel
func handle_drop(draggable: StickyNote) -> int:
if not can_accept_drop(draggable):
return Draggable.DropResult.REJECTED
# Attach sticky to this panel
attatch_sticky_note(draggable, owner, true, true)
# Clean up other empty panels
for panel in get_parent().get_children():
if panel is StickyNotePanel and panel != self:
panel.clear_if_empty()
return Draggable.DropResult.ACCEPTED