Add session mail and player gifts
This commit is contained in:
parent
5dda74c9e4
commit
e53ad1ac38
19 changed files with 1929 additions and 22 deletions
38
main/main.gd
38
main/main.gd
|
|
@ -58,6 +58,12 @@ const NetworkItemUseServiceType = preload(
|
|||
const NetworkChatServiceType = preload(
|
||||
"res://network/network_chat_service.gd"
|
||||
)
|
||||
const NetworkMailServiceType = preload(
|
||||
"res://network/network_mail_service.gd"
|
||||
)
|
||||
const PlayerAssetReservationServiceType = preload(
|
||||
"res://progression/player_asset_reservation_service.gd"
|
||||
)
|
||||
|
||||
const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
||||
|
||||
|
|
@ -101,6 +107,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
|||
%NetworkItemUseService
|
||||
)
|
||||
@onready var _network_chat: NetworkChatServiceType = %NetworkChatService
|
||||
@onready var _network_mail: NetworkMailServiceType = %NetworkMailService
|
||||
@onready var _asset_reservations: PlayerAssetReservationServiceType = (
|
||||
%PlayerAssetReservationService
|
||||
)
|
||||
@onready var _players_root: Node3D = $Players
|
||||
|
||||
var _gameplay_started: bool = false
|
||||
|
|
@ -160,13 +170,29 @@ func _ready() -> void:
|
|||
_player.cooler_capacity
|
||||
)
|
||||
_save_manager.set_autosave_enabled(false)
|
||||
_asset_reservations.setup(
|
||||
_player.wallet, _player.inventory, _player.bag, item_catalog
|
||||
)
|
||||
_network_mail.setup(
|
||||
_network_session,
|
||||
_asset_reservations,
|
||||
_player.wallet,
|
||||
_player.inventory,
|
||||
_player.bag,
|
||||
_player.collection_log,
|
||||
_player.cooler_capacity,
|
||||
item_catalog,
|
||||
fish_catalog,
|
||||
_save_manager
|
||||
)
|
||||
_network_item_use.setup(
|
||||
_network_session,
|
||||
_player_spawn_service,
|
||||
item_catalog,
|
||||
_player.bag,
|
||||
_player.item_effects,
|
||||
_save_manager
|
||||
_save_manager,
|
||||
_asset_reservations
|
||||
)
|
||||
_network_chat.setup(_network_session)
|
||||
_network_fishing.setup(
|
||||
|
|
@ -191,7 +217,8 @@ func _ready() -> void:
|
|||
_save_manager,
|
||||
fish_catalog,
|
||||
pelican_buyer_profile,
|
||||
_test_world.get_pelican_convenience_landmark()
|
||||
_test_world.get_pelican_convenience_landmark(),
|
||||
_asset_reservations
|
||||
)
|
||||
_network_shop.setup(
|
||||
_network_session,
|
||||
|
|
@ -203,7 +230,8 @@ func _ready() -> void:
|
|||
item_catalog,
|
||||
_player.fishing_upgrades,
|
||||
_player.cooler_capacity,
|
||||
_save_manager
|
||||
_save_manager,
|
||||
_asset_reservations
|
||||
)
|
||||
_fishing_spot.setup(
|
||||
_player,
|
||||
|
|
@ -241,7 +269,9 @@ func _ready() -> void:
|
|||
_network_shop,
|
||||
_network_chat,
|
||||
_network_profile,
|
||||
_player_spawn_service
|
||||
_player_spawn_service,
|
||||
_network_mail,
|
||||
_asset_reservations
|
||||
)
|
||||
_water_recovery.setup(
|
||||
_player,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=26 format=3]
|
||||
[gd_scene load_steps=28 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
|
||||
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
[ext_resource type="Script" path="res://network/network_shop_service.gd" id="23_network_shop"]
|
||||
[ext_resource type="Script" path="res://network/network_item_use_service.gd" id="24_network_item"]
|
||||
[ext_resource type="Script" path="res://network/network_chat_service.gd" id="25_network_chat"]
|
||||
[ext_resource type="Script" path="res://network/network_mail_service.gd" id="26_network_mail"]
|
||||
[ext_resource type="Script" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"]
|
||||
|
||||
[node name="Main" type="Node3D"]
|
||||
script = ExtResource("3_main")
|
||||
|
|
@ -69,6 +71,14 @@ script = ExtResource("24_network_item")
|
|||
unique_name_in_owner = true
|
||||
script = ExtResource("25_network_chat")
|
||||
|
||||
[node name="PlayerAssetReservationService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("27_reservations")
|
||||
|
||||
[node name="NetworkMailService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("26_network_mail")
|
||||
|
||||
[node name="TestWorld" parent="." instance=ExtResource("1_world")]
|
||||
|
||||
[node name="Players" type="Node3D" parent="."]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ var _received_results: Dictionary[String, bool] = {}
|
|||
var _pending_local: Dictionary = {}
|
||||
var _equipped_states: Dictionary[int, Dictionary] = {}
|
||||
var _local_equipped_revision: int = 0
|
||||
var _reservations: PlayerAssetReservationService
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -29,6 +30,7 @@ func setup(
|
|||
local_bag: PlayerBag,
|
||||
local_effects: PlayerItemEffects,
|
||||
save_manager: PlayerSaveManager,
|
||||
reservations: PlayerAssetReservationService,
|
||||
) -> void:
|
||||
_session = session
|
||||
_spawn_service = spawn_service
|
||||
|
|
@ -36,6 +38,7 @@ func setup(
|
|||
_local_bag = local_bag
|
||||
_local_effects = local_effects
|
||||
_save_manager = save_manager
|
||||
_reservations = reservations
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.state_changed.connect(_on_session_state_changed)
|
||||
_session.peer_authenticated.connect(_on_peer_authenticated)
|
||||
|
|
@ -45,6 +48,12 @@ func request_use(item_id: StringName) -> String:
|
|||
if not _pending_local.is_empty():
|
||||
local_item_use_finished.emit(false, "An item use is already pending.")
|
||||
return ""
|
||||
if (
|
||||
_reservations != null
|
||||
and _reservations.get_available_item_quantity(item_id) < 1
|
||||
):
|
||||
local_item_use_finished.emit(false, "Reserved in a letter.")
|
||||
return ""
|
||||
if (
|
||||
_session == null
|
||||
or not _session.is_gameplay_session_active()
|
||||
|
|
@ -169,6 +178,14 @@ func _apply_result(data: Dictionary) -> void:
|
|||
_acknowledge(data, false, str(data["message"]))
|
||||
return
|
||||
var item_id := StringName(str(data["item_id"]))
|
||||
if (
|
||||
_reservations != null
|
||||
and _reservations.get_available_item_quantity(item_id) < 1
|
||||
):
|
||||
_pending_local.clear()
|
||||
local_item_use_finished.emit(false, "Reserved in a letter.")
|
||||
_acknowledge(data, false, "Reserved in a letter.")
|
||||
return
|
||||
var bag_snapshot := _local_bag.get_all_items()
|
||||
if (
|
||||
_local_bag.get_quantity(item_id) < 1
|
||||
|
|
|
|||
97
network/network_mail_protocol.gd
Normal file
97
network/network_mail_protocol.gd
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
class_name NetworkMailProtocol
|
||||
extends RefCounted
|
||||
|
||||
const CAPABILITY: StringName = &"mail_v1"
|
||||
const RELIABLE_CHANNEL: int = NetworkProtocol.MAIL_RELIABLE_CHANNEL
|
||||
const MAX_ID_LENGTH: int = 96
|
||||
const MAX_BODY_CHARACTERS: int = 2000
|
||||
const MAX_BODY_BYTES: int = 8000
|
||||
const MAX_RECIPIENT_LETTERS: int = 100
|
||||
const MAX_SESSION_LETTERS: int = 200
|
||||
|
||||
const GREETINGS: PackedStringArray = ["dear", "to", "hey", "greetings"]
|
||||
const SALUTATIONS: PackedStringArray = [
|
||||
"love", "from", "cheers", "salutations", "good_luck_have_fun",
|
||||
]
|
||||
|
||||
enum State {
|
||||
SENT_UNREAD,
|
||||
READ,
|
||||
ACCEPTANCE_PENDING,
|
||||
ACCEPTED,
|
||||
DECLINED,
|
||||
ATTACHMENT_RECALLED,
|
||||
CANCELLED,
|
||||
SESSION_ENDED,
|
||||
}
|
||||
|
||||
|
||||
static func sanitize_body(value: Variant) -> String:
|
||||
if typeof(value) != TYPE_STRING:
|
||||
return ""
|
||||
var body := str(value).strip_edges(false, true)
|
||||
if (
|
||||
body.strip_edges().is_empty()
|
||||
or body.length() > MAX_BODY_CHARACTERS
|
||||
or body.to_utf8_buffer().size() > MAX_BODY_BYTES
|
||||
):
|
||||
return ""
|
||||
for index: int in body.length():
|
||||
var codepoint := body.unicode_at(index)
|
||||
if codepoint < 32 and codepoint not in [9, 10, 13]:
|
||||
return ""
|
||||
if codepoint == 127:
|
||||
return ""
|
||||
return body.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
|
||||
|
||||
|
||||
static func valid_id(value: Variant) -> bool:
|
||||
return (
|
||||
typeof(value) == TYPE_STRING
|
||||
and not str(value).is_empty()
|
||||
and str(value).length() <= MAX_ID_LENGTH
|
||||
)
|
||||
|
||||
|
||||
static func validate_send_request(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
valid_id(value.get("request_id"))
|
||||
and valid_id(value.get("session_id"))
|
||||
and typeof(value.get("recipient_peer_id")) == TYPE_INT
|
||||
and int(value["recipient_peer_id"]) > 0
|
||||
and typeof(value.get("greeting_id")) == TYPE_STRING
|
||||
and str(value["greeting_id"]) in GREETINGS
|
||||
and not sanitize_body(value.get("body")).is_empty()
|
||||
and typeof(value.get("salutation_id")) == TYPE_STRING
|
||||
and str(value["salutation_id"]) in SALUTATIONS
|
||||
and typeof(value.get("reservation_id")) == TYPE_STRING
|
||||
and str(value["reservation_id"]).length() <= MAX_ID_LENGTH
|
||||
and typeof(value.get("attachment")) == TYPE_DICTIONARY
|
||||
and Dictionary(value["attachment"]).size() <= 3
|
||||
)
|
||||
|
||||
|
||||
static func validate_mail(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
valid_id(value.get("mail_id"))
|
||||
and valid_id(value.get("session_id"))
|
||||
and typeof(value.get("sequence")) == TYPE_INT
|
||||
and typeof(value.get("sender_peer_id")) == TYPE_INT
|
||||
and typeof(value.get("recipient_peer_id")) == TYPE_INT
|
||||
and typeof(value.get("sender_display_name")) == TYPE_STRING
|
||||
and str(value["sender_display_name"]).length() <= 24
|
||||
and str(value.get("greeting_id", "")) in GREETINGS
|
||||
and not sanitize_body(value.get("body")).is_empty()
|
||||
and str(value.get("salutation_id", "")) in SALUTATIONS
|
||||
and typeof(value.get("attachment")) == TYPE_DICTIONARY
|
||||
and Dictionary(value["attachment"]).size() <= 3
|
||||
and typeof(value.get("state")) == TYPE_INT
|
||||
and int(value["state"]) >= 0
|
||||
and int(value["state"]) < State.size()
|
||||
)
|
||||
1
network/network_mail_protocol.gd.uid
Normal file
1
network/network_mail_protocol.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://5ttl0pytgtf7
|
||||
802
network/network_mail_service.gd
Normal file
802
network/network_mail_service.gd
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
class_name NetworkMailService
|
||||
extends Node
|
||||
|
||||
signal mailbox_changed
|
||||
signal unread_count_changed(count: int)
|
||||
signal operation_finished(success: bool, message: String)
|
||||
signal peers_changed
|
||||
|
||||
const MAX_LEDGER: int = 256
|
||||
|
||||
var _session: NetworkSession
|
||||
var _reservations: PlayerAssetReservationService
|
||||
var _wallet: PlayerWallet
|
||||
var _inventory: FishInventory
|
||||
var _bag: PlayerBag
|
||||
var _collection_log: CollectionLog
|
||||
var _cooler_capacity: PlayerCoolerCapacity
|
||||
var _item_catalog: ItemCatalog
|
||||
var _fish_catalog: FishPool
|
||||
var _save_manager: PlayerSaveManager
|
||||
|
||||
var _letters: Dictionary[String, Dictionary] = {}
|
||||
var _local_letters: Dictionary[String, Dictionary] = {}
|
||||
var _send_ledger: Dictionary[int, Dictionary] = {}
|
||||
var _action_ledger: Dictionary[String, bool] = {}
|
||||
var _sequence := 0
|
||||
var _pending_local_send: Dictionary = {}
|
||||
var _pending_transfers: Dictionary[String, Dictionary] = {}
|
||||
var _local_removal_snapshots: Dictionary[String, Dictionary] = {}
|
||||
var _received_awards: Dictionary[String, bool] = {}
|
||||
|
||||
|
||||
func setup(
|
||||
session: NetworkSession,
|
||||
reservations: PlayerAssetReservationService,
|
||||
wallet: PlayerWallet,
|
||||
inventory: FishInventory,
|
||||
bag: PlayerBag,
|
||||
collection_log: CollectionLog,
|
||||
cooler_capacity: PlayerCoolerCapacity,
|
||||
item_catalog: ItemCatalog,
|
||||
fish_catalog: FishPool,
|
||||
save_manager: PlayerSaveManager,
|
||||
) -> void:
|
||||
_session = session
|
||||
_reservations = reservations
|
||||
_wallet = wallet
|
||||
_inventory = inventory
|
||||
_bag = bag
|
||||
_collection_log = collection_log
|
||||
_cooler_capacity = cooler_capacity
|
||||
_item_catalog = item_catalog
|
||||
_fish_catalog = fish_catalog
|
||||
_save_manager = save_manager
|
||||
_session.peer_authenticated.connect(func(_id: int, _name: String) -> void:
|
||||
peers_changed.emit()
|
||||
)
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.peer_display_name_changed.connect(
|
||||
func(_id: int, _name: String) -> void: peers_changed.emit()
|
||||
)
|
||||
_session.state_changed.connect(_on_session_state_changed)
|
||||
|
||||
|
||||
func get_local_letters() -> Array[Dictionary]:
|
||||
var values: Array[Dictionary] = []
|
||||
for letter: Dictionary in _local_letters.values():
|
||||
if int(letter["recipient_peer_id"]) == _session.get_local_peer_id():
|
||||
values.append(letter.duplicate(true))
|
||||
values.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var a_unread := int(a["state"]) == NetworkMailProtocol.State.SENT_UNREAD
|
||||
var b_unread := int(b["state"]) == NetworkMailProtocol.State.SENT_UNREAD
|
||||
return a_unread != b_unread if a_unread != b_unread else (
|
||||
int(a["sequence"]) > int(b["sequence"])
|
||||
)
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
func get_letter(mail_id: String) -> Dictionary:
|
||||
return _local_letters.get(mail_id, {}).duplicate(true)
|
||||
|
||||
|
||||
func get_unread_count() -> int:
|
||||
var count := 0
|
||||
for letter: Dictionary in _local_letters.values():
|
||||
if (
|
||||
int(letter["recipient_peer_id"]) == _session.get_local_peer_id()
|
||||
and int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func is_local_recipient(letter: Dictionary) -> bool:
|
||||
return (
|
||||
int(letter.get("recipient_peer_id", 0))
|
||||
== _session.get_local_peer_id()
|
||||
)
|
||||
|
||||
|
||||
func get_local_display_name() -> String:
|
||||
var record := _session.get_peer_record(_session.get_local_peer_id())
|
||||
return record.display_name if record != null else "Player"
|
||||
|
||||
|
||||
func get_recipient_choices() -> Array[Dictionary]:
|
||||
var choices: Array[Dictionary] = []
|
||||
var local_id := _session.get_local_peer_id()
|
||||
for peer_id: int in _session.get_authenticated_peer_ids():
|
||||
if peer_id == local_id:
|
||||
continue
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if record != null:
|
||||
choices.append({"peer_id": peer_id, "name": record.display_name})
|
||||
choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0
|
||||
)
|
||||
var seen: Dictionary[String, int] = {}
|
||||
for choice: Dictionary in choices:
|
||||
var name: String = choice["name"]
|
||||
seen[name] = int(seen.get(name, 0)) + 1
|
||||
if seen[name] > 1:
|
||||
choice["label"] = "%s (%d)" % [name, seen[name]]
|
||||
else:
|
||||
choice["label"] = name
|
||||
return choices
|
||||
|
||||
|
||||
func send_letter(
|
||||
recipient_peer_id: int,
|
||||
greeting_id: String,
|
||||
body: String,
|
||||
salutation_id: String,
|
||||
attachment: Dictionary,
|
||||
) -> bool:
|
||||
if (
|
||||
not _pending_local_send.is_empty()
|
||||
or not _session.is_gameplay_session_active()
|
||||
or recipient_peer_id == _session.get_local_peer_id()
|
||||
or not _session.is_authenticated_peer(recipient_peer_id)
|
||||
or (
|
||||
not _session.is_host()
|
||||
and not _session.supports_server_capability(
|
||||
NetworkMailProtocol.CAPABILITY
|
||||
)
|
||||
)
|
||||
):
|
||||
operation_finished.emit(false, "That player is no longer connected.")
|
||||
return false
|
||||
var request_id := _new_id("mail_request")
|
||||
var reservation_id := ""
|
||||
if int(attachment.get("type", 0)) != 0:
|
||||
reservation_id = _new_id("reservation")
|
||||
if not _reservations.reserve(reservation_id, attachment):
|
||||
operation_finished.emit(false, "That gift is not available.")
|
||||
return false
|
||||
var request := {
|
||||
"request_id": request_id,
|
||||
"session_id": _session.get_session_id(),
|
||||
"recipient_peer_id": recipient_peer_id,
|
||||
"greeting_id": greeting_id,
|
||||
"body": NetworkMailProtocol.sanitize_body(body),
|
||||
"salutation_id": salutation_id,
|
||||
"reservation_id": reservation_id,
|
||||
"attachment": attachment.duplicate(true),
|
||||
}
|
||||
if not NetworkMailProtocol.validate_send_request(request):
|
||||
if not reservation_id.is_empty():
|
||||
_reservations.release(reservation_id)
|
||||
operation_finished.emit(false, "Letter could not be sent.")
|
||||
return false
|
||||
_pending_local_send = request.duplicate(true)
|
||||
if _session.is_host():
|
||||
_handle_send(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
submit_mail.rpc_id(1, request)
|
||||
return true
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func submit_mail(data: Dictionary) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_send(sender, data)
|
||||
|
||||
|
||||
func _handle_send(sender: int, data: Dictionary) -> void:
|
||||
var request_id := str(data.get("request_id", ""))
|
||||
var ledger: Dictionary = _send_ledger.get(sender, {})
|
||||
if ledger.has(request_id):
|
||||
_deliver_send_result(sender, ledger[request_id])
|
||||
return
|
||||
var error := ""
|
||||
if (
|
||||
not NetworkMailProtocol.validate_send_request(data)
|
||||
or str(data.get("session_id", "")) != _session.get_session_id()
|
||||
):
|
||||
error = "Letter could not be sent."
|
||||
var recipient := int(data.get("recipient_peer_id", 0))
|
||||
if error.is_empty() and (
|
||||
recipient == sender or not _session.is_authenticated_peer(recipient)
|
||||
):
|
||||
error = "That player is no longer connected."
|
||||
if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS:
|
||||
error = "The session mailbox is full."
|
||||
var recipient_count := 0
|
||||
for letter: Dictionary in _letters.values():
|
||||
if int(letter["recipient_peer_id"]) == recipient:
|
||||
recipient_count += 1
|
||||
if (
|
||||
error.is_empty()
|
||||
and recipient_count >= NetworkMailProtocol.MAX_RECIPIENT_LETTERS
|
||||
):
|
||||
error = "That inbox is full."
|
||||
var attachment: Dictionary = data.get("attachment", {})
|
||||
if error.is_empty() and not _validate_attachment_structure(attachment):
|
||||
error = "That gift is not valid."
|
||||
var result := {
|
||||
"request_id": request_id if not request_id.is_empty() else "invalid",
|
||||
"accepted": error.is_empty(),
|
||||
"message": "Letter sent." if error.is_empty() else error,
|
||||
"reservation_id": str(data.get("reservation_id", "")),
|
||||
}
|
||||
if error.is_empty():
|
||||
_sequence += 1
|
||||
var record := _session.get_peer_record(sender)
|
||||
var recipient_record := _session.get_peer_record(recipient)
|
||||
var letter := {
|
||||
"mail_id": _new_id("mail"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"sequence": _sequence,
|
||||
"sender_peer_id": sender,
|
||||
"sender_display_name": record.display_name,
|
||||
"recipient_peer_id": recipient,
|
||||
"recipient_display_name": recipient_record.display_name,
|
||||
"greeting_id": str(data["greeting_id"]),
|
||||
"body": NetworkMailProtocol.sanitize_body(data["body"]),
|
||||
"salutation_id": str(data["salutation_id"]),
|
||||
"reservation_id": str(data["reservation_id"]),
|
||||
"attachment": attachment.duplicate(true),
|
||||
"state": NetworkMailProtocol.State.SENT_UNREAD,
|
||||
}
|
||||
_letters[letter["mail_id"]] = letter
|
||||
result["mail"] = letter
|
||||
_deliver_private_letter(sender, letter)
|
||||
_deliver_private_letter(recipient, letter)
|
||||
ledger[result["request_id"]] = result.duplicate(true)
|
||||
_bound(ledger)
|
||||
_send_ledger[sender] = ledger
|
||||
_deliver_send_result(sender, result)
|
||||
|
||||
|
||||
func _deliver_private_letter(peer_id: int, letter: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_receive_letter(letter)
|
||||
elif _session.is_authenticated_peer(peer_id):
|
||||
receive_private_letter.rpc_id(peer_id, letter)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func receive_private_letter(letter: Dictionary) -> void:
|
||||
_receive_letter(letter)
|
||||
|
||||
|
||||
func _receive_letter(letter: Dictionary) -> void:
|
||||
if (
|
||||
not NetworkMailProtocol.validate_mail(letter)
|
||||
or str(letter["session_id"]) != _session.get_session_id()
|
||||
or _session.get_local_peer_id() not in [
|
||||
int(letter["sender_peer_id"]), int(letter["recipient_peer_id"]),
|
||||
]
|
||||
):
|
||||
return
|
||||
_local_letters[letter["mail_id"]] = letter.duplicate(true)
|
||||
_emit_mailbox()
|
||||
|
||||
|
||||
func _deliver_send_result(peer_id: int, result: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_receive_send_result(result)
|
||||
else:
|
||||
receive_send_result.rpc_id(peer_id, result)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func receive_send_result(result: Dictionary) -> void:
|
||||
_receive_send_result(result)
|
||||
|
||||
|
||||
func _receive_send_result(result: Dictionary) -> void:
|
||||
if (
|
||||
_pending_local_send.is_empty()
|
||||
or str(result.get("request_id", ""))
|
||||
!= str(_pending_local_send.get("request_id", ""))
|
||||
):
|
||||
return
|
||||
if not bool(result.get("accepted", false)):
|
||||
var reservation_id := str(_pending_local_send.get("reservation_id", ""))
|
||||
if not reservation_id.is_empty():
|
||||
_reservations.release(reservation_id)
|
||||
_pending_local_send.clear()
|
||||
operation_finished.emit(
|
||||
bool(result.get("accepted", false)),
|
||||
str(result.get("message", "Letter could not be sent."))
|
||||
)
|
||||
|
||||
|
||||
func mark_read(mail_id: String) -> void:
|
||||
var request := _action_request(mail_id)
|
||||
if request.is_empty():
|
||||
return
|
||||
if _session.is_host():
|
||||
_handle_read(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
submit_read.rpc_id(1, request)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func submit_read(data: Dictionary) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_read(sender, data)
|
||||
|
||||
|
||||
func _handle_read(peer_id: int, data: Dictionary) -> void:
|
||||
var action_id := str(data.get("request_id", ""))
|
||||
if _action_ledger.has(action_id):
|
||||
return
|
||||
var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
|
||||
if (
|
||||
letter.is_empty()
|
||||
or int(letter["recipient_peer_id"]) != peer_id
|
||||
or str(data.get("session_id", "")) != _session.get_session_id()
|
||||
):
|
||||
return
|
||||
_action_ledger[action_id] = true
|
||||
if int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD:
|
||||
letter["state"] = NetworkMailProtocol.State.READ
|
||||
_update_participants(letter)
|
||||
|
||||
|
||||
func accept_gift(mail_id: String) -> void:
|
||||
var request := _action_request(mail_id)
|
||||
if request.is_empty():
|
||||
return
|
||||
if _session.is_host():
|
||||
_handle_accept(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
submit_accept.rpc_id(1, request)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func submit_accept(data: Dictionary) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_accept(sender, data)
|
||||
|
||||
|
||||
func _handle_accept(peer_id: int, data: Dictionary) -> void:
|
||||
var action_id := str(data.get("request_id", ""))
|
||||
var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
|
||||
if _action_ledger.has(action_id):
|
||||
return
|
||||
if (
|
||||
letter.is_empty()
|
||||
or int(letter["recipient_peer_id"]) != peer_id
|
||||
or int(letter["state"]) not in [
|
||||
NetworkMailProtocol.State.SENT_UNREAD,
|
||||
NetworkMailProtocol.State.READ,
|
||||
]
|
||||
or int(letter["attachment"].get("type", 0)) == 0
|
||||
or not _session.is_authenticated_peer(int(letter["sender_peer_id"]))
|
||||
):
|
||||
return
|
||||
_action_ledger[action_id] = true
|
||||
var transfer_id := _new_id("mail_transfer")
|
||||
letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING
|
||||
_pending_transfers[transfer_id] = {
|
||||
"letter": letter,
|
||||
"recipient_prepared": false,
|
||||
"sender_removed": false,
|
||||
}
|
||||
_update_participants(letter)
|
||||
_request_prepare(int(letter["recipient_peer_id"]), transfer_id, letter)
|
||||
|
||||
|
||||
func _request_prepare(peer_id: int, transfer_id: String, letter: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_prepare_recipient(transfer_id, letter)
|
||||
else:
|
||||
prepare_recipient.rpc_id(peer_id, transfer_id, letter)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
|
||||
_prepare_recipient(transfer_id, letter)
|
||||
|
||||
|
||||
func _prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
|
||||
var ready := _can_receive(letter["attachment"])
|
||||
_send_phase_ack("recipient_prepared", transfer_id, ready)
|
||||
|
||||
|
||||
func _request_sender_commit(peer_id: int, transfer_id: String, letter: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_commit_sender(transfer_id, letter)
|
||||
else:
|
||||
commit_sender.rpc_id(peer_id, transfer_id, letter)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func commit_sender(transfer_id: String, letter: Dictionary) -> void:
|
||||
_commit_sender(transfer_id, letter)
|
||||
|
||||
|
||||
func _commit_sender(transfer_id: String, letter: Dictionary) -> void:
|
||||
var reservation_id: String = letter["reservation_id"]
|
||||
var snapshot := _capture_assets()
|
||||
var applied := (
|
||||
_reservations.has_reservation(reservation_id)
|
||||
and _reservations.commit_removal(reservation_id)
|
||||
and _save_manager.save_if_dirty()
|
||||
)
|
||||
if not applied:
|
||||
_restore_assets(snapshot)
|
||||
_save_manager.save_if_dirty()
|
||||
else:
|
||||
_local_removal_snapshots[transfer_id] = snapshot
|
||||
_send_phase_ack("sender_removed", transfer_id, applied)
|
||||
|
||||
|
||||
func _request_recipient_award(
|
||||
peer_id: int, transfer_id: String, letter: Dictionary
|
||||
) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_award_recipient(transfer_id, letter)
|
||||
else:
|
||||
award_recipient.rpc_id(peer_id, transfer_id, letter)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func award_recipient(transfer_id: String, letter: Dictionary) -> void:
|
||||
_award_recipient(transfer_id, letter)
|
||||
|
||||
|
||||
func _award_recipient(transfer_id: String, letter: Dictionary) -> void:
|
||||
if _received_awards.has(transfer_id):
|
||||
_send_phase_ack("recipient_awarded", transfer_id, true)
|
||||
return
|
||||
var snapshot := _capture_assets()
|
||||
var applied := _apply_award(letter["attachment"])
|
||||
if applied:
|
||||
applied = _save_manager.save_if_dirty()
|
||||
if not applied:
|
||||
_restore_assets(snapshot)
|
||||
_save_manager.save_if_dirty()
|
||||
else:
|
||||
_received_awards[transfer_id] = true
|
||||
_bound(_received_awards)
|
||||
_send_phase_ack("recipient_awarded", transfer_id, applied)
|
||||
|
||||
|
||||
func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void:
|
||||
if _session.is_host():
|
||||
_handle_phase_ack(_session.get_local_peer_id(), phase, transfer_id, applied)
|
||||
else:
|
||||
acknowledge_transfer_phase.rpc_id(1, phase, transfer_id, applied)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func acknowledge_transfer_phase(
|
||||
phase: String, transfer_id: String, applied: bool
|
||||
) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_phase_ack(sender, phase, transfer_id, applied)
|
||||
|
||||
|
||||
func _handle_phase_ack(
|
||||
peer_id: int, phase: String, transfer_id: String, applied: bool
|
||||
) -> void:
|
||||
var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
|
||||
if transfer.is_empty():
|
||||
return
|
||||
var letter: Dictionary = transfer["letter"]
|
||||
if phase == "recipient_prepared":
|
||||
if peer_id != int(letter["recipient_peer_id"]):
|
||||
return
|
||||
if not applied:
|
||||
_finish_transfer(transfer_id, false, "Gift could not be transferred.")
|
||||
return
|
||||
transfer["recipient_prepared"] = true
|
||||
_request_sender_commit(int(letter["sender_peer_id"]), transfer_id, letter)
|
||||
elif phase == "sender_removed":
|
||||
if peer_id != int(letter["sender_peer_id"]):
|
||||
return
|
||||
if not applied:
|
||||
_finish_transfer(transfer_id, false, "Gift returned to sender.")
|
||||
return
|
||||
transfer["sender_removed"] = true
|
||||
_request_recipient_award(
|
||||
int(letter["recipient_peer_id"]), transfer_id, letter
|
||||
)
|
||||
elif phase == "recipient_awarded":
|
||||
if peer_id != int(letter["recipient_peer_id"]):
|
||||
return
|
||||
if applied:
|
||||
_finish_transfer(transfer_id, true, "Gift accepted.")
|
||||
else:
|
||||
_request_sender_rollback(
|
||||
int(letter["sender_peer_id"]), transfer_id
|
||||
)
|
||||
_finish_transfer(transfer_id, false, "Gift could not be transferred.")
|
||||
|
||||
|
||||
func _request_sender_rollback(peer_id: int, transfer_id: String) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_rollback_sender(transfer_id)
|
||||
else:
|
||||
rollback_sender.rpc_id(peer_id, transfer_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func rollback_sender(transfer_id: String) -> void:
|
||||
_rollback_sender(transfer_id)
|
||||
|
||||
|
||||
func _rollback_sender(transfer_id: String) -> void:
|
||||
var snapshot: Dictionary = _local_removal_snapshots.get(transfer_id, {})
|
||||
if not snapshot.is_empty():
|
||||
_restore_assets(snapshot)
|
||||
_save_manager.save_if_dirty()
|
||||
_local_removal_snapshots.erase(transfer_id)
|
||||
|
||||
|
||||
func _finish_transfer(transfer_id: String, accepted: bool, message: String) -> void:
|
||||
var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
|
||||
if transfer.is_empty():
|
||||
return
|
||||
var letter: Dictionary = transfer["letter"]
|
||||
letter["state"] = (
|
||||
NetworkMailProtocol.State.ACCEPTED
|
||||
if accepted else NetworkMailProtocol.State.ATTACHMENT_RECALLED
|
||||
)
|
||||
_pending_transfers.erase(transfer_id)
|
||||
_update_participants(letter)
|
||||
operation_finished.emit(accepted, message)
|
||||
|
||||
|
||||
func decline_gift(mail_id: String) -> void:
|
||||
var request := _action_request(mail_id)
|
||||
if request.is_empty():
|
||||
return
|
||||
if _session.is_host():
|
||||
_handle_decline(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
submit_decline.rpc_id(1, request)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func submit_decline(data: Dictionary) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_decline(sender, data)
|
||||
|
||||
|
||||
func _handle_decline(peer_id: int, data: Dictionary) -> void:
|
||||
var action_id := str(data.get("request_id", ""))
|
||||
var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
|
||||
if _action_ledger.has(action_id):
|
||||
return
|
||||
if (
|
||||
letter.is_empty()
|
||||
or int(letter["recipient_peer_id"]) != peer_id
|
||||
or int(letter["state"]) not in [
|
||||
NetworkMailProtocol.State.SENT_UNREAD,
|
||||
NetworkMailProtocol.State.READ,
|
||||
]
|
||||
):
|
||||
return
|
||||
_action_ledger[action_id] = true
|
||||
letter["state"] = NetworkMailProtocol.State.DECLINED
|
||||
_request_release(int(letter["sender_peer_id"]), letter["reservation_id"])
|
||||
_update_participants(letter)
|
||||
|
||||
|
||||
func _request_release(peer_id: int, reservation_id: String) -> void:
|
||||
if reservation_id.is_empty():
|
||||
return
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_reservations.release(reservation_id)
|
||||
else:
|
||||
release_reservation.rpc_id(peer_id, reservation_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func release_reservation(reservation_id: String) -> void:
|
||||
_reservations.release(reservation_id)
|
||||
|
||||
|
||||
func _update_participants(letter: Dictionary) -> void:
|
||||
_letters[letter["mail_id"]] = letter
|
||||
_deliver_private_letter(int(letter["sender_peer_id"]), letter)
|
||||
_deliver_private_letter(int(letter["recipient_peer_id"]), letter)
|
||||
|
||||
|
||||
func _action_request(mail_id: String) -> Dictionary:
|
||||
if mail_id.is_empty() or not _local_letters.has(mail_id):
|
||||
return {}
|
||||
return {
|
||||
"request_id": _new_id("mail_action"),
|
||||
"mail_id": mail_id,
|
||||
"session_id": _session.get_session_id(),
|
||||
}
|
||||
|
||||
|
||||
func _validate_attachment_structure(attachment: Dictionary) -> bool:
|
||||
if typeof(attachment.get("type")) != TYPE_INT:
|
||||
return false
|
||||
match int(attachment["type"]):
|
||||
PlayerAssetReservationService.AttachmentType.NONE:
|
||||
return attachment.size() == 1
|
||||
PlayerAssetReservationService.AttachmentType.FISH_COIN:
|
||||
return (
|
||||
attachment.size() == 2
|
||||
and
|
||||
typeof(attachment.get("amount")) == TYPE_INT
|
||||
and int(attachment["amount"]) >= 1
|
||||
and int(attachment["amount"]) <= 1000000000
|
||||
)
|
||||
PlayerAssetReservationService.AttachmentType.FISH:
|
||||
if not (
|
||||
attachment.size() == 3
|
||||
and
|
||||
typeof(attachment.get("catch_id")) == TYPE_STRING
|
||||
and typeof(attachment.get("catch")) == TYPE_DICTIONARY
|
||||
):
|
||||
return false
|
||||
var fish_catch := _decode_catch(attachment)
|
||||
return (
|
||||
fish_catch != null
|
||||
and str(fish_catch.catch_id) == str(attachment["catch_id"])
|
||||
and not bool(
|
||||
Dictionary(attachment["catch"]).get("is_favorited", false)
|
||||
)
|
||||
)
|
||||
PlayerAssetReservationService.AttachmentType.CONSUMABLE:
|
||||
var item := _item_catalog.get_item_by_id(
|
||||
StringName(str(attachment.get("item_id", "")))
|
||||
)
|
||||
return (
|
||||
attachment.size() == 3
|
||||
and
|
||||
item != null
|
||||
and item.category == ItemData.Category.CONSUMABLE
|
||||
and typeof(attachment.get("quantity")) == TYPE_INT
|
||||
and int(attachment["quantity"]) >= 1
|
||||
and int(attachment["quantity"]) <= item.max_stack
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
func _can_receive(attachment: Dictionary) -> bool:
|
||||
match int(attachment.get("type", 0)):
|
||||
PlayerAssetReservationService.AttachmentType.FISH_COIN:
|
||||
return _wallet.can_credit(int(attachment["amount"]))
|
||||
PlayerAssetReservationService.AttachmentType.FISH:
|
||||
return (
|
||||
_inventory.get_all_catches().size() < _cooler_capacity.get_capacity()
|
||||
and not _inventory.contains_catch_id(
|
||||
StringName(str(attachment["catch_id"]))
|
||||
)
|
||||
and _decode_catch(attachment) != null
|
||||
)
|
||||
PlayerAssetReservationService.AttachmentType.CONSUMABLE:
|
||||
return _bag.can_add_item(
|
||||
StringName(str(attachment["item_id"])),
|
||||
int(attachment["quantity"])
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
func _apply_award(attachment: Dictionary) -> bool:
|
||||
if not _can_receive(attachment):
|
||||
return false
|
||||
match int(attachment["type"]):
|
||||
PlayerAssetReservationService.AttachmentType.FISH_COIN:
|
||||
return _wallet.credit(int(attachment["amount"]))
|
||||
PlayerAssetReservationService.AttachmentType.FISH:
|
||||
var fish_catch := _decode_catch(attachment)
|
||||
_inventory.add_catch(fish_catch)
|
||||
_collection_log.mark_discovered(fish_catch.fish_id)
|
||||
return _inventory.contains_catch_id(fish_catch.catch_id)
|
||||
PlayerAssetReservationService.AttachmentType.CONSUMABLE:
|
||||
return _bag.add_item(
|
||||
StringName(str(attachment["item_id"])),
|
||||
int(attachment["quantity"])
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
func _decode_catch(attachment: Dictionary) -> FishCatch:
|
||||
var data: Dictionary = attachment.get("catch", {})
|
||||
var fish := _fish_catalog.get_fish_by_id(
|
||||
StringName(str(data.get("fish_id", "")))
|
||||
)
|
||||
return FishCatch.from_network_dict(data, fish)
|
||||
|
||||
|
||||
func _capture_assets() -> Dictionary:
|
||||
return {
|
||||
"wallet": _wallet.get_balance(),
|
||||
"bag": _bag.get_all_items(),
|
||||
"catches": _inventory.get_all_catches(),
|
||||
"next_sequence": _inventory.get_next_catch_sequence(),
|
||||
"discovered": _collection_log.get_discovered_ids(),
|
||||
}
|
||||
|
||||
|
||||
func _restore_assets(snapshot: Dictionary) -> void:
|
||||
_wallet.restore_balance(int(snapshot["wallet"]))
|
||||
_bag.replace_all_items(snapshot["bag"])
|
||||
_inventory.replace_all_catches(
|
||||
snapshot["catches"], int(snapshot["next_sequence"])
|
||||
)
|
||||
_collection_log.replace_discovered_ids(snapshot["discovered"])
|
||||
|
||||
|
||||
func _emit_mailbox() -> void:
|
||||
mailbox_changed.emit()
|
||||
unread_count_changed.emit(get_unread_count())
|
||||
|
||||
|
||||
func _on_peer_removed(peer_id: int) -> void:
|
||||
if not _session.is_host():
|
||||
_clear_local_mail_for_peer(peer_id)
|
||||
return
|
||||
for letter: Dictionary in _letters.values():
|
||||
if int(letter["recipient_peer_id"]) == peer_id:
|
||||
if int(letter["state"]) in [
|
||||
NetworkMailProtocol.State.SENT_UNREAD,
|
||||
NetworkMailProtocol.State.READ,
|
||||
]:
|
||||
letter["state"] = NetworkMailProtocol.State.CANCELLED
|
||||
_request_release(
|
||||
int(letter["sender_peer_id"]), letter["reservation_id"]
|
||||
)
|
||||
_deliver_private_letter(
|
||||
int(letter["sender_peer_id"]), letter
|
||||
)
|
||||
elif (
|
||||
int(letter["sender_peer_id"]) == peer_id
|
||||
and int(letter["state"]) in [
|
||||
NetworkMailProtocol.State.SENT_UNREAD,
|
||||
NetworkMailProtocol.State.READ,
|
||||
]
|
||||
):
|
||||
letter["state"] = NetworkMailProtocol.State.ATTACHMENT_RECALLED
|
||||
_update_participants(letter)
|
||||
_send_ledger.erase(peer_id)
|
||||
_clear_local_mail_for_peer(peer_id)
|
||||
peers_changed.emit()
|
||||
|
||||
|
||||
func _clear_local_mail_for_peer(peer_id: int) -> void:
|
||||
for mail_id: String in _local_letters.keys():
|
||||
var letter: Dictionary = _local_letters[mail_id]
|
||||
if int(letter["recipient_peer_id"]) == peer_id:
|
||||
_local_letters.erase(mail_id)
|
||||
_emit_mailbox()
|
||||
|
||||
|
||||
func _on_session_state_changed(state: NetworkSession.State) -> void:
|
||||
if state in [
|
||||
NetworkSession.State.INACTIVE,
|
||||
NetworkSession.State.DISCONNECTING,
|
||||
NetworkSession.State.CONNECTION_FAILED,
|
||||
NetworkSession.State.SERVER_LOST,
|
||||
]:
|
||||
_reservations.release_all()
|
||||
_letters.clear()
|
||||
_local_letters.clear()
|
||||
_send_ledger.clear()
|
||||
_action_ledger.clear()
|
||||
_pending_local_send.clear()
|
||||
_pending_transfers.clear()
|
||||
_local_removal_snapshots.clear()
|
||||
_received_awards.clear()
|
||||
_sequence = 0
|
||||
_emit_mailbox()
|
||||
|
||||
|
||||
func _bound(values: Dictionary) -> void:
|
||||
while values.size() > MAX_LEDGER:
|
||||
values.erase(values.keys().front())
|
||||
|
||||
|
||||
func _new_id(prefix: String) -> String:
|
||||
return "%s:%s" % [
|
||||
prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
|
||||
]
|
||||
1
network/network_mail_service.gd.uid
Normal file
1
network/network_mail_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://t235k67v0dno
|
||||
|
|
@ -9,12 +9,13 @@ const MAX_NONCE_LENGTH: int = 96
|
|||
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
|
||||
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
|
||||
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
|
||||
# 8 reliable ordered session chat.
|
||||
# 8 reliable ordered session chat, 9 reliable private session mail.
|
||||
const SALE_RELIABLE_CHANNEL: int = 5
|
||||
const SHOP_RELIABLE_CHANNEL: int = 6
|
||||
const ITEM_RELIABLE_CHANNEL: int = 7
|
||||
const CHAT_RELIABLE_CHANNEL: int = 8
|
||||
const ENET_CHANNEL_COUNT: int = 9
|
||||
const MAIL_RELIABLE_CHANNEL: int = 9
|
||||
const ENET_CHANNEL_COUNT: int = 10
|
||||
|
||||
enum RejectionCode {
|
||||
NONE,
|
||||
|
|
@ -116,6 +117,7 @@ static func make_server_hello(
|
|||
"item_use_v1",
|
||||
"equipment_v1",
|
||||
"chat_v1",
|
||||
"mail_v1",
|
||||
]),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ var _applied_results: Dictionary[String, bool] = {}
|
|||
var _received_results: Dictionary[String, bool] = {}
|
||||
var _pending_local_request_id: String = ""
|
||||
var _pending_local_catch_ids: Array[StringName] = []
|
||||
var _reservations: PlayerAssetReservationService
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -51,6 +52,7 @@ func setup(
|
|||
fish_catalog: FishPoolType,
|
||||
buyer: FishBuyerProfileType,
|
||||
pelican_landmark: Node3D,
|
||||
reservations: PlayerAssetReservationService,
|
||||
) -> void:
|
||||
_session = session
|
||||
_spawn_service = spawn_service
|
||||
|
|
@ -62,6 +64,7 @@ func setup(
|
|||
_fish_catalog = fish_catalog
|
||||
_buyer = buyer
|
||||
_pelican_landmark = pelican_landmark
|
||||
_reservations = reservations
|
||||
if not _session.peer_removed.is_connected(_on_peer_removed):
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
if not _session.state_changed.is_connected(_on_session_state_changed):
|
||||
|
|
@ -86,6 +89,12 @@ func is_local_sale_pending() -> bool:
|
|||
|
||||
|
||||
func request_local_sale(catch_ids: Array[StringName]) -> String:
|
||||
for catch_id: StringName in catch_ids:
|
||||
if _reservations != null and _reservations.is_fish_reserved(catch_id):
|
||||
local_sale_finished.emit(
|
||||
"", false, "Reserved in a letter.", [], 0
|
||||
)
|
||||
return ""
|
||||
if is_local_sale_pending():
|
||||
local_sale_finished.emit(
|
||||
"", false, "Selling…", [], 0
|
||||
|
|
@ -346,6 +355,10 @@ func _apply_sale_result(data: Dictionary) -> void:
|
|||
if catch_ids != _pending_local_catch_ids:
|
||||
_fail_local_apply(data, "Sale could not be completed.")
|
||||
return
|
||||
for catch_id: StringName in catch_ids:
|
||||
if _reservations != null and _reservations.is_fish_reserved(catch_id):
|
||||
_fail_local_apply(data, "Reserved in a letter.")
|
||||
return
|
||||
var preview: FishSaleResultType = _sale_service.preview_batch(
|
||||
catch_ids, _buyer
|
||||
)
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ func supports_server_capability(capability: StringName) -> bool:
|
|||
return str(capability) in PackedStringArray([
|
||||
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
|
||||
"item_use_v1", "equipment_v1", "chat_v1",
|
||||
"mail_v1",
|
||||
])
|
||||
return str(capability) in _server_capabilities
|
||||
|
||||
|
|
@ -285,6 +286,10 @@ func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord:
|
|||
return _registry.get_peer(peer_id)
|
||||
|
||||
|
||||
func get_authenticated_peer_ids() -> Array[int]:
|
||||
return _registry.get_peer_ids()
|
||||
|
||||
|
||||
func update_local_display_name(value: String) -> bool:
|
||||
if _profile == null or not _profile.set_display_name(value):
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ var _acknowledged_results: Dictionary[String, bool] = {}
|
|||
var _received_results: Dictionary[String, bool] = {}
|
||||
var _applied_results: Dictionary[String, bool] = {}
|
||||
var _pending_local_request: Dictionary = {}
|
||||
var _reservations: PlayerAssetReservationService
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -54,6 +55,7 @@ func setup(
|
|||
upgrades: PlayerFishingUpgrades,
|
||||
cooler_capacity: PlayerCoolerCapacity,
|
||||
save_manager: PlayerSaveManager,
|
||||
reservations: PlayerAssetReservationService,
|
||||
) -> void:
|
||||
_session = session
|
||||
_spawn_service = spawn_service
|
||||
|
|
@ -65,6 +67,7 @@ func setup(
|
|||
_upgrades = upgrades
|
||||
_cooler_capacity = cooler_capacity
|
||||
_save_manager = save_manager
|
||||
_reservations = reservations
|
||||
if not _session.peer_removed.is_connected(_on_peer_removed):
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
if not _session.state_changed.is_connected(_on_session_state_changed):
|
||||
|
|
@ -464,6 +467,11 @@ func _validate_local_result(data: Dictionary) -> String:
|
|||
var product_id := StringName(str(data["product_id"]))
|
||||
var expected_state: int = data["expected_state"]
|
||||
var cost: int = data["total_cost"]
|
||||
if (
|
||||
_reservations != null
|
||||
and _reservations.get_available_fish_coin() < cost
|
||||
):
|
||||
return "Reserved in a letter."
|
||||
if int(data["resulting_state"]) != expected_state + 1:
|
||||
return "Purchase could not be completed."
|
||||
if not _wallet.can_afford(cost):
|
||||
|
|
|
|||
200
progression/player_asset_reservation_service.gd
Normal file
200
progression/player_asset_reservation_service.gd
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
class_name PlayerAssetReservationService
|
||||
extends Node
|
||||
|
||||
enum AttachmentType { NONE, FISH_COIN, FISH, CONSUMABLE }
|
||||
|
||||
signal reservations_changed
|
||||
|
||||
var _wallet: PlayerWallet
|
||||
var _inventory: FishInventory
|
||||
var _bag: PlayerBag
|
||||
var _catalog: ItemCatalog
|
||||
var _reservations: Dictionary[String, Dictionary] = {}
|
||||
|
||||
|
||||
func setup(
|
||||
wallet: PlayerWallet,
|
||||
inventory: FishInventory,
|
||||
bag: PlayerBag,
|
||||
catalog: ItemCatalog,
|
||||
) -> void:
|
||||
_wallet = wallet
|
||||
_inventory = inventory
|
||||
_bag = bag
|
||||
_catalog = catalog
|
||||
|
||||
|
||||
func reserve(reservation_id: String, attachment: Dictionary) -> bool:
|
||||
if (
|
||||
reservation_id.is_empty()
|
||||
or reservation_id.length() > 96
|
||||
or _reservations.has(reservation_id)
|
||||
or not validate_attachment(attachment)
|
||||
):
|
||||
return false
|
||||
var kind: int = attachment["type"]
|
||||
match kind:
|
||||
AttachmentType.NONE:
|
||||
return true
|
||||
AttachmentType.FISH_COIN:
|
||||
if int(attachment["amount"]) > get_available_fish_coin():
|
||||
return false
|
||||
AttachmentType.FISH:
|
||||
var catch_id := StringName(str(attachment["catch_id"]))
|
||||
var fish_catch := _inventory.get_catch_by_id(catch_id)
|
||||
if (
|
||||
fish_catch == null
|
||||
or fish_catch.is_favorited
|
||||
or is_fish_reserved(catch_id)
|
||||
):
|
||||
return false
|
||||
AttachmentType.CONSUMABLE:
|
||||
var item_id := StringName(str(attachment["item_id"]))
|
||||
if (
|
||||
int(attachment["quantity"])
|
||||
> get_available_item_quantity(item_id)
|
||||
):
|
||||
return false
|
||||
_reservations[reservation_id] = attachment.duplicate(true)
|
||||
reservations_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func release(reservation_id: String) -> bool:
|
||||
if not _reservations.erase(reservation_id):
|
||||
return false
|
||||
reservations_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func release_all() -> void:
|
||||
if _reservations.is_empty():
|
||||
return
|
||||
_reservations.clear()
|
||||
reservations_changed.emit()
|
||||
|
||||
|
||||
func has_reservation(reservation_id: String) -> bool:
|
||||
return _reservations.has(reservation_id)
|
||||
|
||||
|
||||
func get_reservation(reservation_id: String) -> Dictionary:
|
||||
return _reservations.get(reservation_id, {}).duplicate(true)
|
||||
|
||||
|
||||
func get_available_fish_coin() -> int:
|
||||
return maxi(_wallet.get_balance() - get_reserved_fish_coin(), 0)
|
||||
|
||||
|
||||
func get_reserved_fish_coin() -> int:
|
||||
var total := 0
|
||||
for attachment: Dictionary in _reservations.values():
|
||||
if int(attachment.get("type", 0)) == AttachmentType.FISH_COIN:
|
||||
total += int(attachment.get("amount", 0))
|
||||
return total
|
||||
|
||||
|
||||
func get_available_item_quantity(item_id: StringName) -> int:
|
||||
return maxi(_bag.get_quantity(item_id) - get_reserved_item_quantity(item_id), 0)
|
||||
|
||||
|
||||
func get_reserved_item_quantity(item_id: StringName) -> int:
|
||||
var total := 0
|
||||
for attachment: Dictionary in _reservations.values():
|
||||
if (
|
||||
int(attachment.get("type", 0)) == AttachmentType.CONSUMABLE
|
||||
and StringName(str(attachment.get("item_id", ""))) == item_id
|
||||
):
|
||||
total += int(attachment.get("quantity", 0))
|
||||
return total
|
||||
|
||||
|
||||
func is_fish_reserved(catch_id: StringName) -> bool:
|
||||
for attachment: Dictionary in _reservations.values():
|
||||
if (
|
||||
int(attachment.get("type", 0)) == AttachmentType.FISH
|
||||
and StringName(str(attachment.get("catch_id", ""))) == catch_id
|
||||
):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func can_commit(reservation_id: String) -> bool:
|
||||
var attachment := get_reservation(reservation_id)
|
||||
if attachment.is_empty():
|
||||
return false
|
||||
match int(attachment["type"]):
|
||||
AttachmentType.FISH_COIN:
|
||||
return _wallet.can_afford(int(attachment["amount"]))
|
||||
AttachmentType.FISH:
|
||||
return _inventory.get_catch_by_id(
|
||||
StringName(str(attachment["catch_id"]))
|
||||
) != null
|
||||
AttachmentType.CONSUMABLE:
|
||||
return (
|
||||
_bag.get_quantity(StringName(str(attachment["item_id"])))
|
||||
>= int(attachment["quantity"])
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
func commit_removal(reservation_id: String) -> bool:
|
||||
var attachment := get_reservation(reservation_id)
|
||||
if attachment.is_empty() or not can_commit(reservation_id):
|
||||
return false
|
||||
var applied := false
|
||||
match int(attachment["type"]):
|
||||
AttachmentType.FISH_COIN:
|
||||
applied = _wallet.debit(int(attachment["amount"]))
|
||||
AttachmentType.FISH:
|
||||
applied = _inventory.remove_catch_by_id(
|
||||
StringName(str(attachment["catch_id"]))
|
||||
) != null
|
||||
AttachmentType.CONSUMABLE:
|
||||
applied = _bag.remove_item(
|
||||
StringName(str(attachment["item_id"])),
|
||||
int(attachment["quantity"])
|
||||
)
|
||||
if applied:
|
||||
release(reservation_id)
|
||||
return applied
|
||||
|
||||
|
||||
func validate_attachment(attachment: Dictionary) -> bool:
|
||||
if typeof(attachment.get("type")) != TYPE_INT:
|
||||
return false
|
||||
match int(attachment["type"]):
|
||||
AttachmentType.NONE:
|
||||
return attachment.size() == 1
|
||||
AttachmentType.FISH_COIN:
|
||||
return (
|
||||
attachment.size() == 2
|
||||
and
|
||||
typeof(attachment.get("amount")) == TYPE_INT
|
||||
and int(attachment["amount"]) >= 1
|
||||
and int(attachment["amount"]) <= 1000000000
|
||||
)
|
||||
AttachmentType.FISH:
|
||||
return (
|
||||
attachment.size() == 3
|
||||
and
|
||||
typeof(attachment.get("catch_id")) == TYPE_STRING
|
||||
and not str(attachment["catch_id"]).is_empty()
|
||||
and str(attachment["catch_id"]).length() <= 160
|
||||
and typeof(attachment.get("catch")) == TYPE_DICTIONARY
|
||||
)
|
||||
AttachmentType.CONSUMABLE:
|
||||
if (
|
||||
attachment.size() != 3
|
||||
or
|
||||
typeof(attachment.get("item_id")) != TYPE_STRING
|
||||
or typeof(attachment.get("quantity")) != TYPE_INT
|
||||
or int(attachment["quantity"]) < 1
|
||||
or int(attachment["quantity"]) > 999
|
||||
):
|
||||
return false
|
||||
var item := _catalog.get_item_by_id(
|
||||
StringName(str(attachment["item_id"]))
|
||||
)
|
||||
return item != null and item.category == ItemData.Category.CONSUMABLE
|
||||
return false
|
||||
1
progression/player_asset_reservation_service.gd.uid
Normal file
1
progression/player_asset_reservation_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bheplri3k6ch7
|
||||
|
|
@ -190,7 +190,7 @@ func _on_message(message: Dictionary) -> void:
|
|||
}
|
||||
|
||||
|
||||
func _on_history(_messages: Array[Dictionary]) -> void:
|
||||
func _on_history(_messages: Array) -> void:
|
||||
if _messages.is_empty():
|
||||
for peer_id: int in _speech.keys():
|
||||
_on_peer_removed(peer_id)
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ func setup(
|
|||
network_chat_service: NetworkChatService,
|
||||
network_profile: NetworkProfilePreferences,
|
||||
spawn_service: PlayerSpawnService,
|
||||
network_mail_service: NetworkMailService,
|
||||
reservations: PlayerAssetReservationService,
|
||||
) -> void:
|
||||
_fishing_spot = fishing_spot
|
||||
_item_effects = item_effects
|
||||
|
|
@ -149,7 +151,9 @@ func setup(
|
|||
item_catalog,
|
||||
cooler_capacity,
|
||||
network_session,
|
||||
network_sale_service
|
||||
network_sale_service,
|
||||
network_mail_service,
|
||||
reservations
|
||||
)
|
||||
_hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot)
|
||||
_fishing_shop.setup(
|
||||
|
|
|
|||
603
ui/mail_page.gd
Normal file
603
ui/mail_page.gd
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
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)
|
||||
_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()
|
||||
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)
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color("f3ecd7")
|
||||
style.border_color = Color("665a46")
|
||||
style.set_border_width_all(4)
|
||||
style.set_corner_radius_all(18)
|
||||
style.shadow_color = Color(0.05, 0.04, 0.03, 0.42)
|
||||
style.shadow_size = 7
|
||||
paper.add_theme_stylebox_override("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)
|
||||
_show_inbox()
|
||||
|
||||
|
||||
func _build_inbox() -> Control:
|
||||
var page := Control.new()
|
||||
var title := Label.new()
|
||||
title.text = "session 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"])))
|
||||
_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()
|
||||
1
ui/mail_page.gd.uid
Normal file
1
ui/mail_page.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://urjasxokrp8j
|
||||
|
|
@ -72,7 +72,7 @@ const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
|
|||
const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0)
|
||||
const COMPACT_HEIGHT_THRESHOLD: float = 560.0
|
||||
const NAVIGATION_PRESENTATION_SCALE: float = 0.60
|
||||
const NAVIGATION_CANONICAL_POSITION := Vector2(463.0, 44.0)
|
||||
const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0)
|
||||
const NAVIGATION_SELECTED_SCALE: float = 1.02
|
||||
const COOLER_RARITY_COMMON := Color("e8eef0")
|
||||
const COOLER_RARITY_UNCOMMON := Color("64c87c")
|
||||
|
|
@ -86,6 +86,7 @@ enum Section {
|
|||
COOLER,
|
||||
BAG,
|
||||
LOGBOOK,
|
||||
MAIL,
|
||||
}
|
||||
|
||||
enum SortMode {
|
||||
|
|
@ -154,6 +155,7 @@ enum CloseReason {
|
|||
@onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName
|
||||
@onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData
|
||||
@onready var _logbook_page: Control = %LogbookPage
|
||||
@onready var _mail_page: MailPage = %MailPage
|
||||
@onready var _book_backing: PanelContainer = %BookBacking
|
||||
@onready var _book_spread: BoxContainer = %BookSpread
|
||||
@onready var _left_page: PanelContainer = %LeftPage
|
||||
|
|
@ -170,6 +172,8 @@ enum CloseReason {
|
|||
@onready var _inventory_tab: BubbleButtonType = %InventoryTab
|
||||
@onready var _bag_tab: BubbleButtonType = %BagTab
|
||||
@onready var _logbook_tab: BubbleButtonType = %LogbookTab
|
||||
@onready var _mail_tab: BubbleButtonType = %MailTab
|
||||
@onready var _mail_unread_badge: Label = %MailUnreadBadge
|
||||
@onready var _close_button: BubbleButtonType = %CloseButton
|
||||
@onready var _content_shell: BubbleContentShellType = %MenuPanel
|
||||
@onready var _content_stage: Control = %Content
|
||||
|
|
@ -228,6 +232,7 @@ var _wallet: PlayerWalletType
|
|||
var _sale_service: FishSaleServiceType
|
||||
var _network_session: NetworkSessionType
|
||||
var _network_sale_service: NetworkSaleService
|
||||
var _network_mail_service: NetworkMailService
|
||||
var _default_buyer: FishBuyerProfileType
|
||||
var _catalog: FishPoolType
|
||||
var _fishing_spot: FishingSpotType
|
||||
|
|
@ -262,6 +267,7 @@ var _content_rest_position: Vector2 = Vector2.ZERO
|
|||
var _cooler_rest_position: Vector2 = Vector2.ZERO
|
||||
var _bag_rest_position: Vector2 = Vector2.ZERO
|
||||
var _logbook_rest_position: Vector2 = Vector2.ZERO
|
||||
var _mail_rest_position: Vector2 = Vector2.ZERO
|
||||
var _page_outgoing_root: Control
|
||||
var _page_incoming_root: Control
|
||||
var _page_hosts_shared: bool = false
|
||||
|
|
@ -287,6 +293,7 @@ func _ready() -> void:
|
|||
_logbook_tab.pressed.connect(
|
||||
_show_section.bind(Section.LOGBOOK)
|
||||
)
|
||||
_mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
|
||||
_close_button.pressed.connect(close_menu)
|
||||
_sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option))
|
||||
_sort_direction.pressed.connect(_on_sort_direction_pressed)
|
||||
|
|
@ -312,6 +319,7 @@ func _ready() -> void:
|
|||
_inventory_tab,
|
||||
_bag_tab,
|
||||
_logbook_tab,
|
||||
_mail_tab,
|
||||
_close_button,
|
||||
])
|
||||
_configure_navigation_focus()
|
||||
|
|
@ -358,6 +366,8 @@ func setup(
|
|||
cooler_capacity: PlayerCoolerCapacityType,
|
||||
network_session: NetworkSessionType,
|
||||
network_sale_service: NetworkSaleService,
|
||||
network_mail_service: NetworkMailService,
|
||||
reservations: PlayerAssetReservationService,
|
||||
) -> void:
|
||||
_player = player
|
||||
_inventory = inventory
|
||||
|
|
@ -373,6 +383,14 @@ func setup(
|
|||
_cooler_capacity = cooler_capacity
|
||||
_network_session = network_session
|
||||
_network_sale_service = network_sale_service
|
||||
_network_mail_service = network_mail_service
|
||||
_mail_page.setup(
|
||||
network_mail_service, reservations, inventory, wallet, bag, item_catalog
|
||||
)
|
||||
_network_mail_service.unread_count_changed.connect(
|
||||
_on_mail_unread_count_changed
|
||||
)
|
||||
_on_mail_unread_count_changed(_network_mail_service.get_unread_count())
|
||||
if (
|
||||
_network_sale_service != null
|
||||
and not _network_sale_service.local_sale_pending.is_connected(
|
||||
|
|
@ -428,6 +446,8 @@ func consume_escape() -> bool:
|
|||
return true
|
||||
if _sale_confirmation.visible:
|
||||
_close_sale_confirmation()
|
||||
elif _current_section == Section.MAIL and _mail_page.consume_escape():
|
||||
pass
|
||||
else:
|
||||
close_menu()
|
||||
return true
|
||||
|
|
@ -563,6 +583,7 @@ func _show_section_immediate(section: Section) -> void:
|
|||
_cooler_page.visible = section == Section.COOLER
|
||||
_bag_page.visible = section == Section.BAG
|
||||
_logbook_page.visible = section == Section.LOGBOOK
|
||||
_mail_page.visible = section == Section.MAIL
|
||||
_content_shell.visible = false
|
||||
_inventory_section.visible = false
|
||||
_bag_section.visible = false
|
||||
|
|
@ -581,9 +602,14 @@ func _show_section_immediate(section: Section) -> void:
|
|||
_update_bag_detail()
|
||||
if section != Section.LOGBOOK:
|
||||
_cancel_logbook_page_transition(true)
|
||||
if section == Section.MAIL:
|
||||
_mail_page.activate()
|
||||
else:
|
||||
_mail_page.deactivate()
|
||||
_inventory_tab.button_pressed = section == Section.COOLER
|
||||
_bag_tab.button_pressed = section == Section.BAG
|
||||
_logbook_tab.button_pressed = section == Section.LOGBOOK
|
||||
_mail_tab.button_pressed = section == Section.MAIL
|
||||
_update_navigation_selection()
|
||||
_configure_active_page_focus()
|
||||
|
||||
|
|
@ -593,8 +619,10 @@ func _focus_current_section() -> void:
|
|||
_inventory_tab.grab_focus()
|
||||
elif _current_section == Section.BAG:
|
||||
_bag_tab.grab_focus()
|
||||
else:
|
||||
elif _current_section == Section.LOGBOOK:
|
||||
_logbook_tab.grab_focus()
|
||||
else:
|
||||
_mail_tab.grab_focus()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
|
@ -621,6 +649,7 @@ func _configure_navigation_focus() -> void:
|
|||
_inventory_tab,
|
||||
_bag_tab,
|
||||
_logbook_tab,
|
||||
_mail_tab,
|
||||
_close_button,
|
||||
]
|
||||
for index: int in navigation.size():
|
||||
|
|
@ -676,6 +705,8 @@ func _configure_active_page_focus() -> void:
|
|||
_configure_bag_item_focus()
|
||||
Section.LOGBOOK:
|
||||
_configure_logbook_focus()
|
||||
Section.MAIL:
|
||||
_mail_page.activate()
|
||||
|
||||
|
||||
func _apply_cooler_control_styles() -> void:
|
||||
|
|
@ -768,6 +799,7 @@ func _apply_navigation_styles() -> void:
|
|||
_inventory_tab,
|
||||
_bag_tab,
|
||||
_logbook_tab,
|
||||
_mail_tab,
|
||||
_close_button,
|
||||
]:
|
||||
bubble.add_theme_stylebox_override("normal", normal)
|
||||
|
|
@ -797,6 +829,7 @@ func _apply_navigation_selection_presentation() -> void:
|
|||
_inventory_tab,
|
||||
_bag_tab,
|
||||
_logbook_tab,
|
||||
_mail_tab,
|
||||
]:
|
||||
if not bubble.button_pressed:
|
||||
continue
|
||||
|
|
@ -895,10 +928,16 @@ func _update_navigation_selection() -> void:
|
|||
_set_navigation_target(_current_section)
|
||||
|
||||
|
||||
func _on_mail_unread_count_changed(count: int) -> void:
|
||||
_mail_unread_badge.visible = count > 0
|
||||
_mail_unread_badge.text = "99+" if count > 99 else str(count)
|
||||
|
||||
|
||||
func _set_navigation_target(section: Section) -> void:
|
||||
_inventory_tab.button_pressed = section == Section.COOLER
|
||||
_bag_tab.button_pressed = section == Section.BAG
|
||||
_logbook_tab.button_pressed = section == Section.LOGBOOK
|
||||
_mail_tab.button_pressed = section == Section.MAIL
|
||||
|
||||
|
||||
func _update_shell_layout() -> void:
|
||||
|
|
@ -915,7 +954,7 @@ func _update_shell_layout() -> void:
|
|||
_content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0)
|
||||
_presentation_rest_position = _content_shell.position
|
||||
var navigation_size := (
|
||||
Vector2(620.0, 75.0) if compact else Vector2(620.0, 100.0)
|
||||
Vector2(720.0, 75.0) if compact else Vector2(720.0, 100.0)
|
||||
)
|
||||
_navigation_cluster.position = NAVIGATION_CANONICAL_POSITION
|
||||
_navigation_cluster.size = navigation_size
|
||||
|
|
@ -1084,6 +1123,9 @@ func _update_shell_layout() -> void:
|
|||
_logbook_page.size = reference_size
|
||||
_logbook_page.position = Vector2.ZERO
|
||||
_logbook_rest_position = Vector2.ZERO
|
||||
_mail_page.size = reference_size
|
||||
_mail_page.position = Vector2.ZERO
|
||||
_mail_rest_position = Vector2.ZERO
|
||||
_book_backing.position = (
|
||||
Vector2(14.0, 164.0) if compact else Vector2(58.0, 128.0)
|
||||
)
|
||||
|
|
@ -1144,9 +1186,11 @@ func _update_shell_layout() -> void:
|
|||
_cooler_page.position = _cooler_rest_position
|
||||
_bag_page.position = _bag_rest_position
|
||||
_logbook_page.position = _logbook_rest_position
|
||||
_mail_page.position = _mail_rest_position
|
||||
_cooler_page.modulate.a = 1.0
|
||||
_bag_page.modulate.a = 1.0
|
||||
_logbook_page.modulate.a = 1.0
|
||||
_mail_page.modulate.a = 1.0
|
||||
if not _page_transitioning:
|
||||
_content_stage.position = _content_rest_position
|
||||
_layout_cooler_fish(false)
|
||||
|
|
@ -1229,6 +1273,7 @@ func _begin_menu_entry() -> void:
|
|||
_cooler_page.position = _cooler_rest_position + Vector2.DOWN * start_offset
|
||||
_bag_page.position = _bag_rest_position + Vector2.DOWN * start_offset
|
||||
_logbook_page.position = _logbook_rest_position + Vector2.DOWN * start_offset
|
||||
_mail_page.position = _mail_rest_position + Vector2.DOWN * start_offset
|
||||
var navigation_rest: Vector2 = _navigation_cluster.position
|
||||
_navigation_cluster.position = navigation_rest + Vector2.DOWN * start_offset
|
||||
_presentation_tween = create_tween().set_parallel(true)
|
||||
|
|
@ -1256,6 +1301,12 @@ func _begin_menu_entry() -> void:
|
|||
_logbook_rest_position,
|
||||
MENU_ENTER_DURATION
|
||||
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
_presentation_tween.tween_property(
|
||||
_mail_page,
|
||||
"position",
|
||||
_mail_rest_position,
|
||||
MENU_ENTER_DURATION
|
||||
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
_presentation_tween.tween_property(
|
||||
_navigation_cluster,
|
||||
"position",
|
||||
|
|
@ -1316,6 +1367,12 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void:
|
|||
-_logbook_page.size.y - TRANSITION_SAFE_MARGIN,
|
||||
MENU_EXIT_DURATION
|
||||
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
_presentation_tween.tween_property(
|
||||
_mail_page,
|
||||
"position:y",
|
||||
-_mail_page.size.y - TRANSITION_SAFE_MARGIN,
|
||||
MENU_EXIT_DURATION
|
||||
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
_presentation_tween.tween_property(
|
||||
_navigation_cluster,
|
||||
"position:y",
|
||||
|
|
@ -1377,8 +1434,10 @@ func _get_section_root(section: Section) -> Control:
|
|||
return _cooler_page
|
||||
Section.BAG:
|
||||
return _bag_page
|
||||
_:
|
||||
Section.LOGBOOK:
|
||||
return _logbook_page
|
||||
_:
|
||||
return _mail_page
|
||||
|
||||
|
||||
func _get_section_rest_position(section: Section) -> Vector2:
|
||||
|
|
@ -1387,8 +1446,10 @@ func _get_section_rest_position(section: Section) -> Vector2:
|
|||
return _cooler_rest_position
|
||||
Section.BAG:
|
||||
return _bag_rest_position
|
||||
_:
|
||||
Section.LOGBOOK:
|
||||
return _logbook_rest_position
|
||||
_:
|
||||
return _mail_rest_position
|
||||
|
||||
|
||||
func _begin_page_transition(section: Section) -> void:
|
||||
|
|
@ -1468,6 +1529,7 @@ func _set_shell_interactive(interactive: bool) -> void:
|
|||
_inventory_tab,
|
||||
_bag_tab,
|
||||
_logbook_tab,
|
||||
_mail_tab,
|
||||
_close_button,
|
||||
]:
|
||||
bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
|
||||
|
|
@ -1499,6 +1561,9 @@ func _set_content_interactive(interactive: bool) -> void:
|
|||
if interactive and _current_section == Section.LOGBOOK
|
||||
else Control.MOUSE_FILTER_IGNORE
|
||||
)
|
||||
_mail_page.set_interactive(
|
||||
interactive and _current_section == Section.MAIL
|
||||
)
|
||||
var cooler_interactive: bool = (
|
||||
interactive and _current_section == Section.COOLER
|
||||
)
|
||||
|
|
@ -1569,7 +1634,9 @@ func _cancel_page_tween() -> void:
|
|||
|
||||
|
||||
func _reset_page_transition_visuals() -> void:
|
||||
for section: Section in [Section.COOLER, Section.BAG, Section.LOGBOOK]:
|
||||
for section: Section in [
|
||||
Section.COOLER, Section.BAG, Section.LOGBOOK, Section.MAIL,
|
||||
]:
|
||||
var page: Control = _get_section_root(section)
|
||||
page.position = _get_section_rest_position(section)
|
||||
page.modulate.a = 1.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=19 format=3]
|
||||
[gd_scene load_steps=20 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble1.png" id="15_bubble1"]
|
||||
[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble2.png" id="16_bubble2"]
|
||||
[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble3.png" id="17_bubble3"]
|
||||
[ext_resource type="Script" path="res://ui/mail_page.gd" id="18_mail_page"]
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"]
|
||||
|
||||
|
|
@ -574,6 +575,14 @@ offset_right = 1280.0
|
|||
offset_bottom = 720.0
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="MailPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_right = 1280.0
|
||||
offset_bottom = 720.0
|
||||
mouse_filter = 1
|
||||
script = ExtResource("18_mail_page")
|
||||
|
||||
[node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
|
|
@ -1197,15 +1206,15 @@ custom_minimum_size = Vector2(170, 64)
|
|||
[node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 463.0
|
||||
offset_left = 390.0
|
||||
offset_top = 44.0
|
||||
offset_right = 1083.0
|
||||
offset_right = 1110.0
|
||||
offset_bottom = 144.0
|
||||
mouse_filter = 1
|
||||
script = ExtResource("5_cluster")
|
||||
profile = ExtResource("4_profile")
|
||||
desktop_reference_size = Vector2(620, 100)
|
||||
compact_reference_size = Vector2(620, 100)
|
||||
desktop_reference_size = Vector2(720, 100)
|
||||
compact_reference_size = Vector2(720, 100)
|
||||
|
||||
[node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
|
||||
unique_name_in_owner = true
|
||||
|
|
@ -1267,6 +1276,42 @@ motion_phase = 2.85
|
|||
deformation_amplitude = 0.012
|
||||
deformation_period = 6.7
|
||||
|
||||
[node name="MailTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
toggle_mode = true
|
||||
text = "mail"
|
||||
script = ExtResource("6_bubble")
|
||||
profile = ExtResource("4_profile")
|
||||
neutral_size = Vector2(124, 120)
|
||||
desktop_anchor = Vector2(500, 60)
|
||||
compact_anchor = Vector2(501, 73.3333)
|
||||
compact_minimum_size = Vector2(84, 82)
|
||||
minimum_font_size = 25
|
||||
maximum_font_size = 25
|
||||
horizontal_amplitude = 1.1
|
||||
vertical_amplitude = 2.2
|
||||
motion_period = 5.5
|
||||
motion_phase = 3.55
|
||||
deformation_amplitude = 0.011
|
||||
deformation_period = 6.3
|
||||
|
||||
[node name="MailUnreadBadge" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster/MailTab"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 86.0
|
||||
offset_top = 2.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 34.0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.12, 0.08, 0.04, 1)
|
||||
theme_override_colors/font_outline_color = Color(1, 0.88, 0.34, 1)
|
||||
theme_override_constants/outline_size = 7
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "1"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
|
|
@ -1274,8 +1319,8 @@ text = "close"
|
|||
script = ExtResource("6_bubble")
|
||||
profile = ExtResource("4_profile")
|
||||
neutral_size = Vector2(96, 94)
|
||||
desktop_anchor = Vector2(489, 57)
|
||||
compact_anchor = Vector2(512.667, 73.3333)
|
||||
desktop_anchor = Vector2(626, 57)
|
||||
compact_anchor = Vector2(616, 73.3333)
|
||||
compact_minimum_size = Vector2(76, 74)
|
||||
minimum_font_size = 23
|
||||
maximum_font_size = 23
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue