Add multiplayer item use and session chat
This commit is contained in:
parent
5e04b70609
commit
5dda74c9e4
27 changed files with 1361 additions and 31 deletions
|
|
@ -107,6 +107,7 @@ var _local_hotbar: PlayerHotbarType
|
|||
var _item_catalog: ItemCatalogType
|
||||
var _fishing_upgrades: PlayerFishingUpgradesType
|
||||
var _item_effects: PlayerItemEffectsType
|
||||
var _network_item_use: NetworkItemUseService
|
||||
var _cooler_capacity: PlayerCoolerCapacityType
|
||||
var _network_session: NetworkSessionType
|
||||
var _network_fishing: NetworkFishingServiceType
|
||||
|
|
@ -154,6 +155,7 @@ func setup(
|
|||
item_catalog: ItemCatalogType,
|
||||
fishing_upgrades: PlayerFishingUpgradesType,
|
||||
item_effects: PlayerItemEffectsType,
|
||||
network_item_use: NetworkItemUseService,
|
||||
cooler_capacity: PlayerCoolerCapacityType,
|
||||
network_session: NetworkSessionType = null,
|
||||
network_fishing: NetworkFishingServiceType = null,
|
||||
|
|
@ -166,6 +168,11 @@ func setup(
|
|||
_item_catalog = item_catalog
|
||||
_fishing_upgrades = fishing_upgrades
|
||||
_item_effects = item_effects
|
||||
_network_item_use = network_item_use
|
||||
if _network_item_use != null:
|
||||
_network_item_use.local_item_use_finished.connect(
|
||||
_on_network_item_use_finished
|
||||
)
|
||||
_cooler_capacity = cooler_capacity
|
||||
_network_session = network_session
|
||||
_network_fishing = network_fishing
|
||||
|
|
@ -416,10 +423,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
active_item != null
|
||||
and active_item.category == ItemDataType.Category.CONSUMABLE
|
||||
):
|
||||
if _item_effects.use_consumable(active_item, _local_bag):
|
||||
status_changed.emit(
|
||||
_item_effects.get_feedback(active_item.item_id)
|
||||
)
|
||||
if _network_item_use != null:
|
||||
_network_item_use.request_use(active_item.item_id)
|
||||
elif _item_effects.use_consumable(active_item, _local_bag):
|
||||
status_changed.emit(_item_effects.get_feedback(
|
||||
active_item.item_id
|
||||
))
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if not has_active_fishing_rod():
|
||||
|
|
@ -1112,11 +1121,7 @@ func build_network_context(
|
|||
func _build_network_evidence() -> Dictionary:
|
||||
var rarity_multipliers: Array[float] = []
|
||||
for rarity: int in range(FishDataType.Rarity.size()):
|
||||
rarity_multipliers.append(
|
||||
_item_effects.get_rarity_weight_multiplier(rarity)
|
||||
if _item_effects != null
|
||||
else 1.0
|
||||
)
|
||||
rarity_multipliers.append(1.0)
|
||||
var discovered_ids: Array[String] = []
|
||||
if _local_collection_log != null:
|
||||
for fish_id: StringName in _local_collection_log.get_discovered_ids():
|
||||
|
|
@ -1124,19 +1129,30 @@ func _build_network_evidence() -> Dictionary:
|
|||
var item: ItemDataType = _get_active_item()
|
||||
return {
|
||||
"rod_id": str(item.item_id) if item != null else "",
|
||||
"reel_speed": _get_effective_reel_speed(),
|
||||
"barrier_damage": _get_effective_barrier_damage(),
|
||||
"bite_multiplier": (
|
||||
_item_effects.get_bite_time_multiplier()
|
||||
if _item_effects != null
|
||||
else 1.0
|
||||
"reel_speed": _active_player.reel_speed * (
|
||||
_fishing_upgrades.get_reel_speed_multiplier()
|
||||
if _fishing_upgrades != null else 1.0
|
||||
),
|
||||
"barrier_damage": (
|
||||
_fishing_upgrades.get_barrier_damage()
|
||||
if _fishing_upgrades != null else _active_player.click_power
|
||||
),
|
||||
"bite_multiplier": 1.0,
|
||||
"rarity_multipliers": rarity_multipliers,
|
||||
"discovered_fish_ids": discovered_ids,
|
||||
"capacity_available": not _is_cooler_full(),
|
||||
}
|
||||
|
||||
|
||||
func _on_network_item_use_finished(
|
||||
accepted: bool,
|
||||
message: String,
|
||||
) -> void:
|
||||
status_changed.emit(message)
|
||||
if accepted:
|
||||
refresh_active_item_status()
|
||||
|
||||
|
||||
func _on_network_cast_accepted(
|
||||
_attempt_id: String,
|
||||
target: Vector3,
|
||||
|
|
|
|||
34
main/main.gd
34
main/main.gd
|
|
@ -52,6 +52,12 @@ const NetworkSaleServiceType = preload(
|
|||
const NetworkShopServiceType = preload(
|
||||
"res://network/network_shop_service.gd"
|
||||
)
|
||||
const NetworkItemUseServiceType = preload(
|
||||
"res://network/network_item_use_service.gd"
|
||||
)
|
||||
const NetworkChatServiceType = preload(
|
||||
"res://network/network_chat_service.gd"
|
||||
)
|
||||
|
||||
const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
||||
|
||||
|
|
@ -91,6 +97,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
|||
)
|
||||
@onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService
|
||||
@onready var _network_shop: NetworkShopServiceType = %NetworkShopService
|
||||
@onready var _network_item_use: NetworkItemUseServiceType = (
|
||||
%NetworkItemUseService
|
||||
)
|
||||
@onready var _network_chat: NetworkChatServiceType = %NetworkChatService
|
||||
@onready var _players_root: Node3D = $Players
|
||||
|
||||
var _gameplay_started: bool = false
|
||||
|
|
@ -150,6 +160,15 @@ func _ready() -> void:
|
|||
_player.cooler_capacity
|
||||
)
|
||||
_save_manager.set_autosave_enabled(false)
|
||||
_network_item_use.setup(
|
||||
_network_session,
|
||||
_player_spawn_service,
|
||||
item_catalog,
|
||||
_player.bag,
|
||||
_player.item_effects,
|
||||
_save_manager
|
||||
)
|
||||
_network_chat.setup(_network_session)
|
||||
_network_fishing.setup(
|
||||
_network_session,
|
||||
_player_spawn_service,
|
||||
|
|
@ -159,7 +178,8 @@ func _ready() -> void:
|
|||
_player.cooler_capacity,
|
||||
_save_manager,
|
||||
item_catalog,
|
||||
fish_catalog
|
||||
fish_catalog,
|
||||
_network_item_use
|
||||
)
|
||||
_network_sale.setup(
|
||||
_network_session,
|
||||
|
|
@ -194,6 +214,7 @@ func _ready() -> void:
|
|||
item_catalog,
|
||||
_player.fishing_upgrades,
|
||||
_player.item_effects,
|
||||
_network_item_use,
|
||||
_player.cooler_capacity,
|
||||
_network_session,
|
||||
_network_fishing
|
||||
|
|
@ -217,7 +238,10 @@ func _ready() -> void:
|
|||
_player.cooler_capacity,
|
||||
_network_session,
|
||||
_network_sale,
|
||||
_network_shop
|
||||
_network_shop,
|
||||
_network_chat,
|
||||
_network_profile,
|
||||
_player_spawn_service
|
||||
)
|
||||
_water_recovery.setup(
|
||||
_player,
|
||||
|
|
@ -284,6 +308,7 @@ func _ready() -> void:
|
|||
_player.hotbar.selected_slot_changed.connect(
|
||||
_on_active_hotbar_item_changed
|
||||
)
|
||||
_player.bag.contents_changed.connect(_refresh_active_hotbar_item)
|
||||
_fishing_spot.ready_for_equipment_refresh.connect(
|
||||
_refresh_active_hotbar_item
|
||||
)
|
||||
|
|
@ -409,6 +434,8 @@ func _set_gameplay_active(active: bool) -> void:
|
|||
_water_recovery.set_recovery_enabled(active)
|
||||
_game_ui.set_gameplay_ui_enabled(active)
|
||||
_save_manager.set_autosave_enabled(active)
|
||||
if active:
|
||||
_refresh_active_hotbar_item()
|
||||
|
||||
|
||||
func _on_new_game_requested() -> void:
|
||||
|
|
@ -670,6 +697,9 @@ func _on_active_hotbar_item_changed(
|
|||
and _player.bag.owns_item(item_id)
|
||||
)
|
||||
_player.set_active_item_is_rod(active_is_rod)
|
||||
_network_item_use.submit_local_equipped(item_id, active_is_rod or (
|
||||
item != null and _player.bag.owns_item(item_id)
|
||||
))
|
||||
_fishing_spot.refresh_active_item_status()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=24 format=3]
|
||||
[gd_scene load_steps=26 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"]
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
[ext_resource type="Script" path="res://network/network_fishing_service.gd" id="21_network_fishing"]
|
||||
[ext_resource type="Script" path="res://network/network_sale_service.gd" id="22_network_sale"]
|
||||
[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"]
|
||||
|
||||
[node name="Main" type="Node3D"]
|
||||
script = ExtResource("3_main")
|
||||
|
|
@ -59,6 +61,14 @@ script = ExtResource("22_network_sale")
|
|||
unique_name_in_owner = true
|
||||
script = ExtResource("23_network_shop")
|
||||
|
||||
[node name="NetworkItemUseService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("24_network_item")
|
||||
|
||||
[node name="NetworkChatService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("25_network_chat")
|
||||
|
||||
[node name="TestWorld" parent="." instance=ExtResource("1_world")]
|
||||
|
||||
[node name="Players" type="Node3D" parent="."]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ func start_host(
|
|||
var enet_peer := ENetMultiplayerPeer.new()
|
||||
if not bind_address.is_empty() and bind_address != "*":
|
||||
enet_peer.set_bind_ip(bind_address)
|
||||
var error: Error = enet_peer.create_server(port, max_clients, 5)
|
||||
var error: Error = enet_peer.create_server(
|
||||
port, max_clients, NetworkProtocol.ENET_CHANNEL_COUNT
|
||||
)
|
||||
if error != OK:
|
||||
transport_error.emit("Unable to host UDP port %d." % port)
|
||||
return error
|
||||
|
|
@ -27,7 +29,9 @@ func connect_to_route(route: ConnectionRoute) -> Error:
|
|||
return ERR_INVALID_PARAMETER
|
||||
var endpoint: ConnectionEndpoint = route.direct_endpoint
|
||||
var enet_peer := ENetMultiplayerPeer.new()
|
||||
var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 5)
|
||||
var error: Error = enet_peer.create_client(
|
||||
endpoint.host, endpoint.port, NetworkProtocol.ENET_CHANNEL_COUNT
|
||||
)
|
||||
if error != OK:
|
||||
transport_error.emit(
|
||||
"Unable to connect to %s." % endpoint.normalized_display
|
||||
|
|
|
|||
61
network/network_chat_protocol.gd
Normal file
61
network/network_chat_protocol.gd
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
class_name NetworkChatProtocol
|
||||
extends RefCounted
|
||||
|
||||
const CAPABILITY: StringName = &"chat_v1"
|
||||
const RELIABLE_CHANNEL: int = NetworkProtocol.CHAT_RELIABLE_CHANNEL
|
||||
const MAX_VISIBLE_CHARACTERS: int = 300
|
||||
const MAX_UTF8_BYTES: int = 1200
|
||||
const MAX_HISTORY: int = 100
|
||||
const LATE_JOIN_HISTORY: int = 50
|
||||
const MAX_ID_LENGTH: int = 96
|
||||
|
||||
enum Kind { PLAYER, SYSTEM }
|
||||
|
||||
|
||||
static func sanitize_body(value: Variant) -> String:
|
||||
if typeof(value) != TYPE_STRING:
|
||||
return ""
|
||||
var result: String = str(value).replace("\r", " ").replace("\n", " ")
|
||||
result = result.replace("\t", " ").strip_edges()
|
||||
if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS:
|
||||
return ""
|
||||
if result.to_utf8_buffer().size() > MAX_UTF8_BYTES:
|
||||
return ""
|
||||
for index: int in result.length():
|
||||
var codepoint: int = result.unicode_at(index)
|
||||
if codepoint < 32 or codepoint == 127:
|
||||
return ""
|
||||
return result
|
||||
|
||||
|
||||
static func validate_request(data: Variant) -> bool:
|
||||
return (
|
||||
typeof(data) == TYPE_DICTIONARY
|
||||
and _valid_id(data.get("request_id"))
|
||||
and _valid_id(data.get("session_id"))
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
)
|
||||
|
||||
|
||||
static func validate_message(data: Variant) -> bool:
|
||||
return (
|
||||
typeof(data) == TYPE_DICTIONARY
|
||||
and _valid_id(data.get("message_id"))
|
||||
and _valid_id(data.get("session_id"))
|
||||
and typeof(data.get("sequence")) == TYPE_INT
|
||||
and int(data["sequence"]) >= 0
|
||||
and typeof(data.get("kind")) == TYPE_INT
|
||||
and int(data["kind"]) in [Kind.PLAYER, Kind.SYSTEM]
|
||||
and typeof(data.get("sender_peer_id")) == TYPE_INT
|
||||
and typeof(data.get("sender_display_name")) == TYPE_STRING
|
||||
and str(data["sender_display_name"]).length() <= 24
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
1
network/network_chat_protocol.gd.uid
Normal file
1
network/network_chat_protocol.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c3hg65v27hi2g
|
||||
235
network/network_chat_service.gd
Normal file
235
network/network_chat_service.gd
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
class_name NetworkChatService
|
||||
extends Node
|
||||
|
||||
const BURST_COUNT: int = 3
|
||||
const WINDOW_COUNT: int = 5
|
||||
const WINDOW_SECONDS: float = 10.0
|
||||
|
||||
signal message_received(message: Dictionary)
|
||||
signal send_rejected(message: String)
|
||||
signal history_replaced(messages: Array[Dictionary])
|
||||
|
||||
var _session: NetworkSession
|
||||
var _history: Array[Dictionary] = []
|
||||
var _seen_messages: Dictionary[String, bool] = {}
|
||||
var _request_ledgers: Dictionary[int, Dictionary] = {}
|
||||
var _rate_times: Dictionary[int, Array] = {}
|
||||
var _sequence: int = 0
|
||||
var _peer_names: Dictionary[int, String] = {}
|
||||
|
||||
|
||||
func setup(session: NetworkSession) -> void:
|
||||
_session = session
|
||||
_session.peer_authenticated.connect(_on_peer_authenticated)
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.state_changed.connect(_on_session_state_changed)
|
||||
_session.peer_display_name_changed.connect(
|
||||
func(peer_id: int, display_name: String) -> void:
|
||||
_peer_names[peer_id] = display_name
|
||||
)
|
||||
|
||||
|
||||
func send_local_message(body: String) -> bool:
|
||||
if (
|
||||
_session == null
|
||||
or not _session.is_gameplay_session_active()
|
||||
or (
|
||||
not _session.is_host()
|
||||
and not _session.supports_server_capability(
|
||||
NetworkChatProtocol.CAPABILITY
|
||||
)
|
||||
)
|
||||
):
|
||||
send_rejected.emit("Chat is unavailable.")
|
||||
return false
|
||||
var clean := NetworkChatProtocol.sanitize_body(body)
|
||||
if clean.is_empty():
|
||||
send_rejected.emit("Message is empty or too long.")
|
||||
return false
|
||||
var request := {
|
||||
"request_id": _new_id("chat_request"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"body": clean,
|
||||
}
|
||||
if _session.is_host():
|
||||
_handle_request(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
submit_chat_message.rpc_id(1, request)
|
||||
return true
|
||||
|
||||
|
||||
func get_history() -> Array[Dictionary]:
|
||||
return _history.duplicate(true)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
|
||||
func submit_chat_message(data: Dictionary) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
||||
_handle_request(sender_id, data)
|
||||
|
||||
|
||||
func _handle_request(peer_id: int, data: Dictionary) -> void:
|
||||
if (
|
||||
not NetworkChatProtocol.validate_request(data)
|
||||
or str(data["session_id"]) != _session.get_session_id()
|
||||
):
|
||||
_send_rejection(peer_id, "Message could not be sent.")
|
||||
return
|
||||
var request_id: String = data["request_id"]
|
||||
var ledger: Dictionary = _request_ledgers.get(peer_id, {})
|
||||
if ledger.has(request_id):
|
||||
_send_message(peer_id, ledger[request_id])
|
||||
return
|
||||
if not _consume_rate(peer_id):
|
||||
_send_rejection(peer_id, "Slow down.")
|
||||
return
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if record == null:
|
||||
return
|
||||
var message := _make_message(
|
||||
NetworkChatProtocol.Kind.PLAYER,
|
||||
peer_id,
|
||||
record.display_name,
|
||||
NetworkChatProtocol.sanitize_body(data["body"])
|
||||
)
|
||||
ledger[request_id] = message.duplicate(true)
|
||||
while ledger.size() > 64:
|
||||
ledger.erase(ledger.keys().front())
|
||||
_request_ledgers[peer_id] = ledger
|
||||
_broadcast(message)
|
||||
|
||||
|
||||
func _make_message(
|
||||
kind: int,
|
||||
peer_id: int,
|
||||
display_name: String,
|
||||
body: String,
|
||||
) -> Dictionary:
|
||||
_sequence += 1
|
||||
return {
|
||||
"message_id": _new_id("chat"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"sequence": _sequence,
|
||||
"kind": kind,
|
||||
"sender_peer_id": peer_id,
|
||||
"sender_display_name": display_name.left(24),
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
func _broadcast(message: Dictionary) -> void:
|
||||
_apply_message(message)
|
||||
receive_chat_message.rpc(message)
|
||||
|
||||
|
||||
func _send_message(peer_id: int, message: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_apply_message(message)
|
||||
else:
|
||||
receive_chat_message.rpc_id(peer_id, message)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
|
||||
func receive_chat_message(data: Dictionary) -> void:
|
||||
_apply_message(data)
|
||||
|
||||
|
||||
func _apply_message(data: Dictionary) -> void:
|
||||
if (
|
||||
not NetworkChatProtocol.validate_message(data)
|
||||
or str(data["session_id"]) != _session.get_session_id()
|
||||
or _seen_messages.has(str(data["message_id"]))
|
||||
):
|
||||
return
|
||||
_seen_messages[data["message_id"]] = true
|
||||
_history.append(data.duplicate(true))
|
||||
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
|
||||
_history.pop_front()
|
||||
message_received.emit(data.duplicate(true))
|
||||
|
||||
|
||||
func _send_rejection(peer_id: int, message: String) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
send_rejected.emit(message)
|
||||
else:
|
||||
receive_chat_rejection.rpc_id(peer_id, message.left(80))
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
|
||||
func receive_chat_rejection(message: String) -> void:
|
||||
send_rejected.emit(message.left(80))
|
||||
|
||||
|
||||
func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
|
||||
if not _session.is_host():
|
||||
return
|
||||
_peer_names[peer_id] = display_name
|
||||
var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY)
|
||||
receive_chat_history.rpc_id(peer_id, _history.slice(start))
|
||||
_broadcast(_make_message(
|
||||
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
|
||||
))
|
||||
|
||||
|
||||
func _on_peer_removed(peer_id: int) -> void:
|
||||
_request_ledgers.erase(peer_id)
|
||||
_rate_times.erase(peer_id)
|
||||
if not _session.is_host():
|
||||
return
|
||||
var name: String = _peer_names.get(peer_id, "Player")
|
||||
_peer_names.erase(peer_id)
|
||||
_broadcast(_make_message(
|
||||
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % name
|
||||
))
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
|
||||
func receive_chat_history(values: Array) -> void:
|
||||
if values.size() > NetworkChatProtocol.LATE_JOIN_HISTORY:
|
||||
return
|
||||
_history.clear()
|
||||
_seen_messages.clear()
|
||||
for value: Variant in values:
|
||||
if NetworkChatProtocol.validate_message(value):
|
||||
_apply_message(value)
|
||||
history_replaced.emit(_history.duplicate(true))
|
||||
|
||||
|
||||
func _consume_rate(peer_id: int) -> bool:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
var times: Array = _rate_times.get(peer_id, [])
|
||||
while not times.is_empty() and now - float(times.front()) > WINDOW_SECONDS:
|
||||
times.pop_front()
|
||||
var recent_burst := 0
|
||||
for value: Variant in times:
|
||||
if now - float(value) <= 1.0:
|
||||
recent_burst += 1
|
||||
if recent_burst >= BURST_COUNT or times.size() >= WINDOW_COUNT:
|
||||
_rate_times[peer_id] = times
|
||||
return false
|
||||
times.append(now)
|
||||
_rate_times[peer_id] = times
|
||||
return true
|
||||
|
||||
|
||||
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,
|
||||
]:
|
||||
_history.clear()
|
||||
_seen_messages.clear()
|
||||
_request_ledgers.clear()
|
||||
_rate_times.clear()
|
||||
_peer_names.clear()
|
||||
_sequence = 0
|
||||
history_replaced.emit([])
|
||||
|
||||
|
||||
func _new_id(prefix: String) -> String:
|
||||
return "%s:%s" % [
|
||||
prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
|
||||
]
|
||||
1
network/network_chat_service.gd.uid
Normal file
1
network/network_chat_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b10jayoog5dc2
|
||||
|
|
@ -32,6 +32,7 @@ var _local_capacity: PlayerCoolerCapacity
|
|||
var _save_manager: PlayerSaveManager
|
||||
var _item_catalog: ItemCatalog
|
||||
var _fish_catalog: FishPoolType
|
||||
var _item_use: NetworkItemUseService
|
||||
var _attempts: Dictionary[int, NetworkFishingAttempt] = {}
|
||||
var _request_ledgers: Dictionary[int, Dictionary] = {}
|
||||
var _result_ledgers: Dictionary[String, bool] = {}
|
||||
|
|
@ -53,6 +54,7 @@ func setup(
|
|||
save_manager: PlayerSaveManager,
|
||||
item_catalog: ItemCatalog,
|
||||
fish_catalog: FishPoolType,
|
||||
item_use: NetworkItemUseService,
|
||||
) -> void:
|
||||
_session = session
|
||||
_spawn_service = spawn_service
|
||||
|
|
@ -63,6 +65,7 @@ func setup(
|
|||
_save_manager = save_manager
|
||||
_item_catalog = item_catalog
|
||||
_fish_catalog = fish_catalog
|
||||
_item_use = item_use
|
||||
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):
|
||||
|
|
@ -259,8 +262,12 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
|
|||
if region == null or region.fish_pool == null:
|
||||
_record_and_reject(peer_id, request_id, "Cannot fish here.")
|
||||
return
|
||||
var effects: PlayerItemEffects = (
|
||||
_item_use.get_effects_for_peer(peer_id)
|
||||
if _item_use != null else null
|
||||
)
|
||||
var selected_fish: FishDataType = _select_authoritative_fish(
|
||||
region, data
|
||||
region, data, effects
|
||||
)
|
||||
if selected_fish == null:
|
||||
_record_and_reject(peer_id, request_id, "Nothing is biting here.")
|
||||
|
|
@ -275,10 +282,14 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
|
|||
attempt.target = target
|
||||
attempt.bobber_position = target
|
||||
attempt.fish_id = selected_fish.id
|
||||
attempt.reel_speed = float(data["reel_speed"])
|
||||
attempt.barrier_damage = int(data["barrier_damage"])
|
||||
attempt.reel_speed = float(data["reel_speed"]) * (
|
||||
effects.get_reel_multiplier() if effects != null else 1.0
|
||||
)
|
||||
attempt.barrier_damage = int(data["barrier_damage"]) + (
|
||||
effects.get_barrier_bonus() if effects != null else 0
|
||||
)
|
||||
attempt.bite_time_remaining = _fishing_spot.wait_time * float(
|
||||
data["bite_multiplier"]
|
||||
effects.get_bite_time_multiplier() if effects != null else 1.0
|
||||
)
|
||||
attempt.controller = CatchController.new()
|
||||
add_child(attempt.controller)
|
||||
|
|
@ -359,6 +370,7 @@ func _make_waiting_snapshot(
|
|||
func _select_authoritative_fish(
|
||||
region: FishableWaterRegion,
|
||||
data: Dictionary,
|
||||
effects: PlayerItemEffects,
|
||||
) -> FishDataType:
|
||||
var evidence_log := CollectionLogType.new()
|
||||
for value: Variant in data["discovered_fish_ids"]:
|
||||
|
|
@ -370,8 +382,11 @@ func _select_authoritative_fish(
|
|||
selector.undiscovered_weight_multiplier = (
|
||||
_fishing_spot.undiscovered_weight_multiplier
|
||||
)
|
||||
for value: Variant in data["rarity_multipliers"]:
|
||||
selector.rarity_weight_multipliers.append(float(value))
|
||||
for rarity: int in range(FishDataType.Rarity.size()):
|
||||
selector.rarity_weight_multipliers.append(
|
||||
effects.get_rarity_weight_multiplier(rarity)
|
||||
if effects != null else 1.0
|
||||
)
|
||||
selector.begin_roll()
|
||||
var context: FishingContextType = _fishing_spot.build_network_context(region)
|
||||
return selector.select_fish(region.fish_pool, context, evidence_log)
|
||||
|
|
|
|||
83
network/network_item_protocol.gd
Normal file
83
network/network_item_protocol.gd
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
class_name NetworkItemProtocol
|
||||
extends RefCounted
|
||||
|
||||
const ITEM_USE_CAPABILITY: StringName = &"item_use_v1"
|
||||
const EQUIPMENT_CAPABILITY: StringName = &"equipment_v1"
|
||||
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
|
||||
const MAX_ID_LENGTH: int = 96
|
||||
const MAX_ITEM_ID_LENGTH: int = 96
|
||||
const MAX_MESSAGE_LENGTH: int = 160
|
||||
|
||||
|
||||
static func validate_use_request(data: Variant) -> String:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return "Item use could not be completed."
|
||||
var value: Dictionary = data
|
||||
for key: String in [
|
||||
"request_id", "session_id", "item_id", "quantity",
|
||||
]:
|
||||
if not value.has(key):
|
||||
return "Item use could not be completed."
|
||||
if (
|
||||
not _valid_id(value["request_id"])
|
||||
or not _valid_id(value["session_id"])
|
||||
or typeof(value["item_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
or str(value["item_id"]).is_empty()
|
||||
or str(value["item_id"]).length() > MAX_ITEM_ID_LENGTH
|
||||
or typeof(value["quantity"]) != TYPE_INT
|
||||
or int(value["quantity"]) < 1
|
||||
or int(value["quantity"]) > 99
|
||||
):
|
||||
return "Item use could not be completed."
|
||||
return ""
|
||||
|
||||
|
||||
static func validate_use_result(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
_valid_id(value.get("result_id"))
|
||||
and _valid_id(value.get("request_id"))
|
||||
and _valid_id(value.get("session_id"))
|
||||
and typeof(value.get("target_peer_id")) == TYPE_INT
|
||||
and typeof(value.get("accepted")) == TYPE_BOOL
|
||||
and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
and not str(value["item_id"]).is_empty()
|
||||
and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH
|
||||
and typeof(value.get("quantity")) == TYPE_INT
|
||||
and int(value["quantity"]) >= 0
|
||||
and int(value["quantity"]) <= 1
|
||||
and typeof(value.get("duration")) in [TYPE_FLOAT, TYPE_INT]
|
||||
and is_finite(float(value["duration"]))
|
||||
and float(value["duration"]) >= 0.0
|
||||
and typeof(value.get("message")) == TYPE_STRING
|
||||
and str(value["message"]).length() <= MAX_MESSAGE_LENGTH
|
||||
)
|
||||
|
||||
|
||||
static func validate_equipped_state(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
_valid_id(value.get("session_id"))
|
||||
and typeof(value.get("owner_peer_id")) == TYPE_INT
|
||||
and int(value["owner_peer_id"]) > 0
|
||||
and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH
|
||||
and typeof(value.get("category")) == TYPE_INT
|
||||
and int(value["category"]) >= -1
|
||||
and int(value["category"]) < ItemData.Category.size()
|
||||
and typeof(value.get("revision")) == TYPE_INT
|
||||
and int(value["revision"]) >= 0
|
||||
and typeof(value.get("owns_item")) == TYPE_BOOL
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
1
network/network_item_protocol.gd.uid
Normal file
1
network/network_item_protocol.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bphibe6g81mhm
|
||||
388
network/network_item_use_service.gd
Normal file
388
network/network_item_use_service.gd
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
class_name NetworkItemUseService
|
||||
extends Node
|
||||
|
||||
const MAX_LEDGER_ENTRIES: int = 64
|
||||
|
||||
signal local_item_use_pending(request_id: String)
|
||||
signal local_item_use_finished(accepted: bool, message: String)
|
||||
signal equipped_state_changed(peer_id: int, item_id: StringName, category: int)
|
||||
|
||||
var _session: NetworkSession
|
||||
var _spawn_service: PlayerSpawnService
|
||||
var _catalog: ItemCatalog
|
||||
var _local_bag: PlayerBag
|
||||
var _local_effects: PlayerItemEffects
|
||||
var _save_manager: PlayerSaveManager
|
||||
var _requests: Dictionary[int, Dictionary] = {}
|
||||
var _pending_by_peer: Dictionary[int, String] = {}
|
||||
var _result_owners: Dictionary[String, int] = {}
|
||||
var _received_results: Dictionary[String, bool] = {}
|
||||
var _pending_local: Dictionary = {}
|
||||
var _equipped_states: Dictionary[int, Dictionary] = {}
|
||||
var _local_equipped_revision: int = 0
|
||||
|
||||
|
||||
func setup(
|
||||
session: NetworkSession,
|
||||
spawn_service: PlayerSpawnService,
|
||||
catalog: ItemCatalog,
|
||||
local_bag: PlayerBag,
|
||||
local_effects: PlayerItemEffects,
|
||||
save_manager: PlayerSaveManager,
|
||||
) -> void:
|
||||
_session = session
|
||||
_spawn_service = spawn_service
|
||||
_catalog = catalog
|
||||
_local_bag = local_bag
|
||||
_local_effects = local_effects
|
||||
_save_manager = save_manager
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.state_changed.connect(_on_session_state_changed)
|
||||
_session.peer_authenticated.connect(_on_peer_authenticated)
|
||||
|
||||
|
||||
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 (
|
||||
_session == null
|
||||
or not _session.is_gameplay_session_active()
|
||||
or (
|
||||
not _session.is_host()
|
||||
and not _session.supports_server_capability(
|
||||
NetworkItemProtocol.ITEM_USE_CAPABILITY
|
||||
)
|
||||
)
|
||||
):
|
||||
local_item_use_finished.emit(false, "Item use is unavailable.")
|
||||
return ""
|
||||
var request_id := _new_id("item")
|
||||
var data := {
|
||||
"request_id": request_id,
|
||||
"session_id": _session.get_session_id(),
|
||||
"item_id": str(item_id),
|
||||
"quantity": _local_bag.get_quantity(item_id),
|
||||
}
|
||||
_pending_local = data.duplicate(true)
|
||||
local_item_use_pending.emit(request_id)
|
||||
if _session.is_host():
|
||||
_handle_use_request(_session.get_local_peer_id(), data)
|
||||
else:
|
||||
submit_item_use.rpc_id(1, data)
|
||||
return request_id
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func submit_item_use(data: Dictionary) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
||||
_handle_use_request(sender_id, data)
|
||||
|
||||
|
||||
func _handle_use_request(peer_id: int, data: Dictionary) -> void:
|
||||
var request_id := str(data.get("request_id", ""))
|
||||
var ledger: Dictionary = _requests.get(peer_id, {})
|
||||
if ledger.has(request_id):
|
||||
_send_result(peer_id, ledger[request_id])
|
||||
return
|
||||
var error := NetworkItemProtocol.validate_use_request(data)
|
||||
if (
|
||||
error.is_empty()
|
||||
and str(data["session_id"]) != _session.get_session_id()
|
||||
):
|
||||
error = "Item use could not be completed."
|
||||
if error.is_empty() and _pending_by_peer.has(peer_id):
|
||||
error = "An item use is already pending."
|
||||
var item_id := StringName(str(data.get("item_id", "")))
|
||||
var item: ItemData = _catalog.get_item_by_id(item_id)
|
||||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
var duration: float = 0.0
|
||||
if error.is_empty() and (
|
||||
item == null
|
||||
or item.category != ItemData.Category.CONSUMABLE
|
||||
or not item.usable
|
||||
):
|
||||
error = "That item cannot be used now."
|
||||
if error.is_empty() and int(data["quantity"]) < 1:
|
||||
error = "You do not have that item."
|
||||
if error.is_empty() and (
|
||||
avatar == null
|
||||
or avatar.is_water_recovery_active()
|
||||
):
|
||||
error = "That item cannot be used now."
|
||||
if error.is_empty():
|
||||
duration = avatar.item_effects.get_effect_duration(item_id)
|
||||
if duration <= 0.0:
|
||||
error = "That item cannot be used now."
|
||||
var result := {
|
||||
"result_id": _new_id("item_result"),
|
||||
"request_id": request_id if not request_id.is_empty() else "invalid",
|
||||
"session_id": _session.get_session_id(),
|
||||
"target_peer_id": peer_id,
|
||||
"accepted": error.is_empty(),
|
||||
"item_id": str(item_id) if not item_id.is_empty() else "invalid",
|
||||
"quantity": 1 if error.is_empty() else 0,
|
||||
"duration": duration if error.is_empty() else 0.0,
|
||||
"message": "Used %s." % item.display_name if error.is_empty() else error,
|
||||
}
|
||||
ledger[request_id] = result.duplicate(true)
|
||||
_bound(ledger)
|
||||
_requests[peer_id] = ledger
|
||||
_result_owners[result["result_id"]] = peer_id
|
||||
_pending_by_peer[peer_id] = request_id
|
||||
_send_result(peer_id, result)
|
||||
|
||||
|
||||
func _send_result(peer_id: int, result: Dictionary) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_apply_result(result)
|
||||
else:
|
||||
receive_item_use_result.rpc_id(peer_id, result)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func receive_item_use_result(data: Dictionary) -> void:
|
||||
_apply_result(data)
|
||||
|
||||
|
||||
func _apply_result(data: Dictionary) -> void:
|
||||
if (
|
||||
not NetworkItemProtocol.validate_use_result(data)
|
||||
or str(data["session_id"]) != _session.get_session_id()
|
||||
or int(data["target_peer_id"]) != _session.get_local_peer_id()
|
||||
):
|
||||
return
|
||||
var result_id: String = data["result_id"]
|
||||
if _received_results.has(result_id):
|
||||
_acknowledge(data, true, "")
|
||||
return
|
||||
if (
|
||||
_pending_local.is_empty()
|
||||
or str(data["request_id"]) != str(_pending_local.get("request_id", ""))
|
||||
):
|
||||
return
|
||||
if not bool(data["accepted"]):
|
||||
_received_results[result_id] = true
|
||||
_pending_local.clear()
|
||||
local_item_use_finished.emit(false, str(data["message"]))
|
||||
_acknowledge(data, false, str(data["message"]))
|
||||
return
|
||||
var item_id := StringName(str(data["item_id"]))
|
||||
var bag_snapshot := _local_bag.get_all_items()
|
||||
if (
|
||||
_local_bag.get_quantity(item_id) < 1
|
||||
or not _local_bag.remove_item(item_id, 1)
|
||||
or not _save_manager.save_if_dirty()
|
||||
):
|
||||
_local_bag.replace_all_items(bag_snapshot)
|
||||
_save_manager.save_if_dirty()
|
||||
_pending_local.clear()
|
||||
local_item_use_finished.emit(false, "Item use could not be completed.")
|
||||
_acknowledge(data, false, "Item use could not be completed.")
|
||||
return
|
||||
_received_results[result_id] = true
|
||||
_bound(_received_results)
|
||||
_pending_local.clear()
|
||||
local_item_use_finished.emit(true, str(data["message"]))
|
||||
_acknowledge(data, true, "")
|
||||
|
||||
|
||||
func _acknowledge(data: Dictionary, applied: bool, message: String) -> void:
|
||||
if _session.is_host():
|
||||
_handle_ack(
|
||||
_session.get_local_peer_id(),
|
||||
str(data["result_id"]),
|
||||
str(data["request_id"]),
|
||||
applied,
|
||||
message
|
||||
)
|
||||
else:
|
||||
acknowledge_item_use.rpc_id(
|
||||
1, str(data["result_id"]), str(data["request_id"]), applied,
|
||||
message.left(NetworkItemProtocol.MAX_MESSAGE_LENGTH)
|
||||
)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func acknowledge_item_use(
|
||||
result_id: String,
|
||||
request_id: String,
|
||||
applied: bool,
|
||||
message: String,
|
||||
) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
||||
_handle_ack(sender_id, result_id, request_id, applied, message)
|
||||
|
||||
|
||||
func _handle_ack(
|
||||
peer_id: int,
|
||||
result_id: String,
|
||||
request_id: String,
|
||||
applied: bool,
|
||||
_message: String,
|
||||
) -> void:
|
||||
if (
|
||||
_result_owners.get(result_id, 0) != peer_id
|
||||
or _pending_by_peer.get(peer_id, "") != request_id
|
||||
):
|
||||
return
|
||||
_pending_by_peer.erase(peer_id)
|
||||
if not applied:
|
||||
return
|
||||
var result: Dictionary = _requests.get(peer_id, {}).get(request_id, {})
|
||||
var avatar := _spawn_service.get_avatar(peer_id)
|
||||
if avatar == null or result.is_empty():
|
||||
return
|
||||
var item_id := StringName(str(result["item_id"]))
|
||||
avatar.item_effects.activate_authoritative(item_id, float(result["duration"]))
|
||||
_broadcast_effect(peer_id, item_id, float(result["duration"]))
|
||||
|
||||
|
||||
func get_effects_for_peer(peer_id: int) -> PlayerItemEffects:
|
||||
var avatar := _spawn_service.get_avatar(peer_id)
|
||||
return avatar.item_effects if avatar != null else null
|
||||
|
||||
|
||||
func submit_local_equipped(item_id: StringName, owns_item: bool) -> void:
|
||||
if _session == null or not _session.is_gameplay_session_active():
|
||||
return
|
||||
_local_equipped_revision += 1
|
||||
var item: ItemData = _catalog.get_item_by_id(item_id)
|
||||
var data := {
|
||||
"session_id": _session.get_session_id(),
|
||||
"owner_peer_id": _session.get_local_peer_id(),
|
||||
"item_id": str(item_id) if item != null and owns_item else "",
|
||||
"category": int(item.category) if item != null and owns_item else -1,
|
||||
"revision": _local_equipped_revision,
|
||||
"owns_item": owns_item and item != null,
|
||||
}
|
||||
if _session.is_host():
|
||||
_handle_equipped_state(_session.get_local_peer_id(), data)
|
||||
elif _session.supports_server_capability(
|
||||
NetworkItemProtocol.EQUIPMENT_CAPABILITY
|
||||
):
|
||||
submit_equipped_state.rpc_id(1, data)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func submit_equipped_state(data: Dictionary) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
||||
_handle_equipped_state(sender_id, data)
|
||||
|
||||
|
||||
func _handle_equipped_state(peer_id: int, data: Dictionary) -> void:
|
||||
if (
|
||||
not NetworkItemProtocol.validate_equipped_state(data)
|
||||
or str(data["session_id"]) != _session.get_session_id()
|
||||
or int(data["owner_peer_id"]) != peer_id
|
||||
):
|
||||
return
|
||||
var previous: Dictionary = _equipped_states.get(peer_id, {})
|
||||
if int(data["revision"]) <= int(previous.get("revision", -1)):
|
||||
return
|
||||
var item_id := StringName(str(data["item_id"]))
|
||||
var item: ItemData = _catalog.get_item_by_id(item_id)
|
||||
if not item_id.is_empty() and (
|
||||
item == null
|
||||
or not bool(data["owns_item"])
|
||||
or int(item.category) != int(data["category"])
|
||||
):
|
||||
return
|
||||
_equipped_states[peer_id] = data.duplicate(true)
|
||||
_apply_equipped(data)
|
||||
receive_equipped_state.rpc(data)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func receive_equipped_state(data: Dictionary) -> void:
|
||||
if NetworkItemProtocol.validate_equipped_state(data):
|
||||
_equipped_states[int(data["owner_peer_id"])] = data.duplicate(true)
|
||||
_apply_equipped(data)
|
||||
|
||||
|
||||
func _apply_equipped(data: Dictionary) -> void:
|
||||
var peer_id: int = data["owner_peer_id"]
|
||||
var avatar := _spawn_service.get_avatar(peer_id)
|
||||
if avatar != null:
|
||||
avatar.set_active_item_is_rod(
|
||||
int(data["category"]) == ItemData.Category.ROD
|
||||
)
|
||||
equipped_state_changed.emit(
|
||||
peer_id, StringName(str(data["item_id"])), int(data["category"])
|
||||
)
|
||||
|
||||
|
||||
func _broadcast_effect(peer_id: int, item_id: StringName, duration: float) -> void:
|
||||
var data := {
|
||||
"session_id": _session.get_session_id(),
|
||||
"owner_peer_id": peer_id,
|
||||
"item_id": str(item_id),
|
||||
"remaining": duration,
|
||||
}
|
||||
_apply_effect_snapshot(data)
|
||||
receive_effect_snapshot.rpc(data)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
|
||||
func receive_effect_snapshot(data: Dictionary) -> void:
|
||||
_apply_effect_snapshot(data)
|
||||
|
||||
|
||||
func _apply_effect_snapshot(data: Dictionary) -> void:
|
||||
if (
|
||||
typeof(data.get("owner_peer_id")) != TYPE_INT
|
||||
or typeof(data.get("item_id")) != TYPE_STRING
|
||||
or typeof(data.get("remaining")) not in [TYPE_FLOAT, TYPE_INT]
|
||||
or str(data.get("session_id", "")) != _session.get_session_id()
|
||||
):
|
||||
return
|
||||
var avatar := _spawn_service.get_avatar(int(data["owner_peer_id"]))
|
||||
if avatar != null:
|
||||
avatar.item_effects.activate_authoritative(
|
||||
StringName(data["item_id"]), float(data["remaining"])
|
||||
)
|
||||
|
||||
|
||||
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
||||
if not _session.is_host():
|
||||
return
|
||||
for state: Dictionary in _equipped_states.values():
|
||||
receive_equipped_state.rpc_id(peer_id, state)
|
||||
|
||||
|
||||
func _on_peer_removed(peer_id: int) -> void:
|
||||
_requests.erase(peer_id)
|
||||
_pending_by_peer.erase(peer_id)
|
||||
_equipped_states.erase(peer_id)
|
||||
|
||||
|
||||
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,
|
||||
]:
|
||||
var pending := not _pending_local.is_empty()
|
||||
_requests.clear()
|
||||
_pending_by_peer.clear()
|
||||
_result_owners.clear()
|
||||
_received_results.clear()
|
||||
_equipped_states.clear()
|
||||
_pending_local.clear()
|
||||
if pending:
|
||||
local_item_use_finished.emit(false, "Connection lost.")
|
||||
|
||||
|
||||
func _bound(values: Dictionary) -> void:
|
||||
while values.size() > MAX_LEDGER_ENTRIES:
|
||||
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_item_use_service.gd.uid
Normal file
1
network/network_item_use_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dx21uu253nmpw
|
||||
|
|
@ -34,6 +34,29 @@ func load_or_create() -> bool:
|
|||
return _save_atomic()
|
||||
|
||||
|
||||
func set_display_name(value: String) -> bool:
|
||||
var clean_name: String = value.strip_edges()
|
||||
if not is_valid_display_name(clean_name):
|
||||
return false
|
||||
var previous: String = display_name
|
||||
display_name = clean_name
|
||||
if _save_atomic():
|
||||
return true
|
||||
display_name = previous
|
||||
return false
|
||||
|
||||
|
||||
static func is_valid_display_name(value: String) -> bool:
|
||||
var clean_name := value.strip_edges()
|
||||
if clean_name.is_empty() or clean_name.length() > 24:
|
||||
return false
|
||||
for index: int in clean_name.length():
|
||||
var codepoint: int = clean_name.unicode_at(index)
|
||||
if codepoint < 32 or codepoint == 127:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _load_existing() -> bool:
|
||||
var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ extends RefCounted
|
|||
|
||||
const PROTOCOL_VERSION: int = 2
|
||||
const GAME_BUILD: String = "prealpha"
|
||||
const MAX_DISPLAY_NAME_LENGTH: int = 48
|
||||
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
||||
const MAX_PROFILE_ID_LENGTH: int = 96
|
||||
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.
|
||||
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
|
||||
# 8 reliable ordered session chat.
|
||||
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
|
||||
|
||||
enum RejectionCode {
|
||||
NONE,
|
||||
|
|
@ -109,6 +113,9 @@ static func make_server_hello(
|
|||
"fishing_v1",
|
||||
"sale_v1",
|
||||
"shop_v1",
|
||||
"item_use_v1",
|
||||
"equipment_v1",
|
||||
"chat_v1",
|
||||
]),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ signal peer_authenticated(peer_id: int, display_name: String)
|
|||
signal peer_removed(peer_id: int)
|
||||
signal host_openness_changed(is_open: bool)
|
||||
signal peer_count_changed(player_count: int, max_players: int)
|
||||
signal peer_display_name_changed(peer_id: int, display_name: String)
|
||||
signal join_authenticated
|
||||
signal server_lost
|
||||
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
|
||||
|
|
@ -275,10 +276,50 @@ func supports_server_capability(capability: StringName) -> bool:
|
|||
if is_host():
|
||||
return str(capability) in PackedStringArray([
|
||||
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
|
||||
"item_use_v1", "equipment_v1", "chat_v1",
|
||||
])
|
||||
return str(capability) in _server_capabilities
|
||||
|
||||
|
||||
func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord:
|
||||
return _registry.get_peer(peer_id)
|
||||
|
||||
|
||||
func update_local_display_name(value: String) -> bool:
|
||||
if _profile == null or not _profile.set_display_name(value):
|
||||
return false
|
||||
var peer_id := get_local_peer_id()
|
||||
if is_host():
|
||||
_apply_display_name(peer_id, _profile.display_name)
|
||||
receive_display_name.rpc(peer_id, _profile.display_name)
|
||||
elif is_joined_client() and supports_server_capability(&"chat_v1"):
|
||||
submit_display_name.rpc_id(1, _profile.display_name)
|
||||
return true
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func submit_display_name(value: String) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if (
|
||||
is_host()
|
||||
and is_authenticated_peer(sender_id)
|
||||
and NetworkProfilePreferences.is_valid_display_name(value)
|
||||
):
|
||||
_apply_display_name(sender_id, value.strip_edges())
|
||||
receive_display_name.rpc(sender_id, value.strip_edges())
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_display_name(peer_id: int, value: String) -> void:
|
||||
if NetworkProfilePreferences.is_valid_display_name(value):
|
||||
_apply_display_name(peer_id, value.strip_edges())
|
||||
|
||||
|
||||
func _apply_display_name(peer_id: int, value: String) -> void:
|
||||
if _registry.update_display_name(peer_id, value):
|
||||
peer_display_name_changed.emit(peer_id, value)
|
||||
|
||||
|
||||
func get_local_peer_id() -> int:
|
||||
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
|
||||
|
||||
|
|
@ -414,6 +455,12 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
)
|
||||
return
|
||||
var display_name: String = data["display_name"]
|
||||
if not NetworkProfilePreferences.is_valid_display_name(display_name):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
|
||||
)
|
||||
return
|
||||
if not _registry.add_peer(
|
||||
sender_id,
|
||||
profile_id,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ func get_peer(peer_id: int) -> PeerRecord:
|
|||
return _records.get(peer_id)
|
||||
|
||||
|
||||
func update_display_name(peer_id: int, display_name: String) -> bool:
|
||||
var record: PeerRecord = _records.get(peer_id)
|
||||
if record == null or display_name.is_empty():
|
||||
return false
|
||||
record.display_name = display_name
|
||||
return true
|
||||
|
||||
|
||||
func has_peer(peer_id: int) -> bool:
|
||||
return _records.has(peer_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -536,6 +536,10 @@ func get_body_center_height() -> float:
|
|||
return body_center_height
|
||||
|
||||
|
||||
func get_gameplay_camera() -> Camera3D:
|
||||
return _camera
|
||||
|
||||
|
||||
func get_fishing_rod_tip() -> Marker3D:
|
||||
return _fishing_rod_tip
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,28 @@ func use_consumable(
|
|||
return true
|
||||
|
||||
|
||||
func activate_authoritative(item_id: StringName, duration: float) -> bool:
|
||||
if not _remaining.has(item_id) or not is_finite(duration) or duration <= 0.0:
|
||||
return false
|
||||
_remaining[item_id] = duration
|
||||
effects_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func restore_remaining(snapshot: Dictionary[StringName, float]) -> void:
|
||||
for item_id: StringName in _remaining:
|
||||
_remaining[item_id] = maxf(snapshot.get(item_id, 0.0), 0.0)
|
||||
effects_changed.emit()
|
||||
|
||||
|
||||
func get_remaining_snapshot() -> Dictionary[StringName, float]:
|
||||
return _remaining.duplicate()
|
||||
|
||||
|
||||
func get_effect_duration(item_id: StringName) -> float:
|
||||
return _get_duration(item_id)
|
||||
|
||||
|
||||
func reset_all() -> void:
|
||||
var changed: bool = false
|
||||
for item_id: StringName in _remaining:
|
||||
|
|
|
|||
241
ui/chat_ui.gd
Normal file
241
ui/chat_ui.gd
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
class_name ChatUI
|
||||
extends Control
|
||||
|
||||
const INPUT_OWNER: StringName = &"chat"
|
||||
const COMPACT_SECONDS: float = 8.0
|
||||
const SPEECH_SECONDS: float = 6.0
|
||||
|
||||
var _service: NetworkChatService
|
||||
var _session: NetworkSession
|
||||
var _spawn: PlayerSpawnService
|
||||
var _player: Player
|
||||
var _fishing_spot: FishingSpot
|
||||
var _history: Label
|
||||
var _entry: LineEdit
|
||||
var _chat_button: Button
|
||||
var _panel: PanelContainer
|
||||
var _speech_layer: Control
|
||||
var _speech: Dictionary[int, Dictionary] = {}
|
||||
var _opened: bool = false
|
||||
var _available: bool = false
|
||||
var _last_message_time: float = -INF
|
||||
var _prior_movement: bool = true
|
||||
var _prior_camera: bool = true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
|
||||
|
||||
func setup(
|
||||
service: NetworkChatService,
|
||||
session: NetworkSession,
|
||||
spawn: PlayerSpawnService,
|
||||
player: Player,
|
||||
fishing_spot: FishingSpot,
|
||||
) -> void:
|
||||
_service = service
|
||||
_session = session
|
||||
_spawn = spawn
|
||||
_player = player
|
||||
_fishing_spot = fishing_spot
|
||||
_service.message_received.connect(_on_message)
|
||||
_service.history_replaced.connect(_on_history)
|
||||
_service.send_rejected.connect(_on_rejected)
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_refresh_history()
|
||||
|
||||
|
||||
func open_chat() -> void:
|
||||
if (
|
||||
_opened or not _available or _service == null
|
||||
or not _session.is_gameplay_session_active()
|
||||
):
|
||||
return
|
||||
_opened = true
|
||||
_prior_movement = _player.is_movement_enabled()
|
||||
_prior_camera = _player.is_camera_input_enabled()
|
||||
_player.set_movement_enabled(false)
|
||||
_player.set_camera_input_enabled(false)
|
||||
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
|
||||
_panel.show()
|
||||
_entry.show()
|
||||
_entry.grab_focus()
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
|
||||
func set_available(value: bool) -> void:
|
||||
_available = value
|
||||
_chat_button.visible = value
|
||||
if not value:
|
||||
close_chat()
|
||||
|
||||
|
||||
func close_chat() -> void:
|
||||
if not _opened:
|
||||
return
|
||||
_opened = false
|
||||
_entry.release_focus()
|
||||
_entry.hide()
|
||||
_player.set_movement_enabled(_prior_movement)
|
||||
_player.set_camera_input_enabled(_prior_camera)
|
||||
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
|
||||
_refresh_visibility()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.keycode in [KEY_ENTER, KEY_KP_ENTER]:
|
||||
if _opened:
|
||||
_send()
|
||||
elif (
|
||||
_available
|
||||
and get_viewport().gui_get_focus_owner() is not LineEdit
|
||||
):
|
||||
open_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.keycode == KEY_ESCAPE and _opened:
|
||||
close_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_refresh_visibility()
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
for peer_id: int in _speech.keys():
|
||||
var state: Dictionary = _speech[peer_id]
|
||||
var label := state.get("label") as Label
|
||||
if label == null or now >= float(state.get("expires", 0.0)):
|
||||
if label != null:
|
||||
label.queue_free()
|
||||
_speech.erase(peer_id)
|
||||
continue
|
||||
var avatar := _spawn.get_avatar(peer_id)
|
||||
var camera := _player.get_gameplay_camera()
|
||||
if avatar == null or camera == null:
|
||||
label.hide()
|
||||
continue
|
||||
var world_position := avatar.get_body_center_position() + Vector3.UP * 1.2
|
||||
if camera.is_position_behind(world_position):
|
||||
label.hide()
|
||||
continue
|
||||
var screen_position := camera.unproject_position(world_position)
|
||||
label.position = screen_position - Vector2(label.size.x * 0.5, 42.0)
|
||||
label.show()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.position = Vector2(18, 390)
|
||||
_panel.size = Vector2(390, 240)
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_panel)
|
||||
var box := VBoxContainer.new()
|
||||
box.add_theme_constant_override("separation", 6)
|
||||
_panel.add_child(box)
|
||||
_history = Label.new()
|
||||
_history.custom_minimum_size = Vector2(360, 150)
|
||||
_history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
||||
_history.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_child(_history)
|
||||
_entry = LineEdit.new()
|
||||
_entry.placeholder_text = "type a message…"
|
||||
_entry.max_length = NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
|
||||
_entry.text_submitted.connect(func(_value: String) -> void: _send())
|
||||
_entry.hide()
|
||||
box.add_child(_entry)
|
||||
_chat_button = Button.new()
|
||||
_chat_button.text = "chat"
|
||||
_chat_button.position = Vector2(18, 570)
|
||||
_chat_button.size = Vector2(76, 54)
|
||||
_chat_button.pressed.connect(open_chat)
|
||||
add_child(_chat_button)
|
||||
_speech_layer = Control.new()
|
||||
_speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_speech_layer)
|
||||
_panel.hide()
|
||||
|
||||
|
||||
func _send() -> void:
|
||||
var body := _entry.text
|
||||
if _service.send_local_message(body):
|
||||
_entry.clear()
|
||||
close_chat()
|
||||
|
||||
|
||||
func _on_message(message: Dictionary) -> void:
|
||||
_last_message_time = Time.get_ticks_msec() / 1000.0
|
||||
_refresh_history()
|
||||
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
|
||||
return
|
||||
var peer_id: int = message["sender_peer_id"]
|
||||
_on_peer_removed(peer_id)
|
||||
var label := Label.new()
|
||||
label.text = "%s: %s" % [
|
||||
str(message["sender_display_name"]), str(message["body"]).left(120),
|
||||
]
|
||||
label.custom_minimum_size = Vector2(80, 34)
|
||||
label.size = Vector2(280, 70)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_speech_layer.add_child(label)
|
||||
_speech[peer_id] = {
|
||||
"label": label,
|
||||
"expires": Time.get_ticks_msec() / 1000.0 + SPEECH_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
func _on_history(_messages: Array[Dictionary]) -> void:
|
||||
if _messages.is_empty():
|
||||
for peer_id: int in _speech.keys():
|
||||
_on_peer_removed(peer_id)
|
||||
_refresh_history()
|
||||
|
||||
|
||||
func _on_rejected(message: String) -> void:
|
||||
_entry.placeholder_text = message
|
||||
if not _opened:
|
||||
open_chat()
|
||||
|
||||
|
||||
func _refresh_history() -> void:
|
||||
if _service == null:
|
||||
return
|
||||
var lines: Array[String] = []
|
||||
var messages := _service.get_history()
|
||||
var start := maxi(0, messages.size() - 8)
|
||||
for message: Dictionary in messages.slice(start):
|
||||
if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM:
|
||||
lines.append("• %s" % str(message["body"]))
|
||||
else:
|
||||
lines.append("%s: %s" % [
|
||||
str(message["sender_display_name"]), str(message["body"]),
|
||||
])
|
||||
_history.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _refresh_visibility() -> void:
|
||||
if not _available:
|
||||
_panel.hide()
|
||||
return
|
||||
if _opened:
|
||||
_panel.show()
|
||||
return
|
||||
var recent := (
|
||||
Time.get_ticks_msec() / 1000.0 - _last_message_time
|
||||
< COMPACT_SECONDS
|
||||
)
|
||||
_panel.visible = recent and _service != null and not _service.get_history().is_empty()
|
||||
|
||||
|
||||
func _on_peer_removed(peer_id: int) -> void:
|
||||
var state: Dictionary = _speech.get(peer_id, {})
|
||||
var label := state.get("label") as Label
|
||||
if label != null:
|
||||
label.queue_free()
|
||||
_speech.erase(peer_id)
|
||||
1
ui/chat_ui.gd.uid
Normal file
1
ui/chat_ui.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://canbrvh1cqkie
|
||||
|
|
@ -31,6 +31,7 @@ const PlayerItemEffectsType = preload(
|
|||
const PlayerCoolerCapacityType = preload(
|
||||
"res://progression/player_cooler_capacity.gd"
|
||||
)
|
||||
const ChatUIType = preload("res://ui/chat_ui.gd")
|
||||
|
||||
signal pixelation_settings_visibility_changed(is_visible: bool)
|
||||
signal crisp_reset_focus_requested
|
||||
|
|
@ -53,6 +54,7 @@ signal interactive_pointer_ui_changed(is_open: bool)
|
|||
@onready var _fishing_shop: FishingShopType = %FishingShop
|
||||
@onready var _shop_prompt: PanelContainer = %ShopPrompt
|
||||
@onready var _effect_status: Label = %EffectStatus
|
||||
@onready var _chat_ui: ChatUIType = %ChatUI
|
||||
@onready var _title_settings_panel: SettingsPanelType = (
|
||||
$UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel
|
||||
)
|
||||
|
|
@ -70,6 +72,9 @@ var _item_effects: PlayerItemEffectsType
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
_pause_menu.chat_requested.connect(
|
||||
func() -> void: call_deferred("_open_chat_from_pause")
|
||||
)
|
||||
_title_settings_panel.panel_visibility_changed.connect(
|
||||
_on_settings_visibility_changed
|
||||
)
|
||||
|
|
@ -84,6 +89,10 @@ func _ready() -> void:
|
|||
)
|
||||
|
||||
|
||||
func _open_chat_from_pause() -> void:
|
||||
_chat_ui.open_chat()
|
||||
|
||||
|
||||
func setup(
|
||||
player: PlayerType,
|
||||
inventory: FishInventoryType,
|
||||
|
|
@ -104,9 +113,22 @@ func setup(
|
|||
network_session: NetworkSessionType,
|
||||
network_sale_service: NetworkSaleService,
|
||||
network_shop_service: NetworkShopService,
|
||||
network_chat_service: NetworkChatService,
|
||||
network_profile: NetworkProfilePreferences,
|
||||
spawn_service: PlayerSpawnService,
|
||||
) -> void:
|
||||
_fishing_spot = fishing_spot
|
||||
_item_effects = item_effects
|
||||
_chat_ui.setup(
|
||||
network_chat_service, network_session, spawn_service, player,
|
||||
fishing_spot
|
||||
)
|
||||
_title_settings_panel.setup_network_profile(
|
||||
network_profile, network_session
|
||||
)
|
||||
_pause_settings_panel.setup_network_profile(
|
||||
network_profile, network_session
|
||||
)
|
||||
fishing_spot.status_changed.connect(_on_fishing_status_changed)
|
||||
fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
|
||||
fishing_spot.showcase_changed.connect(_on_showcase_changed)
|
||||
|
|
@ -202,6 +224,7 @@ func get_pause_menu() -> PauseMenuType:
|
|||
|
||||
func set_gameplay_ui_enabled(enabled: bool) -> void:
|
||||
_gameplay_ui_enabled = enabled
|
||||
_refresh_chat_availability()
|
||||
if not enabled:
|
||||
close_player_menu_for_session_end()
|
||||
_fishing_shop.close_for_session_end()
|
||||
|
|
@ -217,6 +240,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
|
|||
|
||||
func set_system_menu_open(is_open: bool) -> void:
|
||||
_system_menu_open = is_open
|
||||
_refresh_chat_availability()
|
||||
_hotbar_ui.visible = (
|
||||
_gameplay_ui_enabled and not is_open and not _shop_open
|
||||
)
|
||||
|
|
@ -444,6 +468,7 @@ func _on_showcase_changed(
|
|||
|
||||
func _on_player_menu_visibility_changed(is_open: bool) -> void:
|
||||
_player_menu_open = is_open
|
||||
_refresh_chat_availability()
|
||||
_hotbar_ui.set_gameplay_input_enabled(
|
||||
_gameplay_ui_enabled and not is_open
|
||||
)
|
||||
|
|
@ -456,6 +481,7 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void:
|
|||
|
||||
func _on_shop_visibility_changed(is_open: bool) -> void:
|
||||
_shop_open = is_open
|
||||
_refresh_chat_availability()
|
||||
_hotbar_ui.visible = (
|
||||
_gameplay_ui_enabled and not is_open and not _system_menu_open
|
||||
)
|
||||
|
|
@ -474,3 +500,14 @@ func _emit_interactive_pointer_ui_changed() -> void:
|
|||
interactive_pointer_ui_changed.emit(
|
||||
_system_menu_open or _player_menu_open or _shop_open
|
||||
)
|
||||
|
||||
|
||||
func _refresh_chat_availability() -> void:
|
||||
if _chat_ui == null:
|
||||
return
|
||||
_chat_ui.set_available(
|
||||
_gameplay_ui_enabled
|
||||
and not _system_menu_open
|
||||
and not _player_menu_open
|
||||
and not _shop_open
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=13 format=3]
|
||||
[gd_scene load_steps=14 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
|
||||
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
[ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"]
|
||||
[ext_resource type="PackedScene" path="res://ui/hotbar.tscn" id="7_hotbar"]
|
||||
[ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"]
|
||||
[ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
|
||||
bg_color = Color(0.055, 0.105, 0.125, 1)
|
||||
|
|
@ -185,6 +186,17 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
|
|||
theme_override_constants/shadow_offset_x = 2
|
||||
theme_override_constants/shadow_offset_y = 2
|
||||
|
||||
[node name="ChatUI" type="Control" parent="UIRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("9_chat")
|
||||
|
||||
[node name="ScreenFade" type="ColorRect" parent="UIRoot"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ signal reset_progress_requested
|
|||
signal quit_requested
|
||||
signal menu_visibility_changed(is_open: bool)
|
||||
signal join_game_requested(endpoint: String)
|
||||
signal chat_requested
|
||||
|
||||
enum ConfirmationAction {
|
||||
NONE,
|
||||
|
|
@ -87,6 +88,7 @@ func _ready() -> void:
|
|||
_save_button.pressed.connect(_save_now)
|
||||
%SettingsButton.pressed.connect(_open_settings)
|
||||
%JoinGameButton.pressed.connect(_open_join_game)
|
||||
%ChatButton.pressed.connect(_request_chat)
|
||||
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
|
||||
%ResetProgressButton.pressed.connect(_confirm_reset_progress)
|
||||
%QuitButton.pressed.connect(_request_quit)
|
||||
|
|
@ -105,6 +107,13 @@ func _ready() -> void:
|
|||
call_deferred("_update_responsive_pause_stage")
|
||||
|
||||
|
||||
func _request_chat() -> void:
|
||||
if not visible or _action_in_progress or _root_transition_active:
|
||||
return
|
||||
close_menu(CloseReason.USER_RETURN, true)
|
||||
chat_requested.emit()
|
||||
|
||||
|
||||
func setup(
|
||||
player: PlayerType,
|
||||
save_manager: SaveManagerType,
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ grow_horizontal = 2
|
|||
grow_vertical = 2
|
||||
script = ExtResource("4_page")
|
||||
page_id = &"pause"
|
||||
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
initial_focus_path = NodePath("BubbleCluster/ResumeButton")
|
||||
back_focus_path = NodePath("BubbleCluster/ResumeButton")
|
||||
maximum_layout_size = Vector2(720, 520)
|
||||
|
|
@ -135,6 +135,18 @@ minimum_font_size = 16
|
|||
maximum_font_size = 23
|
||||
motion_phase = 2.05
|
||||
|
||||
[node name="ChatButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 0)
|
||||
text = "chat"
|
||||
neutral_size = Vector2(112, 106)
|
||||
desktop_anchor = Vector2(620, 255)
|
||||
compact_anchor = Vector2(595, 270)
|
||||
compact_minimum_size = Vector2(92, 88)
|
||||
minimum_font_size = 15
|
||||
maximum_font_size = 20
|
||||
motion_phase = 2.3
|
||||
|
||||
[node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 0)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ enum PresentationMode {
|
|||
@onready var _controls_page: SettingsBubblePage = %ControlsPage
|
||||
@onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage
|
||||
@onready var _feedback: Label = %SettingsFeedback
|
||||
@onready var _display_name_edit: LineEdit = %DisplayNameEdit
|
||||
|
||||
@onready var _world_value: BubbleButton = %WorldValue
|
||||
@onready var _ui_value: BubbleButton = %UIValue
|
||||
|
|
@ -75,6 +76,8 @@ var _auto_click_interval_value: float = 0.20
|
|||
var _mouse_sensitivity: float = 0.005
|
||||
var _controller_sensitivity: float = 2.5
|
||||
var _invert_camera_y: bool = false
|
||||
var _network_profile: NetworkProfilePreferences
|
||||
var _network_session: NetworkSession
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -98,6 +101,10 @@ func _ready() -> void:
|
|||
%DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input)
|
||||
%ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input)
|
||||
%AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input)
|
||||
_display_name_edit.text_submitted.connect(
|
||||
func(_value: String) -> void: _apply_display_name()
|
||||
)
|
||||
_display_name_edit.focus_exited.connect(_apply_display_name)
|
||||
for index: int in _world_options.size():
|
||||
_world_options[index].pressed.connect(
|
||||
_set_world_pixelation.bind(index + 1)
|
||||
|
|
@ -143,6 +150,33 @@ func _ready() -> void:
|
|||
call_deferred("_refresh_panel_size")
|
||||
|
||||
|
||||
func setup_network_profile(
|
||||
profile: NetworkProfilePreferences,
|
||||
session: NetworkSession,
|
||||
) -> void:
|
||||
_network_profile = profile
|
||||
_network_session = session
|
||||
if _network_profile != null:
|
||||
_display_name_edit.text = _network_profile.display_name
|
||||
|
||||
|
||||
func _apply_display_name() -> void:
|
||||
if _network_profile == null:
|
||||
return
|
||||
var value := _display_name_edit.text.strip_edges()
|
||||
var accepted := (
|
||||
_network_session.update_local_display_name(value)
|
||||
if _network_session != null
|
||||
else _network_profile.set_display_name(value)
|
||||
)
|
||||
if accepted:
|
||||
_display_name_edit.text = _network_profile.display_name
|
||||
_feedback.text = "display name saved."
|
||||
else:
|
||||
_display_name_edit.text = _network_profile.display_name
|
||||
_feedback.text = "display name must be 1–24 plain-text characters."
|
||||
|
||||
|
||||
func open_panel(
|
||||
settings_manager: SettingsManagerType,
|
||||
presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
|
||||
|
|
@ -158,6 +192,8 @@ func open_panel(
|
|||
)
|
||||
_settings_manager = settings_manager
|
||||
_load_controls()
|
||||
if _network_profile != null:
|
||||
_display_name_edit.text = _network_profile.display_name
|
||||
_feedback.text = ""
|
||||
show()
|
||||
_page_stack.clear()
|
||||
|
|
|
|||
|
|
@ -144,6 +144,31 @@ minimum_font_size = 14
|
|||
maximum_font_size = 17
|
||||
motion_phase = 4.8
|
||||
|
||||
[node name="DisplayNamePanel" type="PanelContainer" parent="RootPage"]
|
||||
layout_mode = 0
|
||||
offset_left = 318.0
|
||||
offset_top = 430.0
|
||||
offset_right = 618.0
|
||||
offset_bottom = 510.0
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="RootPage/DisplayNamePanel"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="RootPage/DisplayNamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "display name"
|
||||
|
||||
[node name="DisplayNameEdit" type="LineEdit" parent="RootPage/DisplayNamePanel/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
max_length = 24
|
||||
placeholder_text = "Player"
|
||||
|
||||
[node name="Help" type="Label" parent="RootPage/DisplayNamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Shown to other players in multiplayer."
|
||||
theme_override_font_sizes/font_size = 12
|
||||
|
||||
[node name="DisplayPage" type="Control" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue