class_name CardBoard extends PanelContainer enum {NAVIGATE, ASSIGN, DRAG} var focus_stickies:bool = true: set(stickies): if not is_node_ready(): return if stickies and sticky_note_container.get_child_count() == 0: return # this messes things up if called unneeded. if focus_stickies != stickies: focus_stickies = stickies if not current_context == ASSIGN: if stickies: current_sticky_note_id = current_sticky_note_id else: current_dropzone_id = current_dropzone_id var focused := false: set(value): if focused == value: return var was_focused := focused focused = value if focused: _on_board_focused() else: _on_board_unfocused() # Emit closed signal when transitioning from focused to unfocused if was_focused and not focused: closed.emit() @onready var dropzone := $HBoxContainer/dropzone var dropzone_size: Vector2 @export var dropzone_padding:int = 100 @onready var sticky_note_container := $HBoxContainer/ScrollContainer/VBoxContainer @onready var current_context:int = NAVIGATE: set(context): current_context = context @onready var instructions := $instructions_panel/HBoxContainer/cards_remaining var mementos_collected: int = 0: 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: Area2D = null: set(new_node): # this makes sure no accidental context switches can happen while a card is being dragged. if not (current_context == DRAG): 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 @onready var current_dropzone_id: int = 0: 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) @onready var current_sticky_note_id: int = 0: 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) signal board_completed 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 var size_reference := StickyNotePanel.new() dropzone_size = get_viewport_rect().size - Vector2(dropzone_padding + size_reference.minimum_size.x, dropzone_padding) 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"]) get_viewport().gui_focus_changed.connect(reclaim_lost_focus) ## 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 ## Called when board loses focus func _on_board_unfocused() -> void: visible = false Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if is_node_ready(): process_mode = Node.PROCESS_MODE_DISABLED # Stop any active dragging for sticky in dropzone.get_children(): if sticky is StickyNote: sticky.is_dragged = false func reclaim_lost_focus(_thief): if focused: grab_focus() ## Will be used later to spawn Cards and Post-Its and remember them in the dictionary func populate_board(card_names: Array[StringName]): mementos_collected += 1 var all_new:Dictionary = HardCards.get_cards_by_name_array(card_names) # 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] currently_active_node = dropzone.get_child(0) ## Generates a random position within the dropzone bounds func _generate_random_position() -> Vector2: return Vector2( randi_range(dropzone_padding, int(dropzone_size.x)), randi_range(dropzone_padding, int(dropzone_size.y)) ) func add_card(card: Card, re_parent:bool = true): if re_parent: card.reparent(self) else: add_child(card) insert_area(dropzone, card) card.position = _generate_random_position() card.set_owner(self) card.is_dragable = true 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) #WARNING this for some reason would break the tweens new_panel.set_owner(self) sticky.current_handle = self new_panel.attatch_sticky_note(sticky, self, false, re_parent) # Checks if a Node is currently inside the dropzone func is_in_dropzone(to_check: Node) -> bool: return dropzone.get_rect().has_point(to_check.global_position) # Called by notes when a mouse event needs handling func handle_mouse_button(input: InputEventMouseButton, to_handle = currently_active_node) -> void: # 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: return if input.button_index == MOUSE_BUTTON_MASK_LEFT and input.pressed: currently_active_node = to_handle to_handle.is_dragged = true if to_handle is StickyNote: if not to_handle.on_board: to_handle.reparent(dropzone) to_handle.on_board = true to_handle.attached_to = self current_context = DRAG # when Drag stops ... if input.button_index == MOUSE_BUTTON_MASK_LEFT and not input.pressed: to_handle.is_dragged = false if to_handle is StickyNote: 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. if not (to_handle is StickyNote and to_handle.is_sticky_note_attached()): if to_handle.get_parent() is Card: insert_area(to_handle.get_parent().remove_sticky_note(), to_handle) else: insert_area(dropzone, to_handle) current_context = NAVIGATE focus_stickies = false current_dropzone_id = dropzone.get_children().find(to_handle) 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: 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. for panel:StickyNotePanel in sticky_note_container.get_children(): panel.reclaim_sticky_note() for node in dropzone.get_children(): if node is StickyNote: node.is_dragable = true 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: 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 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 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 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: if State.speech_language == 2: $AnimationPlayer.play("unfitting_de") else: $AnimationPlayer.play("unfitting") 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): 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: if State.speech_language == 2: $AnimationPlayer.play("incomplete_de") else: $AnimationPlayer.play("incomplete") incomplete = true else: instructions.text = "Lisa would like you to leave her room and move on." if not complete: if State.speech_language == 2: $AnimationPlayer.play("complete_de") else: $AnimationPlayer.play("complete") complete = true # Mark area that was hovered over as currently selected func handle_hover(to_handle: Area2D) -> void: if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): return currently_active_node = to_handle 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 else: current_sticky_note_id = sticky_note_container.get_children().find(to_handle.attached_to) focus_stickies = true # Adds a child at the correct child indext in an area func insert_area(parent: Control, node: Area2D): var children:Array = parent.get_children() var i = 0 if not node in parent.get_children(): node.reparent(parent, false) # Don't preserve global transform - we set positions explicitly if node is StickyNote: node.on_board = true node.owner = self if children.size() > 0: children.erase(node) while children[i].global_position.y < node.global_position.y and i+1 < children.size(): i+=1 parent.move_child(node, i) if node is StickyNote: node.attached_to = self node.is_dragable = true # Takes the inputs for control inputs func _input(event) -> void: if not focused or not is_instance_valid(currently_active_node): return if event.is_action_pressed("ui_cancel"): focused = false get_viewport().set_input_as_handled() 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 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 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() 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() 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() elif event.is_action_pressed("ui_left"): # right to switch context to the right print_debug(try_select_nearest_card(selection_position, Vector2.LEFT)) if focus_stickies: if current_context == NAVIGATE: focus_stickies = false elif current_context == ASSIGN: current_context = NAVIGATE get_viewport().set_input_as_handled() 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 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() 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 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() ## 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: 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 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() 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) var note: StickyNote = child.get_attached_sticky_note() 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) 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) 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() ]) 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()) 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"]: # Set position BEFORE adding to scene tree to avoid reparent position issues if savegame.board_positions.has(card.name): card.position = savegame.board_positions[card.name] print_debug(" Card '%s' at %s" % [card.name, card.position]) else: card.position = _generate_random_position() print_debug(" Card '%s' - generated random position: %s" % [card.name, card.position]) add_child(card) insert_area(dropzone, 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) # 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]) add_sticky_note(sticky, false) # Sticky is loose on board else: # Set position BEFORE adding to scene tree to avoid reparent position issues if savegame.board_positions.has(sticky.name): sticky.position = savegame.board_positions[sticky.name] print_debug(" Loose sticky '%s' at %s" % [sticky.name, sticky.position]) else: sticky.position = _generate_random_position() print_debug(" Loose sticky '%s' - generated random position: %s" % [sticky.name, sticky.position]) add_child(sticky) insert_area(dropzone, 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 sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id) print_debug("CardBoard: Load complete!") func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool: var selection_transform := Transform2D(0, from).looking_at(from+towards) 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() 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() 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 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] = {} 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 scores.sort() 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