class_name CardBoard extends Playable signal board_completed signal closed @export var dropzone_padding : int = 100 @export var sticky_width : float = 400.0 @export var sticky_height : float = 110.0 var all_names : Array[StringName] = [] var notes : Array[StickyNote] = [] var cards : Array[Card] = [] var board_was_completed: bool = false var current_context : int = NAVIGATE var selection_state : SelectionState @onready var instructions := $instructions_panel/HBoxContainer/cards_remaining @onready var dropzone : Control = %CardZone @onready var notezone : Control = %NoteZone enum SelectionState {FREE,STICKIES,CARDS} enum {NAVIGATE, ASSIGN, DRAG} func play(): _check_completion() # check completion one extra time on on enter await closed _finalize_board_state() var mementos_collected: int = 0: set(mementos): mementos_collected = mementos instructions.text = I18n.get_memento_prompt(mementos_collected) var selection: Draggable = null: set(value): if selection == value: return # Select & highlight new if selection: selection.highlighted = false selection = value if selection: selection.highlighted = true # Are we selecting cards or stickies? if selection is Card: selection_state = SelectionState.CARDS if selection is StickyNote: selection_state = SelectionState.STICKIES func _navigate_next(): var candidates := _selection_candidates var index := maxi(0, candidates.find(selection)) selection = candidates[(index+1) % len(candidates)] func _navigate_prev(): var candidates := _selection_candidates var index := maxi(0, candidates.find(selection)) selection = candidates[index-1] # Called when the node enters the scene tree for the first time. func _ready() -> void: # HACK: Lets us debug more easily if get_parent() == get_tree().root: _debug_mode() return print("Board Ready!", self, "room", State.room) State.room.card_board = self ## frame rate independent FIR smoothing filter func _smooth(current: Vector2, goal: Vector2, delta: float) -> Vector2: var k := pow(0.1, 60.0 * delta) return (1.0-k) * current + k * goal func _process(delta: float): var zone_position := Vector2(notezone.get_screen_position().x + sticky_width / 3.0, sticky_height) for note in notes: # Skip all dragged and already attached notes if note.is_attached: continue if note.is_dragged: continue # Magnetically move all notes to where they ought to be on screen note.position = _smooth(note.position, zone_position, delta) zone_position.y += sticky_height pass func _check_completion() -> void: 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: # End any active drag operations if current_context == DRAG: _end_drag(selection) for item in notes: item.is_dragged = false for item in cards: item.is_dragged = false # Reset context to NAVIGATE current_context = NAVIGATE print_debug("CardBoard: Board state finalized") ## Spawn Cards and Post-Its # TODO: rename to "add to board" func populate_board(names: Array[StringName]): mementos_collected += 1 for item in names: assert(name not in all_names, "Tried to re-add card %s" % item) var all_new:Dictionary = HardCards.get_cards_by_name_array(names) for new_card: Card in all_new["cards"]: add_card(new_card) new_card.picked_random = new_card.name == names[1] #CAUTION: Hardcoded Index for new_sticky_note: StickyNote in all_new["sticky_notes"]: add_note(new_sticky_note) new_sticky_note.picked_random = new_sticky_note.name == names[3] #CAUTION: Hardcoded Index # FIXME: This can be made even simpler. ## 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 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)) ) func add_card(card: Card) -> void: add_child(card) cards.append(card) card.position = _generate_random_position() card.is_dragable = true func add_note(note: StickyNote) -> void: add_child(note) notes.append(note) note.is_dragable = true # Checks if a Node is currently inside the dropzone func is_in_dropzone(to_check: Draggable) -> bool: return dropzone.get_rect().has_point(to_check.global_position) #TODO: is global pos correct here? # Called by notes when a mouse event needs handling func handle_mouse_button(input: InputEventMouseButton, target: Draggable) -> void: # === DRAG START === if input.button_index == MOUSE_BUTTON_LEFT and input.is_pressed(): _start_drag(target) return # === DRAG END === if input.button_index == MOUSE_BUTTON_LEFT and not input.is_released(): _end_drag(target) return ## Starts a drag operation for the given draggable func _start_drag(draggable: Draggable) -> void: selection = draggable current_context = DRAG 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: selection = draggable # Cleanup and state update current_context = NAVIGATE var destination := draggable.end_drag() if not destination and draggable is StickyNote: # reclaim if necessary pass if destination and destination is Card: # attach / unattach pass if destination and destination is StickyNote: # unattach and attach to parent pass # 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) # pass # Check win condition if sticky was attached to card if draggable is StickyNote and draggable.is_attached: check_board_completion() func reclaim_sticky(note: StickyNote): note.reparent(self) notes.append(note) func check_board_completion(): if is_board_complete(): 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: return false for sticky : StickyNote in notes: if not sticky.is_attached: return false return true 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 elif fitting_card_count < total_card_count: 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(draggable: Draggable) -> void: # If we're hovering with the mouse without clicking, that updates our selection if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): return func _sort_by_proximity_and_depth(draggables: Array) -> Array[Draggable]: var result : Array[Draggable] = [] result.append_array(draggables) result.sort_custom(_by_mouse) var depth := len(result) * 2 for item in result: depth -= 1 item.z_index = depth return result func _nearest_hovered(candidates: Array[Draggable]) -> Draggable: for candidate in candidates: if candidate.mouse_over: return candidate return null func _by_spatial(a: Draggable, b: Draggable) -> bool: return a.position.x + a.position.y * 100 > b.position.x + b.position.y * 100 func _by_mouse(a: Draggable, b: Draggable) -> bool: var mouse_pos : Vector2 = get_viewport().get_mouse_position() return (a.position-mouse_pos).length() < (b.position-mouse_pos).length() ## Call this after bulk loading to fix child order / z-index issues func _sort_by_positions() -> void: cards.sort_custom(_by_spatial) notes.sort_custom(_by_spatial) # === 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 return Draggable.DropResult.ACCEPTED # Takes the inputs for control inputs func _input(event) -> void: if event is InputEventAction and event.is_action_pressed("ui_cancel"): closed.emit() get_viewport().set_input_as_handled() if event is InputEventMouseMotion and not event.is_action_pressed("mouse_left"): var candidate := _nearest_hovered(_sort_by_proximity_and_depth(notes)) if not candidate: candidate = _nearest_hovered(_sort_by_proximity_and_depth(cards)) selection = candidate ## 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 cards: # 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) # Save position of loose sticky on board (local to dropzone) for child in notes: print_debug(" Loose sticky '%s' at %s" % [child.name, child.position]) savegame.board_positions[child.name] = child.position savegame.board_in_panel.append(child.name) if child.picked_random: savegame.board_randoms.append(child.name) if child.picked_random: savegame.board_randoms.append(child.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() ]) # Clear & Collect all card/sticky names from all relevant dictionaries all_names = [] # Names from positions (cards and loose stickies) for item_name: StringName in savegame.board_positions.keys(): all_names.append(item_name) # Sticky names from attachments for sticky_name: StringName in savegame.board_attachments.keys(): all_names.append(sticky_name) # Card names from attachments (the values) for card_name: StringName in savegame.board_attachments.values(): all_names.append(card_name) # Sticky names from panel for item_name: StringName in savegame.board_in_panel: all_names.append(item_name) print_debug(" Collected %d unique card/sticky names to load" % all_names.size()) populate_board(all_names) # Track cards by name for sticky note attachment pass var cards_by_name: Dictionary[StringName, Card] = {} for card in cards: cards_by_name[card.name] = card # Calculate mementos collected (each memento gives 2 cards) mementos_collected = int(len(cards) / 2.0) # Position all cards for card: Card in cards: var target_position: Vector2 if savegame.board_positions.has(card.name): target_position = savegame.board_positions[card.name] print_debug(" Card '%s' loading at %s" % [card.name, target_position]) else: target_position = _generate_random_position() push_warning(" Card '%s' - generated random position: %s" % [card.name, target_position]) card.position = target_position card.picked_random = savegame.board_randoms.has(card.card_id) # Add all sticky notes for sticky: StickyNote in notes: var card_name := savegame.board_attachments[sticky.name] var card : Card = cards_by_name.get(card_name, null) if card: sticky.reparent(card) # was alrady added as our own child by populate_board card.attach_sticky_note(sticky) print_debug(" Sticky '%s' attached to card '%s'" % [sticky.name, card_name]) else: # Sticky is loose, this means it will be automatically moved sticky.position = savegame.board_positions.get(sticky.name, position + size/2.0) sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id) print_debug("CardBoard: Load complete!") # Re-sort dropzone children now that all positions are set correctly _sort_by_positions() # === Computed Properties === var dropzone_size: Vector2: # FIXME: Hardcode get: return get_viewport_rect().size - Vector2(dropzone_padding + sticky_width, dropzone_padding) var _selection_candidates : Array[Draggable]: get: match selection_state: SelectionState.CARDS: return cards as Array[Draggable] SelectionState.STICKIES: return notes as Array[Draggable] SelectionState.FREE: print_debug("switching from free selection to guided stickies selection") # Otherwise default to sticky selection selection_state = SelectionState.STICKIES return notes as Array[Draggable] # === Util === func _debug_mode() -> void: 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"]) await get_tree().process_frame play()