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

534 lines
16 KiB
GDScript

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)
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
## 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()