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_board_completion() 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] func _ready() -> void: print("CardBoard.gd: %s._ready()" % self.name) State.room.card_board = self super._ready() # HACK: Lets us debug more easily if get_parent() == get_tree().root: _debug_mode() return print("Board Ready!", self, "room", State.room) ## frame rate independent FIR smoothing filter used for small or dynamic card adjustments 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) var dragging := notes.any(func (n : Draggable): return n.is_dragged) if dragging: # Y-sort the nodes, this lets us fill the gap more nicely. notes.sort_custom(func (a:Draggable, b:Draggable): return a.global_position.y < b.global_position.y) 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.home = zone_position zone_position.y += sticky_height # Only if not already in transit / animated or user holding on to one if not dragging and not note.tween: note.animate_home() else: # do adjustment with FIR filter note.position = _smooth(note.position, note.home, delta) func _check_completion() -> void: if is_board_complete(): board_was_completed = true board_completed.emit() ## 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("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) for new_sticky_note: StickyNote in all_new["sticky_notes"]: add_note(new_sticky_note) # 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_draggable = true func appear(): await Main.curtain.close() show() await Main.curtain.open() func vanish(): await Main.curtain.close() hide() await Main.curtain.open() # 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 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() # Handle sticky note drops if draggable is StickyNote: var sticky := draggable as StickyNote # If dropped on a card, attach it if destination and destination is Card: var target_card := destination as Card target_card.attach_or_exchange_note(sticky) # If dropped on board (no destination), ensure it's a child of the board elif not destination: if sticky.is_attached: reclaim_sticky(sticky) # Check win condition after any sticky movement check_board_completion() func reclaim_sticky(note: StickyNote): note.reparent(self) note.tween = null 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: return mementos_collected == 4 and notes.all(func (n : StickyNote): return n.is_attached) 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_note_attached(): fitting_card_count += int(child.card_id == child.get_attached_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 selection and selection.is_dragged: return var candidate := _nearest_hovered(_sort_by_proximity_and_depth(notes)) if not candidate: candidate = _nearest_hovered(_sort_by_proximity_and_depth(cards)) selection = candidate 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 if item.mouse_over else 0 # only care about the ones we are currently touching 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 * 10000 > b.position.x + b.position.y * 10000 func _by_mouse(a: Draggable, b: Draggable) -> bool: var viewport := get_viewport() # when app closes, the sorting might still be going on var mouse_pos : Vector2 = viewport.get_mouse_position() if viewport else Vector2.ZERO 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_action_pressed("ui_cancel"): closed.emit() get_viewport().set_input_as_handled() if selection and not selection.is_dragged and 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() print("CardBoard: Saving board state...") # Save all cards and their positions for card in cards: savegame.board_positions[card.name] = card.position print(" Card '%s' at %s" % [card.name, card.position]) # Save sticky note attachment if present var note: StickyNote = card.get_attached_note() if note: savegame.board_attachments[note.name] = card.name print(" Sticky '%s' attached to card '%s'" % [note.name, card.name]) # Save loose sticky notes (not attached to cards) for note in notes: savegame.board_positions[note.name] = note.position print(" Loose sticky '%s' at %s" % [note.name, note.position]) print("CardBoard: Saved %d positions, %d attachments" % [ savegame.board_positions.size(), savegame.board_attachments.size() ]) func initialise_from_save(savegame: SaveGame) -> void: # Early return if nothing to load if savegame.board_positions.is_empty(): print("CardBoard: No board state to load (save is empty or legacy format)") return print("CardBoard: Loading board state from save...") print(" Positions: %d, Attachments: %d" % [ savegame.board_positions.size(), savegame.board_attachments.size() ]) # Collect all card/sticky names from positions and attachments 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 (keys) for sticky_name: StringName in savegame.board_attachments.keys(): if sticky_name not in all_names: all_names.append(sticky_name) # Card names from attachments (values) for card_name: StringName in savegame.board_attachments.values(): if card_name not in all_names: all_names.append(card_name) print(" Collected %d unique card/sticky names to load" % all_names.size()) # Create all cards and stickies populate_board(all_names) # Calculate mementos collected (each memento gives 2 cards) mementos_collected = int(len(cards) / 2.0) # Build lookup dictionary for cards var cards_by_name: Dictionary[StringName, Card] = {} for card in cards: cards_by_name[card.name] = card # Position all cards for card: Card in cards: if savegame.board_positions.has(card.name): card.position = savegame.board_positions[card.name] print(" Card '%s' at %s" % [card.name, card.position]) else: card.position = _generate_random_position() push_warning(" Card '%s' - no saved position, using random" % card.name) # Attach sticky notes to cards or position them loose for sticky: StickyNote in notes: var card_name: StringName = savegame.board_attachments.get(sticky.name, &"--nil--") if card_name and cards_by_name.has(card_name): # Sticky must be attached to a card var card: Card = cards_by_name[card_name] card.attach_or_exchange_note(sticky, true) print(" Sticky '%s' attached to card '%s'" % [sticky.name, card_name]) else: # Sticky is loose on the board if savegame.board_positions.has(sticky.name): sticky.position = savegame.board_positions[sticky.name] print(" Loose sticky '%s' at %s" % [sticky.name, sticky.position]) else: # Fallback to center of board sticky.position = position + size / 2.0 push_warning(" Sticky '%s' - no saved position, using center" % sticky.name) # Re-sort by positions for correct z-ordering _sort_by_positions() print("CardBoard: Load complete!") _check_completion() # === 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("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()