frame-of-mind/src/logic-scenes/board/card-board.gd

911 lines
32 KiB
GDScript3
Raw Normal View History

2024-10-06 09:31:47 +00:00
class_name CardBoard extends PanelContainer
enum {NAVIGATE, ASSIGN, DRAG}
2026-01-05 20:52:20 +00:00
2023-11-01 22:19:47 +00:00
var focus_stickies:bool = true:
2024-09-15 09:30:31 +00:00
set(stickies):
if not is_node_ready(): return
2024-09-15 09:30:31 +00:00
if stickies and sticky_note_container.get_child_count() == 0: return
2025-12-15 22:13:40 +00:00
2025-01-31 02:22:07 +00:00
# this messes things up if called unneeded.
if focus_stickies != stickies:
focus_stickies = stickies
2025-12-15 22:13:40 +00:00
2025-01-31 02:22:07 +00:00
if not current_context == ASSIGN:
if stickies:
current_sticky_note_id = current_sticky_note_id
else:
2025-12-15 22:13:40 +00:00
current_dropzone_id = current_dropzone_id
2023-07-02 13:10:33 +00:00
2026-01-12 17:39:34 +00:00
var focused := false:
set(value):
if focused == value:
return
2026-01-12 21:50:49 +00:00
var was_focused := focused
focused = value
if focused:
_on_board_focused()
2024-09-15 09:30:31 +00:00
else:
_on_board_unfocused()
2026-01-12 21:50:49 +00:00
# Emit closed signal when transitioning from focused to unfocused
if was_focused and not focused:
closed.emit()
2023-07-11 13:04:46 +00:00
@onready var dropzone := $HBoxContainer/dropzone
2023-08-01 08:59:24 +00:00
var dropzone_size: Vector2
@export var dropzone_padding:int = 100
@onready var sticky_note_container := $HBoxContainer/ScrollContainer/VBoxContainer
@onready var current_context:int = NAVIGATE:
2024-09-15 09:30:31 +00:00
set(context):
current_context = context
@onready var instructions := $instructions_panel/HBoxContainer/cards_remaining
2023-07-17 22:15:04 +00:00
var mementos_collected: int = 0:
2024-09-15 09:30:31 +00:00
set(mementos):
mementos_collected = mementos
match mementos:
1:
instructions.text = "There are three Mementos left to find."
2:
instructions.text = "You have collected half of the mementos."
3:
instructions.text = "Find the last Memento to complete the Board."
4:
instructions.text = "Combine cards to order your thoughts."
@onready var currently_active_node: Draggable = null:
2024-09-15 09:30:31 +00:00
set(new_node):
2025-01-31 02:22:07 +00:00
# this makes sure no accidental context switches can happen while a card is being dragged.
2025-02-24 15:14:08 +00:00
if not (current_context == DRAG):
2025-01-31 02:22:07 +00:00
if not currently_active_node == null:
currently_active_node.highlighted = false
currently_active_node = new_node
if not currently_active_node == null:
currently_active_node.highlighted = true
2023-09-23 14:19:58 +00:00
@onready var current_dropzone_id: int = 0:
2024-09-15 09:30:31 +00:00
set(new_id):
if is_node_ready():
if new_id > dropzone.get_child_count() - 1: current_dropzone_id = 0
elif new_id < 0: current_dropzone_id = dropzone.get_child_count() - 1
else: current_dropzone_id = new_id
if current_context == ASSIGN and not focus_stickies:
while not dropzone.get_child(current_dropzone_id) is Card:
current_dropzone_id = (current_dropzone_id + (1 if not new_id == -1 else -1)) % dropzone.get_child_count()
(dropzone.get_child(current_dropzone_id) as Card).preview_sticky_note(currently_active_node)
elif not focus_stickies:
currently_active_node = dropzone.get_child(current_dropzone_id)
2025-12-15 22:13:40 +00:00
2023-10-12 16:25:21 +00:00
@onready var current_sticky_note_id: int = 0:
2024-09-15 09:30:31 +00:00
set(new_id):
if is_node_ready():
if sticky_note_container.get_child_count() < 1: return
elif sticky_note_container.get_child_count() == 1: current_sticky_note_id = 0
elif new_id > sticky_note_container.get_child_count() - 1: current_sticky_note_id = 0
elif new_id < 0: current_sticky_note_id = sticky_note_container.get_child_count() - 1
elif sticky_note_container.get_child(new_id).invalid:
if sticky_note_container.get_child_count() == 1: return
if new_id+1 == sticky_note_container.get_child_count():
current_sticky_note_id = new_id-1
else:
current_sticky_note_id = new_id+1
else: current_sticky_note_id = new_id
if current_context == ASSIGN:
_return_sticky_notes_to_panels()
currently_active_node.preview_sticky_note(sticky_note_container.get_child(current_sticky_note_id).attached_sticky_note)
elif focus_stickies:
if sticky_note_container.get_child(current_sticky_note_id).get_child_count() == 1:
currently_active_node = sticky_note_container.get_child(current_sticky_note_id).get_child(0)
else:
for i in range(sticky_note_container.get_child_count() - 1):
if sticky_note_container.get_child(i).get_child_count() == 1:
currently_active_node = sticky_note_container.get_child(i).get_child(0)
2023-07-02 13:10:33 +00:00
signal board_completed
2026-01-12 21:50:49 +00:00
signal closed
# Called when the node enters the scene tree for the first time.
func _ready():
print("Board Ready!", self, "room", State.room)
State.room.card_board = self
2026-01-12 21:50:49 +00:00
var size_reference := StickyNotePanel.new()
2025-12-15 22:13:40 +00:00
2025-02-24 15:14:08 +00:00
dropzone_size = get_viewport_rect().size - Vector2(dropzone_padding + size_reference.minimum_size.x, dropzone_padding)
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
if get_parent() == get_tree().root:
populate_board(["c_void", 'c_gifted', "p_wet", "p_joy"])
populate_board(["c_jui_jutsu", 'c_hit', "p_girly", "p_vent"])
populate_board(["c_comic_heroes", 'c_teasing', "p_agent_q", "p_good_intended"])
populate_board(["c_out_of_world", 'c_confusion', "p_outer_conflict", "p_unique"])
2023-09-23 14:19:58 +00:00
2025-02-24 15:14:08 +00:00
get_viewport().gui_focus_changed.connect(reclaim_lost_focus)
2025-12-15 22:13:40 +00:00
## Called when board receives focus
func _on_board_focused() -> void:
get_tree().call_group("interactables", "collapse")
current_dropzone_id = 0
current_sticky_note_id = 0
focus_stickies = true
visible = true
if is_node_ready():
process_mode = Node.PROCESS_MODE_INHERIT
2026-01-16 19:46:16 +00:00
# Check board state and give lore feedback when presented
if is_board_complete():
board_was_completed = true
give_lore_feedback()
## Finalizes board state before closing (ends drags, cleans up transitions)
func _finalize_board_state() -> void:
print_debug("CardBoard: Finalizing board state before closing (context: %d)" % current_context)
# End any active drag operations
if current_context == DRAG and is_instance_valid(currently_active_node):
if currently_active_node.is_dragged:
print_debug(" Ending active drag for: %s" % currently_active_node.name)
_end_drag(currently_active_node)
# Stop dragging for all items (safety net in case _end_drag didn't catch everything)
for child in dropzone.get_children():
if (child is Card or child is StickyNote) and child.is_dragged:
print_debug(" Force-stopping drag for: %s" % child.name)
child.is_dragged = false
# Also check cards for attached stickies that might be dragged
for child in dropzone.get_children():
if child is Card:
var attached_sticky = child.get_attached_sticky_note()
if attached_sticky and attached_sticky.is_dragged:
print_debug(" Force-stopping drag for attached sticky: %s" % attached_sticky.name)
attached_sticky.is_dragged = false
# Return stickies to panels if in ASSIGN mode
if current_context == ASSIGN:
print_debug(" Returning stickies to panels (was in ASSIGN mode)")
_return_sticky_notes_to_panels()
# Reset context to NAVIGATE
current_context = NAVIGATE
print_debug("CardBoard: Board state finalized")
## Called when board loses focus
func _on_board_unfocused() -> void:
visible = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if is_node_ready():
# Finalize any drag operations before closing
_finalize_board_state()
process_mode = Node.PROCESS_MODE_DISABLED
2025-02-24 15:14:08 +00:00
2025-06-03 21:18:34 +00:00
func reclaim_lost_focus(_thief):
2026-01-12 17:39:34 +00:00
if focused:
2025-02-24 15:14:08 +00:00
grab_focus()
2023-09-23 14:19:58 +00:00
2025-12-15 22:13:40 +00:00
## Will be used later to spawn Cards and Post-Its and remember them in the dictionary
func populate_board(card_names: Array[StringName]):
2024-09-15 09:30:31 +00:00
mementos_collected += 1
2025-12-15 22:13:40 +00:00
var all_new:Dictionary = HardCards.get_cards_by_name_array(card_names)
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
# spawning the cards and adding them to the dictionary
for new_card: Card in all_new["cards"]:
add_card(new_card, false)
# marking the first card as random picks
new_card.picked_random = new_card.name == card_names[1]
for new_sticky_note: StickyNote in all_new["sticky_notes"]: # spawning a sticky note
add_sticky_note(new_sticky_note, false)
# marking the first sticky as random picks
new_sticky_note.picked_random = new_sticky_note.name == card_names[3]
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
currently_active_node = dropzone.get_child(0)
2023-11-01 22:19:47 +00:00
## Generates a random position within the dropzone bounds
## Attempts to avoid overlapping with existing cards/stickies
func _generate_random_position(min_distance: float = 150.0) -> Vector2:
var max_attempts := 20
var attempt := 0
var card_diameter := 336.0 # Card diameter from card.gd
var sticky_diameter := 312.0 # Sticky diameter from sticky-note.gd
while attempt < max_attempts:
var pos := Vector2(
randi_range(dropzone_padding, int(dropzone_size.x)),
randi_range(dropzone_padding, int(dropzone_size.y))
)
# Check if this position is far enough from existing items
var is_valid := true
for child in dropzone.get_children():
if child is Card or child is StickyNote:
var distance := pos.distance_to(child.position)
var required_distance := min_distance
# Use actual diameters for more precise collision checking
if child is Card:
required_distance = card_diameter * 0.6 # 60% of diameter for some overlap tolerance
elif child is StickyNote:
required_distance = sticky_diameter * 0.6
if distance < required_distance:
is_valid = false
break
if is_valid:
return pos
attempt += 1
# If we couldn't find a good position after max attempts, return a random one
# This prevents infinite loops when the board is crowded
return Vector2(
randi_range(dropzone_padding, int(dropzone_size.x)),
randi_range(dropzone_padding, int(dropzone_size.y))
)
2025-12-13 10:14:10 +00:00
func add_card(card: Card, re_parent:bool = true):
if re_parent:
card.reparent(self)
else:
add_child(card)
2024-09-15 09:30:31 +00:00
insert_area(dropzone, card)
card.position = _generate_random_position()
2024-09-15 09:30:31 +00:00
card.set_owner(self)
card.is_dragable = true
2025-12-15 22:13:40 +00:00
2025-12-13 10:14:10 +00:00
func add_sticky_note(sticky: StickyNote, re_parent:bool = true):
var new_panel := StickyNotePanel.new()
sticky_note_container.add_child(new_panel, true, Node.INTERNAL_MODE_DISABLED)
2025-02-24 15:14:08 +00:00
#WARNING this for some reason would break the tweens
2024-09-15 09:30:31 +00:00
new_panel.set_owner(self)
2025-02-24 15:14:08 +00:00
sticky.current_handle = self
2025-12-13 10:14:10 +00:00
new_panel.attatch_sticky_note(sticky, self, false, re_parent)
2025-12-15 22:13:40 +00:00
# Checks if a Node is currently inside the dropzone
func is_in_dropzone(to_check: Node) -> bool:
2024-09-15 09:30:31 +00:00
return dropzone.get_rect().has_point(to_check.global_position)
2025-01-31 02:22:07 +00:00
# Called by notes when a mouse event needs handling
func handle_mouse_button(input: InputEventMouseButton, to_handle = currently_active_node) -> void:
2026-01-16 19:46:16 +00:00
# Prevent dragging multiple nodes at once
2024-09-15 09:30:31 +00:00
if current_context == DRAG and to_handle != currently_active_node:
return
2026-01-16 19:46:16 +00:00
# === DRAG START ===
if input.button_index == MOUSE_BUTTON_LEFT and input.pressed:
_start_drag(to_handle)
return
2026-01-16 19:46:16 +00:00
# === DRAG END ===
if input.button_index == MOUSE_BUTTON_LEFT and not input.pressed:
_end_drag(to_handle)
return
2025-12-15 22:13:40 +00:00
2026-01-16 19:46:16 +00:00
## Starts a drag operation for the given draggable
func _start_drag(draggable: Draggable) -> void:
currently_active_node = draggable
current_context = DRAG
2026-01-16 19:46:16 +00:00
var mouse_offset = get_viewport().get_mouse_position() - draggable.global_position
draggable.start_drag(mouse_offset)
## Ends a drag operation and handles the drop
func _end_drag(draggable: Draggable) -> void:
draggable.end_drag()
2026-01-16 19:46:16 +00:00
# Let draggable find its own drop target
var drop_target = draggable.find_drop_target()
2026-01-16 19:46:16 +00:00
# Execute the drop
if drop_target and Draggable.is_drop_target(drop_target):
var result = drop_target.handle_drop(draggable)
2026-01-16 19:46:16 +00:00
# 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)
2026-01-16 19:46:16 +00:00
# Cleanup and state update
_return_sticky_notes_to_panels()
current_context = NAVIGATE
_update_focus_after_drop(draggable)
2026-01-16 19:46:16 +00:00
# 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()
2026-01-16 19:46:16 +00:00
if not old_sticky:
push_warning("CardBoard: Exchange occurred but no sticky returned")
return
2026-01-16 19:46:16 +00:00
# Reset visual state for old sticky
old_sticky.rotation = 0.0
old_sticky.scale = Vector2.ONE
2026-01-16 19:46:16 +00:00
old_sticky.z_index = 0
2026-01-16 19:46:16 +00:00
# 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)
2026-01-16 19:46:16 +00:00
# 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):
2024-09-15 09:30:31 +00:00
focus_stickies = false
2026-01-16 19:46:16 +00:00
current_dropzone_id = dropzone.get_children().find(draggable)
2025-01-31 02:22:07 +00:00
func _return_sticky_notes_to_panels() -> void:
if not (current_context == ASSIGN and focus_stickies): return #FIXME this is an early return to prevent race conditions. Check if it is save to be removed.
2025-02-24 15:14:08 +00:00
for panel:StickyNotePanel in sticky_note_container.get_children():
2024-09-15 09:30:31 +00:00
panel.reclaim_sticky_note()
2025-12-15 22:13:40 +00:00
for node in dropzone.get_children():
if node is StickyNote:
node.is_dragable = true
2025-05-30 14:10:44 +00:00
var board_was_completed: bool = false
func check_board_comnpletion():
if is_board_complete():
for child:StickyNotePanel in sticky_note_container.get_children():
child.clear_if_empty()
if not board_was_completed:
board_was_completed = true
board_completed.emit()
if board_was_completed:
give_lore_feedback()
func is_board_complete() -> bool:
2024-09-15 09:30:31 +00:00
if mementos_collected == 4:
for card in dropzone.get_children():
if card is Card:
if not card.has_sticky_note_attached():
return false
return true
return false
2023-08-30 09:07:22 +00:00
2025-05-30 14:10:44 +00:00
var unfitting: bool = false
var incomplete: bool = false
var complete: bool = false
func give_lore_feedback():
var fitting_card_count: int = 0
var total_card_count: int = 0
2025-12-15 22:13:40 +00:00
2025-05-30 14:10:44 +00:00
for child in dropzone.get_children():
if child is Card:
if child.has_sticky_note_attached():
fitting_card_count += int(child.card_id == child.get_attached_sticky_note().parent_id)
total_card_count += 1
2025-12-15 22:13:40 +00:00
2025-05-30 14:10:44 +00:00
if float(fitting_card_count) / float(total_card_count) < 0.2:
instructions.text = "You can move on, but you may not have understood Lisa."
if not unfitting:
2025-07-21 12:33:04 +00:00
if State.speech_language == 2:
$AnimationPlayer.play("unfitting_de")
else:
$AnimationPlayer.play("unfitting")
2025-05-30 14:10:44 +00:00
unfitting = true
#FIXME: check if this logic (after the "or") is still needed.
elif fitting_card_count != total_card_count or (total_card_count != dropzone.get_child_count() and sticky_note_container.get_child_count() != 0):
2025-05-30 14:10:44 +00:00
instructions.text = TranslationServer.translate("You may leave the room, but Lisa only agrees with %d of the %d connections.") % [fitting_card_count, total_card_count]
if not incomplete:
2025-07-21 12:33:04 +00:00
if State.speech_language == 2:
$AnimationPlayer.play("incomplete_de")
else:
$AnimationPlayer.play("incomplete")
2025-05-30 14:10:44 +00:00
incomplete = true
else:
instructions.text = "Lisa would like you to leave her room and move on."
if not complete:
2025-07-21 12:33:04 +00:00
if State.speech_language == 2:
$AnimationPlayer.play("complete_de")
else:
$AnimationPlayer.play("complete")
2025-05-30 14:10:44 +00:00
complete = true
2023-07-02 13:10:33 +00:00
# Mark area that was hovered over as currently selected
func handle_hover(to_handle: Area2D) -> void:
2024-09-15 09:30:31 +00:00
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): return
currently_active_node = to_handle
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
if is_in_dropzone(to_handle) or to_handle is Card:
if not (current_context == ASSIGN and not currently_active_node.is_dragged): #Prevent Mouse input from messing up directional control selections
if not (to_handle is StickyNote and !to_handle.on_board):
current_dropzone_id = dropzone.get_children().find(to_handle)
focus_stickies = false
2024-09-15 09:30:31 +00:00
else:
current_sticky_note_id = sticky_note_container.get_children().find(to_handle.attached_to)
focus_stickies = true
2025-12-15 22:13:40 +00:00
2023-09-23 14:19:58 +00:00
# Adds a child at the correct child indext in an area
func insert_area(parent: Control, node: Area2D):
2025-12-15 22:13:40 +00:00
var children:Array = parent.get_children()
2024-09-15 09:30:31 +00:00
var i = 0
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
if not node in parent.get_children():
node.reparent(parent, false) # Don't preserve global transform - we set positions explicitly
2024-09-15 09:30:31 +00:00
if node is StickyNote:
node.on_board = true
node.owner = self
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
if children.size() > 0:
children.erase(node)
while children[i].global_position.y < node.global_position.y and i+1 < children.size():
2025-12-15 22:13:40 +00:00
i+=1
2024-09-15 09:30:31 +00:00
parent.move_child(node, i)
2025-12-15 22:13:40 +00:00
2025-03-31 19:31:09 +00:00
if node is StickyNote:
node.attached_to = self
node.is_dragable = true
2025-12-15 22:13:40 +00:00
## Sorts all children in dropzone by their Y position
## Call this after bulk loading to fix child order / z-index issues
func _sort_dropzone_children() -> void:
var children = dropzone.get_children()
if children.size() <= 1:
return
# Sort by global Y position
children.sort_custom(func(a, b): return a.global_position.y < b.global_position.y)
# Reorder children in the scene tree
for i in range(children.size()):
dropzone.move_child(children[i], i)
print_debug("CardBoard: Re-sorted %d dropzone children by Y position" % children.size())
# Force collision shape updates on next physics frame
# This ensures Area2D hover detection works correctly after repositioning
_update_collision_shapes.call_deferred()
## Forces collision shape updates for all cards/stickies in dropzone
func _update_collision_shapes() -> void:
await get_tree().process_frame
for child in dropzone.get_children():
if child is Area2D:
# Force collision shape update by toggling monitoring
var was_monitoring = child.monitoring
child.monitoring = false
child.monitoring = was_monitoring
2026-01-16 19:46:16 +00:00
# === 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
2026-01-16 19:46:16 +00:00
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 = 0.0
sticky.scale = Vector2.ONE
2026-01-16 19:46:16 +00:00
elif draggable is Card:
# Handle card drop
insert_area(dropzone, draggable)
draggable.is_dragable = true
2026-01-16 19:46:16 +00:00
return Draggable.DropResult.ACCEPTED
2023-07-02 13:10:33 +00:00
# Takes the inputs for control inputs
func _input(event) -> void:
2025-12-15 22:13:40 +00:00
2026-01-12 17:39:34 +00:00
if not focused or not is_instance_valid(currently_active_node): return
if event.is_action_pressed("ui_cancel"):
2026-01-12 17:39:34 +00:00
focused = false
get_viewport().set_input_as_handled()
2025-12-15 22:13:40 +00:00
2025-05-21 17:42:45 +00:00
if event is InputEventMouse:
# makes sure to pass release events so notes do not get attached to the mouse while the cursor leaves the area.
if event is InputEventMouseButton and current_context == DRAG:
if event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
handle_mouse_button(event)
get_viewport().set_input_as_handled()
else:
return
2025-02-24 15:14:08 +00:00
if current_context != DRAG:
var selection_position: Vector2
if current_context == ASSIGN:
selection_position = dropzone.get_child( current_dropzone_id ).global_position
else:
selection_position = currently_active_node.global_position
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
if event.is_action_pressed("ui_up"):
if focus_stickies:
current_sticky_note_id -= 1
else:
if not try_select_nearest_card(selection_position, Vector2.UP):
current_dropzone_id -= 1
get_viewport().set_input_as_handled()
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
elif event.is_action_pressed("ui_down"): # down to select an element beneath
if focus_stickies:
current_sticky_note_id += 1
else:
if not try_select_nearest_card(selection_position, Vector2.DOWN):
current_dropzone_id += 1
get_viewport().set_input_as_handled()
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
elif event.is_action_pressed("ui_right"): # left to switch context to the left
if not try_select_nearest_card(selection_position, Vector2.RIGHT, true):
if not focus_stickies:
if current_context == NAVIGATE:
focus_stickies = true
elif current_context == ASSIGN:
sticky_note_container.get_children()[current_sticky_note_id].reclaim_sticky_note()
current_context = NAVIGATE
get_viewport().set_input_as_handled()
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
elif event.is_action_pressed("ui_left"): # right to switch context to the right
print_debug(try_select_nearest_card(selection_position, Vector2.LEFT))
2024-09-15 09:30:31 +00:00
if focus_stickies:
if current_context == NAVIGATE:
focus_stickies = false
elif current_context == ASSIGN:
current_context = NAVIGATE
get_viewport().set_input_as_handled()
2025-12-15 22:13:40 +00:00
2024-09-15 09:30:31 +00:00
elif event.is_action_pressed("ui_accept"): # select the selected note it
if current_context == ASSIGN:
if not dropzone.get_child(current_dropzone_id) is Card: return
var card:Card = dropzone.get_child(current_dropzone_id)
var sticky: StickyNote = currently_active_node if not focus_stickies else sticky_note_container.get_child(current_sticky_note_id).attached_sticky_note
2025-12-15 22:13:40 +00:00
if card.has_sticky_note_attached():
currently_active_node = card.exchange_sticky_note_with(sticky)
focus_stickies = false
if not try_select_nearest_empty_card(currently_active_node.global_position):
current_dropzone_id = find_first_free_card()
2024-09-15 09:30:31 +00:00
else:
card.attach_sticky_note(sticky)
current_context = NAVIGATE
for panel: StickyNotePanel in sticky_note_container.get_children():
panel.clear_if_empty()
if not try_select_nearest_empty_card(currently_active_node.global_position):
current_dropzone_id = find_first_free_card()
check_board_comnpletion()
if focus_stickies:
focus_stickies = false
current_dropzone_id = current_dropzone_id
else:
focus_stickies = true
current_sticky_note_id -= 1
2025-12-15 22:13:40 +00:00
elif current_context == NAVIGATE:
if focus_stickies:
# this is kind of redundant, but a safety feature to avoid active node and index misaligning.
currently_active_node = sticky_note_container.get_children()[current_sticky_note_id].get_child(0)
current_context = ASSIGN
focus_stickies = false
if not try_select_nearest_empty_card(currently_active_node.global_position):
current_dropzone_id = find_first_free_card()
else:
if currently_active_node is StickyNote:
add_sticky_note(currently_active_node)
current_sticky_note_id = sticky_note_container.get_child_count()-1
current_context = ASSIGN
focus_stickies = false
if currently_active_node is Card:
if currently_active_node.has_sticky_note_attached():
currently_active_node = currently_active_node.remove_sticky_note()
add_sticky_note(currently_active_node)
current_sticky_note_id = sticky_note_container.get_child_count()-1
focus_stickies = true
else:
if not is_board_complete():
current_context = ASSIGN
focus_stickies = true
current_sticky_note_id = current_sticky_note_id
get_viewport().set_input_as_handled()
2023-07-02 13:10:33 +00:00
2026-01-12 21:50:49 +00:00
## Awaitable function to show the board and wait until user closes it
func play() -> void:
focused = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await closed
func find_first_free_card() -> int:
2024-09-15 09:30:31 +00:00
for i in range(dropzone.get_child_count()):
# start searching at the current location, use modulo to avoid getting out of array bounds
if !dropzone.get_child((i+current_dropzone_id)%dropzone.get_child_count()).has_sticky_note_attached():
return (i+current_dropzone_id)%dropzone.get_child_count()
return -1
2023-11-01 22:19:47 +00:00
func on_sticky_panel_cleared(at_id: int):
if current_sticky_note_id == at_id:
current_sticky_note_id += 1
if current_sticky_note_id == sticky_note_container.get_child_count()-1:
if current_sticky_note_id-1 != at_id:
current_sticky_note_id -= 1
else:
current_sticky_note_id += 1
## Saves board state directly to SaveGame resource
func save_to_resource(savegame: SaveGame) -> void:
savegame.board_positions.clear()
savegame.board_attachments.clear()
savegame.board_in_panel.clear()
savegame.board_randoms.clear()
2025-12-15 22:13:40 +00:00
print_debug("CardBoard: Saving board state...")
for child in dropzone.get_children():
if child is Card:
# Save card position (local to dropzone)
savegame.board_positions[child.name] = child.position
print_debug(" Card '%s' at %s" % [child.name, child.position])
if child.picked_random:
savegame.board_randoms.append(child.name)
2025-12-15 22:13:40 +00:00
var note: StickyNote = child.get_attached_sticky_note()
2026-01-12 00:22:25 +00:00
if note:
# Save sticky attachment to card
savegame.board_attachments[note.name] = child.name
# Don't save position for attached stickies - it's determined by the card
print_debug(" Sticky '%s' attached to card '%s'" % [note.name, child.name])
if note.picked_random:
savegame.board_randoms.append(note.name)
2025-12-15 22:13:40 +00:00
elif child is StickyNote:
# Save position of loose sticky on board (local to dropzone)
savegame.board_positions[child.name] = child.position
print_debug(" Loose sticky '%s' at %s" % [child.name, child.position])
if child.picked_random:
savegame.board_randoms.append(child.name)
2025-12-15 22:13:40 +00:00
for child in sticky_note_container.get_children():
if child is StickyNotePanel and child.attached_sticky_note:
# Save sticky in panel state
savegame.board_in_panel.append(child.attached_sticky_note.name)
print_debug(" Sticky '%s' in panel" % child.attached_sticky_note.name)
if child.attached_sticky_note.picked_random:
savegame.board_randoms.append(child.attached_sticky_note.name)
print_debug("CardBoard: Saved %d positions, %d attachments, %d in panel" % [
savegame.board_positions.size(),
savegame.board_attachments.size(),
savegame.board_in_panel.size()
])
2025-03-31 19:31:09 +00:00
func initialise_from_save(savegame: SaveGame) -> void:
# Early return if nothing to load
if savegame.board_positions.is_empty():
print_debug("CardBoard: No board state to load (save is empty or legacy format)")
return
print_debug("CardBoard: Loading board state from save...")
print_debug(" Positions: %d, Attachments: %d, In panel: %d" % [
savegame.board_positions.size(),
savegame.board_attachments.size(),
savegame.board_in_panel.size()
])
# Collect all card/sticky names from all relevant dictionaries
var all_names: Array[StringName]
# Names from positions (cards and loose stickies)
for item_name: StringName in savegame.board_positions.keys():
if not all_names.has(item_name):
all_names.append(item_name)
# Sticky names from attachments
for sticky_name: StringName in savegame.board_attachments.keys():
if not all_names.has(sticky_name):
all_names.append(sticky_name)
# Card names from attachments (the values)
for card_name: StringName in savegame.board_attachments.values():
if not all_names.has(card_name):
all_names.append(card_name)
# Sticky names from panel
for item_name: StringName in savegame.board_in_panel:
if not all_names.has(item_name):
all_names.append(item_name)
print_debug(" Collected %d unique card/sticky names to load" % all_names.size())
2025-12-15 22:13:40 +00:00
var card_pile: Dictionary[String, Array] = HardCards.get_cards_by_name_array(all_names)
# Track cards by name for sticky note attachment
var cards_by_name: Dictionary = {}
# Calculate mementos collected (each memento gives 2 cards)
mementos_collected = int(card_pile["cards"].size() / 2.0)
print_debug(" Calculated mementos_collected: %d (from %d cards)" % [mementos_collected, card_pile["cards"].size()])
# Add all cards
print_debug(" Loading %d cards..." % card_pile["cards"].size())
for card: Card in card_pile["cards"]:
2026-01-16 19:46:16 +00:00
# Determine target position (will be set after adding to scene)
var target_position: Vector2
if savegame.board_positions.has(card.name):
2026-01-16 19:46:16 +00:00
target_position = savegame.board_positions[card.name]
print_debug(" Card '%s' loading at %s" % [card.name, target_position])
else:
2026-01-16 19:46:16 +00:00
target_position = _generate_random_position()
print_debug(" Card '%s' - generated random position: %s" % [card.name, target_position])
2026-01-16 19:46:16 +00:00
# Add to board first
add_child(card)
card.set_owner(self)
card.is_dragable = true
cards_by_name[card.name] = card
card.picked_random = savegame.board_randoms.has(card.card_id)
2026-01-16 19:46:16 +00:00
# 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
print_debug(" Loading %d stickies..." % card_pile["sticky_notes"].size())
for sticky: StickyNote in card_pile["sticky_notes"]:
# Check if sticky is in panel
if savegame.board_in_panel.has(sticky.name):
add_sticky_note(sticky, false)
print_debug(" Sticky '%s' added to panel" % sticky.name)
# Check if sticky is attached to a card
elif savegame.board_attachments.has(sticky.name):
var card_name = savegame.board_attachments[sticky.name]
if cards_by_name.has(card_name):
# Must add sticky to scene tree BEFORE attach_sticky_note() can reparent it
add_child(sticky)
sticky.set_owner(self)
sticky.current_handle = self # Required for input handling
cards_by_name[card_name].attach_sticky_note(sticky)
print_debug(" Sticky '%s' attached to card '%s'" % [sticky.name, card_name])
else:
push_warning("CardBoard: Sticky '%s' attached to non-existent card '%s', adding to panel" % [sticky.name, card_name])
2025-12-02 20:33:48 +00:00
add_sticky_note(sticky, false)
# Sticky is loose on board
else:
2026-01-16 19:46:16 +00:00
# Determine target position (will be set after adding to scene)
var target_position: Vector2
if savegame.board_positions.has(sticky.name):
2026-01-16 19:46:16 +00:00
target_position = savegame.board_positions[sticky.name]
print_debug(" Loose sticky '%s' loading at %s" % [sticky.name, target_position])
else:
2026-01-16 19:46:16 +00:00
target_position = _generate_random_position()
print_debug(" Loose sticky '%s' - generated random position: %s" % [sticky.name, target_position])
2026-01-16 19:46:16 +00:00
# Add to board first
add_child(sticky)
sticky.set_owner(self)
sticky.current_handle = self # Required for input handling
sticky.on_board = true
sticky.attached_to = self
sticky.is_dragable = true
2026-01-16 19:46:16 +00:00
# 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)
2025-12-15 22:13:40 +00:00
print_debug("CardBoard: Load complete!")
# Re-sort dropzone children now that all positions are set correctly
# This fixes hover detection issues caused by incorrect z-order during load
_sort_dropzone_children()
2026-01-16 19:46:16 +00:00
# Note: Lore feedback will be triggered when board is presented (in play())
2025-03-31 19:31:09 +00:00
func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool:
var selection_transform := Transform2D(0, from).looking_at(from+towards)
2025-12-15 22:13:40 +00:00
var scores: Dictionary[int, Area2D] = {-1: null}
for child:Area2D in dropzone.get_children():
if not (child is StickyNote and current_context == ASSIGN):
scores[get_distance_score(child.global_position, selection_transform)] = child
scores.erase(-1)
scores.sort()
2025-12-15 22:13:40 +00:00
if include_stickies:
var panel_scores: Dictionary[int, StickyNotePanel] = {-1: null}
for child:StickyNotePanel in sticky_note_container.get_children():
if not child.is_empty():
panel_scores[get_distance_score(child.attached_sticky_note.global_position, selection_transform)] = child
panel_scores.erase(-1)
panel_scores.sort()
2025-12-15 22:13:40 +00:00
if panel_scores != {}:
if scores != {}:
if panel_scores.keys()[0] < scores.keys()[0]:
if current_context == ASSIGN: return false
current_sticky_note_id = sticky_note_container.get_children().find(panel_scores.values()[0])
focus_stickies = true
return true
else:
if current_context == ASSIGN: return false
current_sticky_note_id = sticky_note_container.get_children().find(panel_scores.values()[0])
focus_stickies = true
return true
2025-12-15 22:13:40 +00:00
if scores != {}:
current_dropzone_id = dropzone.get_children().find(scores.values()[0])
return true
return false
func try_select_nearest_empty_card(from: Vector2) -> bool:
var scores: Dictionary[int, Area2D] = {}
2025-12-15 22:13:40 +00:00
for card in dropzone.get_children():
if card is Card:
if not card.has_sticky_note_attached():
scores[int((from-card.global_position).length())] = card
2025-12-15 22:13:40 +00:00
scores.sort()
2025-12-15 22:13:40 +00:00
if scores != {}:
current_dropzone_id = dropzone.get_children().find(scores.values()[0])
return true
return false
func get_distance_score(from: Vector2, to: Transform2D) -> int:
var diff := from * to
var dir := diff.normalized()
if dir.x > 0.5 and diff.length() > 0:
return int((abs(dir.y) + 0.5) * diff.length())
else:
return -1