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

543 lines
16 KiB
GDScript3
Raw Normal View History

class_name CardBoard
extends Playable
signal board_completed
signal closed
2026-01-05 20:52:20 +00:00
@export var dropzone_padding : int = 100
@export var sticky_width : float = 400.0
@export var sticky_height : float = 110.0
2025-12-15 22:13:40 +00:00
var all_names : Array[StringName] = []
var notes : Array[StickyNote] = []
var cards : Array[Card] = []
2025-12-15 22:13:40 +00:00
var board_was_completed: bool = false
2023-07-02 13:10:33 +00:00
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
2023-07-11 13:04:46 +00:00
enum SelectionState {FREE,STICKIES,CARDS}
enum {NAVIGATE, ASSIGN, DRAG}
func play():
check_board_completion()
await closed
_finalize_board_state()
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
instructions.text = I18n.get_memento_prompt(mementos_collected)
2023-07-02 13:10:33 +00:00
var selection: Draggable = null:
set(value):
2026-01-18 09:48:03 +00:00
if selection == value: return
2026-01-18 18:16:56 +00:00
2026-01-18 09:48:03 +00:00
# Select & highlight new
if selection: selection.highlighted = false
selection = value
2026-01-18 09:48:03 +00:00
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:
2026-01-18 20:01:20 +00:00
print_debug("CardBoard.gd: %s._ready()" % self.name)
super._ready()
# 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
2026-01-12 21:50:49 +00:00
2025-12-15 22:13:40 +00:00
## 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
2025-12-15 22:13:40 +00:00
2026-01-18 09:48:03 +00:00
func _process(delta: float):
2026-01-17 11:15:21 +00:00
var zone_position := Vector2(notezone.get_screen_position().x + sticky_width / 3.0, sticky_height)
2023-09-23 14:19:58 +00:00
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
2026-01-18 06:30:38 +00:00
if note.is_dragged: continue
2025-12-15 22:13:40 +00:00
# Magnetically move all notes to where they ought to be on screen
note.home = zone_position
zone_position.y += sticky_height
2025-12-15 22:13:40 +00:00
# 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:
2026-01-16 19:46:16 +00:00
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_debug("CardBoard: Board state finalized")
## Spawn Cards and Post-Its
# TODO: rename to "add to board"
func populate_board(names: Array[StringName]):
2024-09-15 09:30:31 +00:00
mementos_collected += 1
2025-12-15 22:13:40 +00:00
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)
2025-12-15 22:13:40 +00:00
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)
2025-12-15 22:13:40 +00:00
2023-11-01 22:19:47 +00:00
# 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()
2024-09-15 09:30:31 +00:00
card.is_dragable = true
2025-12-15 22:13:40 +00:00
func add_note(note: StickyNote) -> void:
add_child(note)
notes.append(note)
note.is_draggable = true
2026-01-18 20:53:19 +00:00
func appear():
await Main.curtain.close()
show()
await Main.curtain.open()
func vanish():
await Main.curtain.close()
hide()
await Main.curtain.open()
2025-12-15 22:13:40 +00:00
# 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?
2025-01-31 02:22:07 +00:00
# Called by notes when a mouse event needs handling
2026-01-18 09:48:03 +00:00
func handle_mouse_button(input: InputEventMouseButton, target: Draggable) -> void:
2026-01-16 19:46:16 +00:00
# === DRAG START ===
2026-01-18 09:48:03 +00:00
if input.button_index == MOUSE_BUTTON_LEFT and input.is_pressed():
_start_drag(target)
2026-01-16 19:46:16 +00:00
return
2026-01-16 19:46:16 +00:00
# === DRAG END ===
2026-01-18 16:32:31 +00:00
if input.button_index == MOUSE_BUTTON_LEFT and input.is_released():
2026-01-18 09:48:03 +00:00
_end_drag(target)
2026-01-16 19:46:16 +00:00
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
2026-01-18 09:48:03 +00:00
func _start_drag(draggable: Draggable) -> void:
selection = draggable
2026-01-16 19:46:16 +00:00
current_context = DRAG
var mouse_offset := get_viewport().get_mouse_position() - draggable.global_position
2026-01-16 19:46:16 +00:00
draggable.start_drag(mouse_offset)
## Ends a drag operation and handles the drop
func _end_drag(draggable: Draggable) -> void:
2026-01-18 18:16:56 +00:00
selection = draggable
2026-01-18 09:48:03 +00:00
# Cleanup and state update
current_context = NAVIGATE
2026-01-18 09:48:03 +00:00
var destination := draggable.end_drag()
2026-01-18 16:32:31 +00:00
# Handle sticky note drops
if draggable is StickyNote:
var sticky := draggable as StickyNote
2026-01-18 18:16:56 +00:00
2026-01-18 16:32:31 +00:00
# If dropped on a card, attach it
if destination and destination is Card:
var target_card := destination as Card
2026-01-18 18:16:56 +00:00
2026-01-18 16:32:31 +00:00
if sticky.is_attached and sticky.attached_to != target_card:
sticky.attached_to.remove_note_if_present()
target_card.attach_or_exchange_note(sticky)
2026-01-18 18:16:56 +00:00
2026-01-18 16:32:31 +00:00
# If dropped on board (no destination), ensure it's a child of the board
elif not destination:
if sticky.is_attached:
reclaim_sticky(sticky)
2026-01-18 18:16:56 +00:00
2026-01-18 16:32:31 +00:00
# Check win condition after any sticky movement
2026-01-18 09:48:03 +00:00
check_board_completion()
2026-01-16 19:46:16 +00:00
func reclaim_sticky(note: StickyNote):
note.reparent(self)
note.tween = null
2025-01-31 02:22:07 +00:00
2025-12-15 22:13:40 +00:00
2026-01-18 09:48:03 +00:00
func check_board_completion():
2025-05-30 14:10:44 +00:00
if is_board_complete():
if not board_was_completed:
board_was_completed = true
board_completed.emit()
2025-05-30 14:10:44 +00:00
if board_was_completed:
give_lore_feedback()
func is_board_complete() -> bool:
2026-01-18 16:52:04 +00:00
return mementos_collected == 4 and notes.all(func (n : StickyNote): return n.is_attached)
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
2025-05-30 14:10:44 +00:00
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_note_attached():
fitting_card_count += int(child.card_id == child.get_attached_note().parent_id)
2025-05-30 14:10:44 +00:00
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
elif fitting_card_count < total_card_count:
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
2026-01-18 15:49:40 +00:00
func handle_hover(_draggable: Draggable) -> void:
# If we're hovering with the mouse without clicking, that updates our selection
2026-01-18 16:52:04 +00:00
if selection and selection.is_dragged: return
2026-01-18 15:49:40 +00:00
var candidate := _nearest_hovered(_sort_by_proximity_and_depth(notes))
if not candidate:
candidate = _nearest_hovered(_sort_by_proximity_and_depth(cards))
selection = candidate
2026-01-18 09:48:03 +00:00
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
2026-01-18 15:49:40 +00:00
item.z_index = depth if item.mouse_over else 0 # only care about the ones we are currently touching
2026-01-18 09:48:03 +00:00
return result
2025-12-15 22:13:40 +00:00
2026-01-18 09:48:03 +00:00
func _nearest_hovered(candidates: Array[Draggable]) -> Draggable:
for candidate in candidates:
if candidate.mouse_over: return candidate
return null
2025-12-15 22:13:40 +00:00
2026-01-18 09:48:03 +00:00
func _by_spatial(a: Draggable, b: Draggable) -> bool:
return a.position.x + a.position.y * 100 > b.position.x + b.position.y * 100
2025-12-15 22:13:40 +00:00
2026-01-18 09:48:03 +00:00
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
2026-01-18 09:48:03 +00:00
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:
2026-01-18 09:48:03 +00:00
cards.sort_custom(_by_spatial)
notes.sort_custom(_by_spatial)
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
return Draggable.DropResult.ACCEPTED
2023-07-02 13:10:33 +00:00
# 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()
2025-12-15 22:13:40 +00:00
2026-01-18 16:52:04 +00:00
if selection and not selection.is_dragged and event is InputEventMouseMotion and not event.is_action_pressed("mouse_left"):
2026-01-18 09:48:03 +00:00
var candidate := _nearest_hovered(_sort_by_proximity_and_depth(notes))
if not candidate:
candidate = _nearest_hovered(_sort_by_proximity_and_depth(cards))
selection = candidate
2025-02-24 15:14:08 +00:00
## Saves board state directly to SaveGame resource
func save_to_resource(savegame: SaveGame) -> void:
savegame.board_positions.clear()
savegame.board_attachments.clear()
2025-12-15 22:13:40 +00:00
print_debug("CardBoard: Saving board state...")
2026-01-18 18:16:56 +00:00
# Save all cards and their positions
for card in cards:
savegame.board_positions[card.name] = card.position
print_debug(" Card '%s' at %s" % [card.name, card.position])
2026-01-18 18:16:56 +00:00
# Save sticky note attachment if present
var note: StickyNote = card.get_attached_note()
2026-01-18 18:16:56 +00:00
if note:
savegame.board_attachments[note.name] = card.name
print_debug(" Sticky '%s' attached to card '%s'" % [note.name, card.name])
2026-01-18 18:16:56 +00:00
# Save loose sticky notes (not attached to cards)
for note in notes:
savegame.board_positions[note.name] = note.position
print_debug(" Loose sticky '%s' at %s" % [note.name, note.position])
2026-01-18 18:16:56 +00:00
print_debug("CardBoard: Saved %d positions, %d attachments" % [
savegame.board_positions.size(),
2026-01-18 18:16:56 +00:00
savegame.board_attachments.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...")
2026-01-18 18:16:56 +00:00
print_debug(" Positions: %d, Attachments: %d" % [
savegame.board_positions.size(),
2026-01-18 18:16:56 +00:00
savegame.board_attachments.size()
])
2026-01-18 18:16:56 +00:00
# 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)
2026-01-18 18:16:56 +00:00
# Sticky names from attachments (keys)
for sticky_name: StringName in savegame.board_attachments.keys():
2026-01-18 18:16:56 +00:00
if sticky_name not in all_names:
all_names.append(sticky_name)
2026-01-18 18:16:56 +00:00
# Card names from attachments (values)
for card_name: StringName in savegame.board_attachments.values():
2026-01-18 18:16:56 +00:00
if card_name not in all_names:
all_names.append(card_name)
print_debug(" Collected %d unique card/sticky names to load" % all_names.size())
2025-12-15 22:13:40 +00:00
2026-01-18 18:16:56 +00:00
# Create all cards and stickies
populate_board(all_names)
2026-01-18 18:16:56 +00:00
# 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):
2026-01-18 18:16:56 +00:00
card.position = savegame.board_positions[card.name]
print_debug(" Card '%s' at %s" % [card.name, card.position])
else:
2026-01-18 18:16:56 +00:00
card.position = _generate_random_position()
push_warning(" Card '%s' - no saved position, using random" % card.name)
2026-01-18 18:16:56 +00:00
# Attach sticky notes to cards or position them loose
for sticky: StickyNote in notes:
2026-01-18 18:16:56 +00:00
var card_name: StringName = savegame.board_attachments.get(sticky.name, &"")
if card_name and cards_by_name.has(card_name):
# Sticky is attached to a card
var card: Card = cards_by_name[card_name]
card.attach_or_exchange_note(sticky, true)
2026-01-18 18:16:56 +00:00
print_debug(" Sticky '%s' attached to card '%s'" % [sticky.name, card_name])
else:
2026-01-18 18:16:56 +00:00
# Sticky is loose on the board
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:
# Fallback to center of board
sticky.position = position + size / 2.0
push_warning(" Sticky '%s' - no saved position, using center" % sticky.name)
2025-12-15 22:13:40 +00:00
2026-01-18 18:16:56 +00:00
# Re-sort by positions for correct z-ordering
_sort_by_positions()
print_debug("CardBoard: Load complete!")
_check_completion()
2025-12-15 22:13:40 +00:00
# === 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")
2025-12-15 22:13:40 +00:00
# Otherwise default to sticky selection
selection_state = SelectionState.STICKIES
return notes as Array[Draggable]
2025-12-15 22:13:40 +00:00
# === 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()