class_name MailPage extends Control signal focus_requested const GREETING_LABELS := { "dear": "Dear", "to": "To", "hey": "Hey", "greetings": "Greetings", } const SALUTATION_LABELS := { "love": "Love", "from": "From", "cheers": "Cheers", "salutations": "Salutations", "good_luck_have_fun": "Good Luck Have Fun", } var _service: NetworkMailService var _reservations: PlayerAssetReservationService var _inventory: FishInventory var _wallet: PlayerWallet var _bag: PlayerBag var _catalog: ItemCatalog var _inbox: Control var _compose: Control var _letter: Control var _inbox_list: VBoxContainer var _empty_label: Label var _recipient: OptionButton var _greeting: OptionButton var _body: TextEdit var _salutation: OptionButton var _signature: Label var _attachment_kind: OptionButton var _attachment_choice: OptionButton var _attachment_amount: SpinBox var _attachment_summary: Label var _send_button: Button var _status: Label var _letter_text: Label var _letter_gift: Label var _accept: Button var _decline: Button var _current_mail_id := "" func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) UtilityPageStyle.apply_page(self) _build_ui() func setup( service: NetworkMailService, reservations: PlayerAssetReservationService, inventory: FishInventory, wallet: PlayerWallet, bag: PlayerBag, catalog: ItemCatalog, ) -> void: _service = service _reservations = reservations _inventory = inventory _wallet = wallet _bag = bag _catalog = catalog _service.mailbox_changed.connect(_refresh_inbox) _service.peers_changed.connect(_refresh_recipients) _service.operation_finished.connect(_on_operation_finished) _refresh_inbox() _refresh_recipients() func activate() -> void: _show_inbox() UtilityPageStyle.animate_in(self) call_deferred("_focus_first") func deactivate() -> void: _current_mail_id = "" _show_inbox() func consume_escape() -> bool: if _compose.visible or _letter.visible: _show_inbox() return true return false func set_interactive(value: bool) -> void: mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE for node: Node in find_children("*", "BaseButton", true, false): (node as BaseButton).focus_mode = ( Control.FOCUS_ALL if value and node.is_visible_in_tree() else Control.FOCUS_NONE ) func _build_ui() -> void: var paper := PanelContainer.new() paper.position = Vector2(116, 126) paper.size = Vector2(1048, 470) paper.add_theme_stylebox_override("panel", UtilityPageStyle.panel_style()) add_child(paper) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 34) margin.add_theme_constant_override("margin_right", 34) margin.add_theme_constant_override("margin_top", 28) margin.add_theme_constant_override("margin_bottom", 28) paper.add_child(margin) var root := Control.new() margin.add_child(root) _inbox = _build_inbox() _compose = _build_compose() _letter = _build_letter() for page: Control in [_inbox, _compose, _letter]: page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.add_child(page) _status = Label.new() _status.position = Vector2(18, 430) _status.size = Vector2(900, 28) _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status.add_theme_color_override("font_color", Color("4a3f31")) root.add_child(_status) _style_controls(root) _show_inbox() func _build_inbox() -> Control: var page := Control.new() var title := Label.new() title.text = "Mail" title.position = Vector2(12, 0) title.size = Vector2(500, 42) title.add_theme_font_size_override("font_size", 30) page.add_child(title) var send := Button.new() send.text = "send mail" send.position = Vector2(820, 0) send.size = Vector2(150, 48) send.pressed.connect(_show_compose) page.add_child(send) var scroll := ScrollContainer.new() scroll.position = Vector2(12, 62) scroll.size = Vector2(958, 342) page.add_child(scroll) _inbox_list = VBoxContainer.new() _inbox_list.custom_minimum_size = Vector2(930, 0) _inbox_list.add_theme_constant_override("separation", 8) scroll.add_child(_inbox_list) _empty_label = Label.new() _empty_label.text = "No letters yet." _empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _empty_label.custom_minimum_size = Vector2(930, 80) _inbox_list.add_child(_empty_label) return page func _build_compose() -> Control: var page := Control.new() var title := Label.new() title.text = "write a letter" title.position = Vector2(12, 0) title.size = Vector2(300, 36) title.add_theme_font_size_override("font_size", 26) page.add_child(title) _greeting = OptionButton.new() _greeting.position = Vector2(12, 48) _greeting.size = Vector2(180, 46) for id: String in NetworkMailProtocol.GREETINGS: _greeting.add_item(GREETING_LABELS[id]) _greeting.set_item_metadata(_greeting.item_count - 1, id) page.add_child(_greeting) _recipient = OptionButton.new() _recipient.position = Vector2(204, 48) _recipient.size = Vector2(330, 46) page.add_child(_recipient) _body = TextEdit.new() _body.position = Vector2(12, 106) _body.size = Vector2(650, 218) _body.placeholder_text = "write your letter…" _body.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY _body.text_changed.connect(_update_send_state) page.add_child(_body) _salutation = OptionButton.new() _salutation.position = Vector2(12, 336) _salutation.size = Vector2(250, 46) for id: String in NetworkMailProtocol.SALUTATIONS: _salutation.add_item(SALUTATION_LABELS[id]) _salutation.set_item_metadata(_salutation.item_count - 1, id) page.add_child(_salutation) _signature = Label.new() _signature.position = Vector2(274, 342) _signature.size = Vector2(360, 36) page.add_child(_signature) _attachment_kind = OptionButton.new() _attachment_kind.position = Vector2(688, 48) _attachment_kind.size = Vector2(280, 46) for label: String in ["No gift", "Fish coin", "Fish", "Consumable"]: _attachment_kind.add_item(label) _attachment_kind.item_selected.connect(_refresh_attachment_choices) page.add_child(_attachment_kind) _attachment_choice = OptionButton.new() _attachment_choice.position = Vector2(688, 106) _attachment_choice.size = Vector2(280, 46) _attachment_choice.item_selected.connect( func(_index: int) -> void: _update_attachment_amount_limit() _update_attachment_summary() ) page.add_child(_attachment_choice) _attachment_amount = SpinBox.new() _attachment_amount.position = Vector2(688, 164) _attachment_amount.size = Vector2(180, 46) _attachment_amount.min_value = 1 _attachment_amount.max_value = 999 _attachment_amount.value = 1 _attachment_amount.value_changed.connect( func(_value: float) -> void: _update_attachment_summary() ) page.add_child(_attachment_amount) _attachment_summary = Label.new() _attachment_summary.position = Vector2(688, 224) _attachment_summary.size = Vector2(280, 100) _attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART page.add_child(_attachment_summary) var cancel := Button.new() cancel.text = "cancel" cancel.position = Vector2(688, 342) cancel.size = Vector2(126, 48) cancel.pressed.connect(_show_inbox) page.add_child(cancel) _send_button = Button.new() _send_button.text = "send letter" _send_button.position = Vector2(826, 342) _send_button.size = Vector2(142, 48) _send_button.pressed.connect(_send) page.add_child(_send_button) _recipient.item_selected.connect(func(_i: int) -> void: _update_send_state()) _refresh_attachment_choices(0) return page func _build_letter() -> Control: var page := Control.new() _letter_text = Label.new() _letter_text.position = Vector2(26, 20) _letter_text.size = Vector2(680, 350) _letter_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _letter_text.vertical_alignment = VERTICAL_ALIGNMENT_TOP page.add_child(_letter_text) _letter_gift = Label.new() _letter_gift.position = Vector2(730, 46) _letter_gift.size = Vector2(230, 180) _letter_gift.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART page.add_child(_letter_gift) _accept = Button.new() _accept.text = "accept gift" _accept.position = Vector2(730, 250) _accept.size = Vector2(230, 48) _accept.pressed.connect(func() -> void: _service.accept_gift(_current_mail_id) ) page.add_child(_accept) _decline = Button.new() _decline.text = "decline gift" _decline.position = Vector2(730, 308) _decline.size = Vector2(230, 48) _decline.pressed.connect(func() -> void: _service.decline_gift(_current_mail_id) ) page.add_child(_decline) var close := Button.new() close.text = "close" close.position = Vector2(26, 370) close.size = Vector2(150, 48) close.pressed.connect(_show_inbox) page.add_child(close) return page func _show_inbox() -> void: _inbox.show() _compose.hide() _letter.hide() _current_mail_id = "" _refresh_inbox() func _show_compose() -> void: _inbox.hide() _compose.show() _letter.hide() _body.clear() _signature.text = _service.get_local_display_name() _status.text = "" _refresh_recipients() _refresh_attachment_choices(_attachment_kind.selected) _update_send_state() _greeting.grab_focus() func _open_letter(mail_id: String) -> void: var letter := _service.get_letter(mail_id) if letter.is_empty(): return _current_mail_id = mail_id _service.mark_read(mail_id) _inbox.hide() _compose.hide() _letter.show() _letter_text.text = "%s %s,\n\n%s\n\n%s,\n%s" % [ GREETING_LABELS.get(letter["greeting_id"], "Dear"), letter.get("recipient_display_name", "you"), letter["body"], SALUTATION_LABELS.get(letter["salutation_id"], "From"), letter["sender_display_name"], ] _letter_gift.text = _attachment_state_text(letter) var pending := ( int(letter["attachment"].get("type", 0)) != 0 and int(letter["state"]) in [ NetworkMailProtocol.State.SENT_UNREAD, NetworkMailProtocol.State.READ, ] and _service.is_local_recipient(letter) ) _accept.visible = pending _decline.visible = pending if pending: _accept.grab_focus() func _refresh_inbox() -> void: if _service == null or _inbox_list == null: return for child: Node in _inbox_list.get_children(): child.queue_free() var letters := _service.get_local_letters() if letters.is_empty(): var empty := Label.new() empty.text = "No letters yet." empty.custom_minimum_size = Vector2(930, 80) empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _inbox_list.add_child(empty) return for letter: Dictionary in letters: var button := Button.new() var first_line: String = str(letter["body"]).split("\n", false)[0] var unread := int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD var gift := int(letter["attachment"].get("type", 0)) != 0 button.text = "%s%s — %s%s" % [ "new · " if unread else "", letter["sender_display_name"], first_line.left(72), " · gift enclosed" if gift else "", ] button.custom_minimum_size = Vector2(930, 54) button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) UtilityPageStyle.apply_button(button) _inbox_list.add_child(button) func _refresh_recipients() -> void: if _service == null or _recipient == null: return var selected_peer := ( int(_recipient.get_selected_metadata()) if _recipient.selected >= 0 and _recipient.get_selected_metadata() != null else 0 ) _recipient.clear() _recipient.add_item("select a player") _recipient.set_item_metadata(0, 0) var retained := false for choice: Dictionary in _service.get_recipient_choices(): _recipient.add_item(choice["label"]) _recipient.set_item_metadata( _recipient.item_count - 1, choice["peer_id"] ) if int(choice["peer_id"]) == selected_peer: _recipient.select(_recipient.item_count - 1) retained = true if selected_peer > 0 and not retained: _recipient.select(0) if _status != null: _status.text = "That player is no longer connected." _update_send_state() func _refresh_attachment_choices(_index: int) -> void: if _attachment_choice == null: return _attachment_choice.clear() _attachment_amount.visible = _attachment_kind.selected in [1, 3] match _attachment_kind.selected: 0: _attachment_choice.add_item("No attachment") 1: _attachment_choice.add_item( "available: %d fish coin" % _reservations.get_available_fish_coin() ) _attachment_amount.max_value = maxi( _reservations.get_available_fish_coin(), 1 ) 2: for fish_catch: FishCatch in _inventory.get_all_catches(): if ( not fish_catch.is_favorited and not _reservations.is_fish_reserved(fish_catch.catch_id) ): _attachment_choice.add_item( "%s — %.1f lb" % [ fish_catch.fish.display_name, fish_catch.weight_lb, ] ) _attachment_choice.set_item_metadata( _attachment_choice.item_count - 1, str(fish_catch.catch_id) ) 3: for item: ItemData in _catalog.get_valid_items(): var available := _reservations.get_available_item_quantity( item.item_id ) if item.category == ItemData.Category.CONSUMABLE and available > 0: _attachment_choice.add_item( "%s ×%d" % [item.display_name, available] ) _attachment_choice.set_item_metadata( _attachment_choice.item_count - 1, str(item.item_id) ) _update_attachment_amount_limit() _update_attachment_summary() func _update_attachment_amount_limit() -> void: if _attachment_kind.selected == 3 and _attachment_choice.selected >= 0: var item_id := StringName(str(_attachment_choice.get_selected_metadata())) _attachment_amount.max_value = maxi( _reservations.get_available_item_quantity(item_id), 1 ) func _make_attachment() -> Dictionary: match _attachment_kind.selected: 1: return { "type": PlayerAssetReservationService.AttachmentType.FISH_COIN, "amount": int(_attachment_amount.value), } 2: if _attachment_choice.selected < 0: return {} var catch_id := StringName(str(_attachment_choice.get_selected_metadata())) var fish_catch := _inventory.get_catch_by_id(catch_id) if fish_catch == null: return {} return { "type": PlayerAssetReservationService.AttachmentType.FISH, "catch_id": str(catch_id), "catch": fish_catch.to_network_dict(), } 3: if _attachment_choice.selected < 0: return {} return { "type": PlayerAssetReservationService.AttachmentType.CONSUMABLE, "item_id": str(_attachment_choice.get_selected_metadata()), "quantity": int(_attachment_amount.value), } return {"type": PlayerAssetReservationService.AttachmentType.NONE} func _update_attachment_summary() -> void: if _attachment_summary == null: return var attachment := _make_attachment() _attachment_summary.text = _attachment_text(attachment) _update_send_state() func _update_send_state() -> void: if _send_button == null: return var recipient_id := ( int(_recipient.get_selected_metadata()) if _recipient.selected >= 0 and _recipient.get_selected_metadata() != null else 0 ) var attachment := _make_attachment() _send_button.disabled = ( recipient_id <= 0 or NetworkMailProtocol.sanitize_body(_body.text).is_empty() or attachment.is_empty() or not _reservations.validate_attachment(attachment) or not _attachment_is_available(attachment) ) func _attachment_is_available(attachment: Dictionary) -> bool: match int(attachment.get("type", 0)): PlayerAssetReservationService.AttachmentType.NONE: return true PlayerAssetReservationService.AttachmentType.FISH_COIN: return ( int(attachment["amount"]) <= _reservations.get_available_fish_coin() ) PlayerAssetReservationService.AttachmentType.FISH: return not _reservations.is_fish_reserved( StringName(str(attachment["catch_id"])) ) PlayerAssetReservationService.AttachmentType.CONSUMABLE: return ( int(attachment["quantity"]) <= _reservations.get_available_item_quantity( StringName(str(attachment["item_id"])) ) ) return false func _send() -> void: var recipient_id := int(_recipient.get_selected_metadata()) var greeting_id := str(_greeting.get_selected_metadata()) var salutation_id := str(_salutation.get_selected_metadata()) if _service.send_letter( recipient_id, greeting_id, _body.text, salutation_id, _make_attachment() ): _send_button.disabled = true _status.text = "Sending letter…" func _on_operation_finished(success: bool, message: String) -> void: _status.text = message if success and _compose.visible: _show_inbox() elif _letter.visible and not _current_mail_id.is_empty(): _open_letter(_current_mail_id) else: _update_send_state() func _attachment_state_text(letter: Dictionary) -> String: var attachment: Dictionary = letter["attachment"] if int(attachment.get("type", 0)) == 0: return "" match int(letter["state"]): NetworkMailProtocol.State.ACCEPTED: return "Gift accepted.\n\n%s" % _attachment_text(attachment) NetworkMailProtocol.State.DECLINED: return "Gift declined." NetworkMailProtocol.State.ATTACHMENT_RECALLED, NetworkMailProtocol.State.CANCELLED: return "Gift returned to sender." NetworkMailProtocol.State.ACCEPTANCE_PENDING: return "Transferring gift…" return "Gift enclosed\n\n%s" % _attachment_text(attachment) func _attachment_text(attachment: Dictionary) -> String: match int(attachment.get("type", 0)): PlayerAssetReservationService.AttachmentType.NONE: return "No gift attached." PlayerAssetReservationService.AttachmentType.FISH_COIN: return "%d fish coin" % int(attachment.get("amount", 0)) PlayerAssetReservationService.AttachmentType.FISH: var fish_catch := _inventory.get_catch_by_id( StringName(str(attachment.get("catch_id", ""))) ) if fish_catch != null: return "%s — %.1f lb" % [ fish_catch.fish.display_name, fish_catch.weight_lb, ] var data: Dictionary = attachment.get("catch", {}) return "%s — %.1f lb" % [ str(data.get("fish_id", "fish")).capitalize(), float(data.get("weight_lb", 0.0)), ] PlayerAssetReservationService.AttachmentType.CONSUMABLE: var item := _catalog.get_item_by_id( StringName(str(attachment.get("item_id", ""))) ) return "%s ×%d" % [ item.display_name if item != null else "Consumable", int(attachment.get("quantity", 0)), ] return "Invalid gift." func _focus_first() -> void: var buttons := _inbox_list.find_children("*", "Button", true, false) if not buttons.is_empty(): (buttons.front() as Button).grab_focus() func _style_controls(root: Node) -> void: for node: Node in root.find_children("*", "", true, false): if node is BaseButton: UtilityPageStyle.apply_button(node) elif node is LineEdit: UtilityPageStyle.apply_line_edit(node) elif node is Label: node.add_theme_color_override( "font_color", UtilityPageStyle.INK ) elif node is TextEdit: node.add_theme_font_override("font", UtilityPageStyle.TuffyFont) node.add_theme_color_override( "font_color", UtilityPageStyle.LIGHT_TEXT ) node.add_theme_color_override( "font_placeholder_color", Color(0.82, 0.80, 0.71, 0.84) )