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 @export var is_memory_board: bool = false: set(is_memory): is_memory_board = is_memory if is_node_ready(): %CardboardBox.visible = is_memory var all_names : Array[StringName] = [] var notes : Array[StickyNote] = [] var cards : Array[Card] = [] var board_was_completed: bool = false var current_context : Context = Context.NAVIGATE var selection_state : SelectionState var last_card_selected: Card var last_note_selected: StickyNote var selection: Draggable = null: set(value): if selection == value: return if selection: if selection is Card: last_card_selected = selection elif selection is StickyNote: last_note_selected = selection # 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 current_context == Context.ASSIGN: selection.preview_sticky_note(last_note_selected) if selection is StickyNote: selection_state = SelectionState.STICKIES #@onready var instructions := $instructions_panel/HBoxContainer/cards_remaining @onready var dropzone : Control = %CardZone @onready var sideboard : SideBoard = %SideBoard enum SelectionState {FREE,STICKIES,CARDS} enum Context {NAVIGATE, ASSIGN, DRAG} func _ready() -> void: prints("card-board.gd:", "_ready()", self, "room:", State.active_room, owner) super._ready() _delayed_ready.call_deferred() # We need to wait for our room further up our parent hierarchy to actually make itself known # for interactables to do things with it, e.g. CardBoard needs to register itself func _delayed_ready() ->void: var board_room := State.active_room as RoomWithBoard if not get_tree().root == get_parent(): assert(board_room, "CardBoard spawned in room that's not a RoomWithboard.") board_room.card_board = self is_memory_board = is_memory_board ## Updates prompt label based on the interaction type and collected state func _show_prompt() -> void: var overrides : Array[StringName] = [&"menu_back"] Prompts.right_bottom(Prompts.pick(&"ui_cancel"), overrides) Prompts.instruction(I18n.get_memento_prompt(mementos_collected)) Prompts.performed.connect(_perform) func _hide_prompt(): Prompts.performed.disconnect(_perform) Prompts.clear() func play(): _show_prompt() check_board_completion() await closed print("CardBoard.gd: closing") _finalize_board_state() _hide_prompt() var mementos_collected: int = 0: set(mementos): mementos_collected = mementos 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] ## 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.9, 60.0 * delta) return (k) * current + (1.0-k) * goal 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 == 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 = Context.NAVIGATE print("CardBoard: Board state finalized") func _ensure_unique(items: Array[StringName]) -> Array[StringName]: var result : Array[StringName] = [] for item in items: if item not in result: result.append(item) else: push_warning("card-board.gd: ", "discarding non-unique item ", item) return result ## Spawn Cards and Post-Its # TODO: rename to "add to board" func populate_board(names: Array[StringName]): mementos_collected += 1 names = _ensure_unique(names) var all_new:Dictionary = CardLibrary.get_cards_by_name_array(names) all_names.append_array(all_new.keys()) for new_card: Card in all_new["cards"]: add_card(new_card) notes += sideboard.populate(all_new["sticky_notes"]) # 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 var parent_before_drag: Node ## Starts a drag operation for the given draggable func _start_drag(draggable: Draggable) -> void: parent_before_drag = draggable.get_parent() selection = draggable current_context = Context.DRAG var mouse_offset := get_viewport().get_mouse_position() - draggable.global_position draggable.start_drag(mouse_offset) draggable.reparent(self) ## Ends a drag operation and handles the drop func _end_drag(draggable: Draggable) -> void: selection = draggable # Cleanup and state update current_context = Context.NAVIGATE var destination := draggable.end_drag() if destination: if destination is Card: # If dropped on a card, attach it if draggable is StickyNote: destination.attach_or_exchange_note(draggable) elif destination is SideBoundary: if not sideboard.accept_drop(draggable): #FIXME remove if handle_drop and can_accept_drop is properly implemented. destination = null # If dropped on board (no destination), ensure it's a child of the board if not destination: if draggable is StickyNote: reclaim_sticky(draggable) # Check win condition after any sticky movement check_board_completion() parent_before_drag = null 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 = len(cards) for child in cards: if child.has_note_attached(): fitting_card_count += int(child.card_id == child.get_attached_note().parent_id) if float(fitting_card_count) / float(total_card_count) < 0.2: Prompts.instruction("You can move on, but you may not have understood Lisa.") if not unfitting: if State.speech_locale == 2: $AnimationPlayer.play("unfitting_de") else: $AnimationPlayer.play("unfitting") unfitting = true elif fitting_card_count < total_card_count: Prompts.instruction(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_locale == 2: $AnimationPlayer.play("incomplete_de") else: $AnimationPlayer.play("incomplete") incomplete = true else: Prompts.instruction("Lisa would like you to leave her room and move on.") if not complete: if State.speech_locale == 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 # FIXME: this function seems to not be used. ## 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 func _perform(action : StringName) -> void: match action: &"ui_cancel": closed.emit() # Takes the inputs for control inputs func _input(event : InputEvent) -> void: # trying to not do the double check on the line below. var is_mouse_left:= false if event is InputEventMouseButton: is_mouse_left = event.button_index == MOUSE_BUTTON_LEFT if selection and not selection.is_dragged and event is InputEventMouseMotion and not is_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 if event.is_action_pressed("ui_left"): _handle_direction_input(Vector2.LEFT) if event.is_action_pressed("ui_right"): _handle_direction_input(Vector2.RIGHT) if event.is_action_pressed("ui_up"): _handle_direction_input(Vector2.UP) if event.is_action_pressed("ui_down"): _handle_direction_input(Vector2.DOWN) #TODO: make this work with the prompter once more. if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() closed.emit() func _handle_direction_input(direction: Vector2): if current_context == Context.NAVIGATE and _assure_selection(): try_select_nearest_card(selection.global_position, direction, (current_context != Context.ASSIGN)) func _assure_selection() -> bool: if selection: return true else: if selection_state == SelectionState.STICKIES or selection_state == SelectionState.FREE: if dropzone.get_child_count() > 0: selection = dropzone.get_child(0) return true ## Attempting to still select a Card if no sticky could be found. if sideboard.get_child_count() > 0: selection = sideboard.get_child(0) return true return false ## 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(" sositions: %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() print(" 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 print(" 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 as_draggable(cards) SelectionState.STICKIES: return as_draggable(notes) SelectionState.FREE: print("switching from free selection to guided stickies selection") # Otherwise default to sticky selection selection_state = SelectionState.STICKIES return as_draggable(notes) func as_draggable(items : Array) -> Array[Draggable]: var result : Array[Draggable] = [] for item in items: result.append(item) return result # === Util === func _debug_mode() -> void: super._debug_mode() 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() 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 include_stickies): scores[get_distance_score(child.global_position, selection_transform)] = child scores.erase(-1) scores.sort() if include_stickies: var panel_scores: Dictionary[int, Control] = {-1: null} for child:Control in sideboard.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 == Context.ASSIGN: return false selection = panel_scores.values()[0] selection_state = SelectionState.STICKIES return true else: if current_context == Context.ASSIGN: return false selection = panel_scores.values()[0] selection_state = SelectionState.STICKIES return true if scores != {}: selection = 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_note_attached(): scores[int((from-card.global_position).length())] = card scores.sort() if scores != {}: selection = 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