534 lines
16 KiB
GDScript
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()
|