Add multiplayer item use and session chat

This commit is contained in:
Alexander Sellite 2026-07-29 19:26:46 -04:00
parent 5e04b70609
commit 5dda74c9e4
27 changed files with 1361 additions and 31 deletions

View file

@ -107,6 +107,7 @@ var _local_hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType var _fishing_upgrades: PlayerFishingUpgradesType
var _item_effects: PlayerItemEffectsType var _item_effects: PlayerItemEffectsType
var _network_item_use: NetworkItemUseService
var _cooler_capacity: PlayerCoolerCapacityType var _cooler_capacity: PlayerCoolerCapacityType
var _network_session: NetworkSessionType var _network_session: NetworkSessionType
var _network_fishing: NetworkFishingServiceType var _network_fishing: NetworkFishingServiceType
@ -154,6 +155,7 @@ func setup(
item_catalog: ItemCatalogType, item_catalog: ItemCatalogType,
fishing_upgrades: PlayerFishingUpgradesType, fishing_upgrades: PlayerFishingUpgradesType,
item_effects: PlayerItemEffectsType, item_effects: PlayerItemEffectsType,
network_item_use: NetworkItemUseService,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType = null, network_session: NetworkSessionType = null,
network_fishing: NetworkFishingServiceType = null, network_fishing: NetworkFishingServiceType = null,
@ -166,6 +168,11 @@ func setup(
_item_catalog = item_catalog _item_catalog = item_catalog
_fishing_upgrades = fishing_upgrades _fishing_upgrades = fishing_upgrades
_item_effects = item_effects _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 _cooler_capacity = cooler_capacity
_network_session = network_session _network_session = network_session
_network_fishing = network_fishing _network_fishing = network_fishing
@ -416,10 +423,12 @@ func _unhandled_input(event: InputEvent) -> void:
active_item != null active_item != null
and active_item.category == ItemDataType.Category.CONSUMABLE and active_item.category == ItemDataType.Category.CONSUMABLE
): ):
if _item_effects.use_consumable(active_item, _local_bag): if _network_item_use != null:
status_changed.emit( _network_item_use.request_use(active_item.item_id)
_item_effects.get_feedback(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() get_viewport().set_input_as_handled()
return return
if not has_active_fishing_rod(): if not has_active_fishing_rod():
@ -1112,11 +1121,7 @@ func build_network_context(
func _build_network_evidence() -> Dictionary: func _build_network_evidence() -> Dictionary:
var rarity_multipliers: Array[float] = [] var rarity_multipliers: Array[float] = []
for rarity: int in range(FishDataType.Rarity.size()): for rarity: int in range(FishDataType.Rarity.size()):
rarity_multipliers.append( rarity_multipliers.append(1.0)
_item_effects.get_rarity_weight_multiplier(rarity)
if _item_effects != null
else 1.0
)
var discovered_ids: Array[String] = [] var discovered_ids: Array[String] = []
if _local_collection_log != null: if _local_collection_log != null:
for fish_id: StringName in _local_collection_log.get_discovered_ids(): 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() var item: ItemDataType = _get_active_item()
return { return {
"rod_id": str(item.item_id) if item != null else "", "rod_id": str(item.item_id) if item != null else "",
"reel_speed": _get_effective_reel_speed(), "reel_speed": _active_player.reel_speed * (
"barrier_damage": _get_effective_barrier_damage(), _fishing_upgrades.get_reel_speed_multiplier()
"bite_multiplier": ( if _fishing_upgrades != null else 1.0
_item_effects.get_bite_time_multiplier()
if _item_effects != 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, "rarity_multipliers": rarity_multipliers,
"discovered_fish_ids": discovered_ids, "discovered_fish_ids": discovered_ids,
"capacity_available": not _is_cooler_full(), "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( func _on_network_cast_accepted(
_attempt_id: String, _attempt_id: String,
target: Vector3, target: Vector3,

View file

@ -52,6 +52,12 @@ const NetworkSaleServiceType = preload(
const NetworkShopServiceType = preload( const NetworkShopServiceType = preload(
"res://network/network_shop_service.gd" "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 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_sale: NetworkSaleServiceType = %NetworkSaleService
@onready var _network_shop: NetworkShopServiceType = %NetworkShopService @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 @onready var _players_root: Node3D = $Players
var _gameplay_started: bool = false var _gameplay_started: bool = false
@ -150,6 +160,15 @@ func _ready() -> void:
_player.cooler_capacity _player.cooler_capacity
) )
_save_manager.set_autosave_enabled(false) _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_fishing.setup(
_network_session, _network_session,
_player_spawn_service, _player_spawn_service,
@ -159,7 +178,8 @@ func _ready() -> void:
_player.cooler_capacity, _player.cooler_capacity,
_save_manager, _save_manager,
item_catalog, item_catalog,
fish_catalog fish_catalog,
_network_item_use
) )
_network_sale.setup( _network_sale.setup(
_network_session, _network_session,
@ -194,6 +214,7 @@ func _ready() -> void:
item_catalog, item_catalog,
_player.fishing_upgrades, _player.fishing_upgrades,
_player.item_effects, _player.item_effects,
_network_item_use,
_player.cooler_capacity, _player.cooler_capacity,
_network_session, _network_session,
_network_fishing _network_fishing
@ -217,7 +238,10 @@ func _ready() -> void:
_player.cooler_capacity, _player.cooler_capacity,
_network_session, _network_session,
_network_sale, _network_sale,
_network_shop _network_shop,
_network_chat,
_network_profile,
_player_spawn_service
) )
_water_recovery.setup( _water_recovery.setup(
_player, _player,
@ -284,6 +308,7 @@ func _ready() -> void:
_player.hotbar.selected_slot_changed.connect( _player.hotbar.selected_slot_changed.connect(
_on_active_hotbar_item_changed _on_active_hotbar_item_changed
) )
_player.bag.contents_changed.connect(_refresh_active_hotbar_item)
_fishing_spot.ready_for_equipment_refresh.connect( _fishing_spot.ready_for_equipment_refresh.connect(
_refresh_active_hotbar_item _refresh_active_hotbar_item
) )
@ -409,6 +434,8 @@ func _set_gameplay_active(active: bool) -> void:
_water_recovery.set_recovery_enabled(active) _water_recovery.set_recovery_enabled(active)
_game_ui.set_gameplay_ui_enabled(active) _game_ui.set_gameplay_ui_enabled(active)
_save_manager.set_autosave_enabled(active) _save_manager.set_autosave_enabled(active)
if active:
_refresh_active_hotbar_item()
func _on_new_game_requested() -> void: func _on_new_game_requested() -> void:
@ -670,6 +697,9 @@ func _on_active_hotbar_item_changed(
and _player.bag.owns_item(item_id) and _player.bag.owns_item(item_id)
) )
_player.set_active_item_is_rod(active_is_rod) _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() _fishing_spot.refresh_active_item_status()

View file

@ -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://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [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_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_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_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"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
@ -59,6 +61,14 @@ script = ExtResource("22_network_sale")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("23_network_shop") 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="TestWorld" parent="." instance=ExtResource("1_world")]
[node name="Players" type="Node3D" parent="."] [node name="Players" type="Node3D" parent="."]

View file

@ -11,7 +11,9 @@ func start_host(
var enet_peer := ENetMultiplayerPeer.new() var enet_peer := ENetMultiplayerPeer.new()
if not bind_address.is_empty() and bind_address != "*": if not bind_address.is_empty() and bind_address != "*":
enet_peer.set_bind_ip(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: if error != OK:
transport_error.emit("Unable to host UDP port %d." % port) transport_error.emit("Unable to host UDP port %d." % port)
return error return error
@ -27,7 +29,9 @@ func connect_to_route(route: ConnectionRoute) -> Error:
return ERR_INVALID_PARAMETER return ERR_INVALID_PARAMETER
var endpoint: ConnectionEndpoint = route.direct_endpoint var endpoint: ConnectionEndpoint = route.direct_endpoint
var enet_peer := ENetMultiplayerPeer.new() 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: if error != OK:
transport_error.emit( transport_error.emit(
"Unable to connect to %s." % endpoint.normalized_display "Unable to connect to %s." % endpoint.normalized_display

View 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
)

View file

@ -0,0 +1 @@
uid://c3hg65v27hi2g

View 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(),
]

View file

@ -0,0 +1 @@
uid://b10jayoog5dc2

View file

@ -32,6 +32,7 @@ var _local_capacity: PlayerCoolerCapacity
var _save_manager: PlayerSaveManager var _save_manager: PlayerSaveManager
var _item_catalog: ItemCatalog var _item_catalog: ItemCatalog
var _fish_catalog: FishPoolType var _fish_catalog: FishPoolType
var _item_use: NetworkItemUseService
var _attempts: Dictionary[int, NetworkFishingAttempt] = {} var _attempts: Dictionary[int, NetworkFishingAttempt] = {}
var _request_ledgers: Dictionary[int, Dictionary] = {} var _request_ledgers: Dictionary[int, Dictionary] = {}
var _result_ledgers: Dictionary[String, bool] = {} var _result_ledgers: Dictionary[String, bool] = {}
@ -53,6 +54,7 @@ func setup(
save_manager: PlayerSaveManager, save_manager: PlayerSaveManager,
item_catalog: ItemCatalog, item_catalog: ItemCatalog,
fish_catalog: FishPoolType, fish_catalog: FishPoolType,
item_use: NetworkItemUseService,
) -> void: ) -> void:
_session = session _session = session
_spawn_service = spawn_service _spawn_service = spawn_service
@ -63,6 +65,7 @@ func setup(
_save_manager = save_manager _save_manager = save_manager
_item_catalog = item_catalog _item_catalog = item_catalog
_fish_catalog = fish_catalog _fish_catalog = fish_catalog
_item_use = item_use
if not _session.peer_removed.is_connected(_on_peer_removed): if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed) _session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed): 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: if region == null or region.fish_pool == null:
_record_and_reject(peer_id, request_id, "Cannot fish here.") _record_and_reject(peer_id, request_id, "Cannot fish here.")
return 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( var selected_fish: FishDataType = _select_authoritative_fish(
region, data region, data, effects
) )
if selected_fish == null: if selected_fish == null:
_record_and_reject(peer_id, request_id, "Nothing is biting here.") _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.target = target
attempt.bobber_position = target attempt.bobber_position = target
attempt.fish_id = selected_fish.id attempt.fish_id = selected_fish.id
attempt.reel_speed = float(data["reel_speed"]) attempt.reel_speed = float(data["reel_speed"]) * (
attempt.barrier_damage = int(data["barrier_damage"]) 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( 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() attempt.controller = CatchController.new()
add_child(attempt.controller) add_child(attempt.controller)
@ -359,6 +370,7 @@ func _make_waiting_snapshot(
func _select_authoritative_fish( func _select_authoritative_fish(
region: FishableWaterRegion, region: FishableWaterRegion,
data: Dictionary, data: Dictionary,
effects: PlayerItemEffects,
) -> FishDataType: ) -> FishDataType:
var evidence_log := CollectionLogType.new() var evidence_log := CollectionLogType.new()
for value: Variant in data["discovered_fish_ids"]: for value: Variant in data["discovered_fish_ids"]:
@ -370,8 +382,11 @@ func _select_authoritative_fish(
selector.undiscovered_weight_multiplier = ( selector.undiscovered_weight_multiplier = (
_fishing_spot.undiscovered_weight_multiplier _fishing_spot.undiscovered_weight_multiplier
) )
for value: Variant in data["rarity_multipliers"]: for rarity: int in range(FishDataType.Rarity.size()):
selector.rarity_weight_multipliers.append(float(value)) selector.rarity_weight_multipliers.append(
effects.get_rarity_weight_multiplier(rarity)
if effects != null else 1.0
)
selector.begin_roll() selector.begin_roll()
var context: FishingContextType = _fishing_spot.build_network_context(region) var context: FishingContextType = _fishing_spot.build_network_context(region)
return selector.select_fish(region.fish_pool, context, evidence_log) return selector.select_fish(region.fish_pool, context, evidence_log)

View 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
)

View file

@ -0,0 +1 @@
uid://bphibe6g81mhm

View 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(),
]

View file

@ -0,0 +1 @@
uid://dx21uu253nmpw

View file

@ -34,6 +34,29 @@ func load_or_create() -> bool:
return _save_atomic() 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: func _load_existing() -> bool:
var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
if file == null: if file == null:

View file

@ -3,14 +3,18 @@ extends RefCounted
const PROTOCOL_VERSION: int = 2 const PROTOCOL_VERSION: int = 2
const GAME_BUILD: String = "prealpha" 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_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96 const MAX_NONCE_LENGTH: int = 96
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, # 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 SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6 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 { enum RejectionCode {
NONE, NONE,
@ -109,6 +113,9 @@ static func make_server_hello(
"fishing_v1", "fishing_v1",
"sale_v1", "sale_v1",
"shop_v1", "shop_v1",
"item_use_v1",
"equipment_v1",
"chat_v1",
]), ]),
} }

View file

@ -16,6 +16,7 @@ signal peer_authenticated(peer_id: int, display_name: String)
signal peer_removed(peer_id: int) signal peer_removed(peer_id: int)
signal host_openness_changed(is_open: bool) signal host_openness_changed(is_open: bool)
signal peer_count_changed(player_count: int, max_players: int) 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 join_authenticated
signal server_lost signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
@ -275,10 +276,50 @@ func supports_server_capability(capability: StringName) -> bool:
if is_host(): if is_host():
return str(capability) in PackedStringArray([ return str(capability) in PackedStringArray([
"movement_v1", "fishing_v1", "sale_v1", "shop_v1", "movement_v1", "fishing_v1", "sale_v1", "shop_v1",
"item_use_v1", "equipment_v1", "chat_v1",
]) ])
return str(capability) in _server_capabilities 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: func get_local_peer_id() -> int:
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0 return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
@ -414,6 +455,12 @@ func submit_client_hello(data: Dictionary) -> void:
) )
return return
var display_name: String = data["display_name"] 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( if not _registry.add_peer(
sender_id, sender_id,
profile_id, profile_id,

View file

@ -42,6 +42,14 @@ func get_peer(peer_id: int) -> PeerRecord:
return _records.get(peer_id) 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: func has_peer(peer_id: int) -> bool:
return _records.has(peer_id) return _records.has(peer_id)

View file

@ -536,6 +536,10 @@ func get_body_center_height() -> float:
return body_center_height return body_center_height
func get_gameplay_camera() -> Camera3D:
return _camera
func get_fishing_rod_tip() -> Marker3D: func get_fishing_rod_tip() -> Marker3D:
return _fishing_rod_tip return _fishing_rod_tip

View file

@ -61,6 +61,28 @@ func use_consumable(
return true 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: func reset_all() -> void:
var changed: bool = false var changed: bool = false
for item_id: StringName in _remaining: for item_id: StringName in _remaining:

241
ui/chat_ui.gd Normal file
View 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
View file

@ -0,0 +1 @@
uid://canbrvh1cqkie

View file

@ -31,6 +31,7 @@ const PlayerItemEffectsType = preload(
const PlayerCoolerCapacityType = preload( const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd" "res://progression/player_cooler_capacity.gd"
) )
const ChatUIType = preload("res://ui/chat_ui.gd")
signal pixelation_settings_visibility_changed(is_visible: bool) signal pixelation_settings_visibility_changed(is_visible: bool)
signal crisp_reset_focus_requested 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 _fishing_shop: FishingShopType = %FishingShop
@onready var _shop_prompt: PanelContainer = %ShopPrompt @onready var _shop_prompt: PanelContainer = %ShopPrompt
@onready var _effect_status: Label = %EffectStatus @onready var _effect_status: Label = %EffectStatus
@onready var _chat_ui: ChatUIType = %ChatUI
@onready var _title_settings_panel: SettingsPanelType = ( @onready var _title_settings_panel: SettingsPanelType = (
$UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel $UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel
) )
@ -70,6 +72,9 @@ var _item_effects: PlayerItemEffectsType
func _ready() -> void: func _ready() -> void:
_pause_menu.chat_requested.connect(
func() -> void: call_deferred("_open_chat_from_pause")
)
_title_settings_panel.panel_visibility_changed.connect( _title_settings_panel.panel_visibility_changed.connect(
_on_settings_visibility_changed _on_settings_visibility_changed
) )
@ -84,6 +89,10 @@ func _ready() -> void:
) )
func _open_chat_from_pause() -> void:
_chat_ui.open_chat()
func setup( func setup(
player: PlayerType, player: PlayerType,
inventory: FishInventoryType, inventory: FishInventoryType,
@ -104,9 +113,22 @@ func setup(
network_session: NetworkSessionType, network_session: NetworkSessionType,
network_sale_service: NetworkSaleService, network_sale_service: NetworkSaleService,
network_shop_service: NetworkShopService, network_shop_service: NetworkShopService,
network_chat_service: NetworkChatService,
network_profile: NetworkProfilePreferences,
spawn_service: PlayerSpawnService,
) -> void: ) -> void:
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
_item_effects = item_effects _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.status_changed.connect(_on_fishing_status_changed)
fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
fishing_spot.showcase_changed.connect(_on_showcase_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: func set_gameplay_ui_enabled(enabled: bool) -> void:
_gameplay_ui_enabled = enabled _gameplay_ui_enabled = enabled
_refresh_chat_availability()
if not enabled: if not enabled:
close_player_menu_for_session_end() close_player_menu_for_session_end()
_fishing_shop.close_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: func set_system_menu_open(is_open: bool) -> void:
_system_menu_open = is_open _system_menu_open = is_open
_refresh_chat_availability()
_hotbar_ui.visible = ( _hotbar_ui.visible = (
_gameplay_ui_enabled and not is_open and not _shop_open _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: func _on_player_menu_visibility_changed(is_open: bool) -> void:
_player_menu_open = is_open _player_menu_open = is_open
_refresh_chat_availability()
_hotbar_ui.set_gameplay_input_enabled( _hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled and not is_open _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: func _on_shop_visibility_changed(is_open: bool) -> void:
_shop_open = is_open _shop_open = is_open
_refresh_chat_availability()
_hotbar_ui.visible = ( _hotbar_ui.visible = (
_gameplay_ui_enabled and not is_open and not _system_menu_open _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( interactive_pointer_ui_changed.emit(
_system_menu_open or _player_menu_open or _shop_open _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
)

View file

@ -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="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] [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/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/hotbar.tscn" id="7_hotbar"]
[ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"] [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"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.055, 0.105, 0.125, 1) 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_x = 2
theme_override_constants/shadow_offset_y = 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"] [node name="ScreenFade" type="ColorRect" parent="UIRoot"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false

View file

@ -28,6 +28,7 @@ signal reset_progress_requested
signal quit_requested signal quit_requested
signal menu_visibility_changed(is_open: bool) signal menu_visibility_changed(is_open: bool)
signal join_game_requested(endpoint: String) signal join_game_requested(endpoint: String)
signal chat_requested
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,
@ -87,6 +88,7 @@ func _ready() -> void:
_save_button.pressed.connect(_save_now) _save_button.pressed.connect(_save_now)
%SettingsButton.pressed.connect(_open_settings) %SettingsButton.pressed.connect(_open_settings)
%JoinGameButton.pressed.connect(_open_join_game) %JoinGameButton.pressed.connect(_open_join_game)
%ChatButton.pressed.connect(_request_chat)
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title) %ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
%ResetProgressButton.pressed.connect(_confirm_reset_progress) %ResetProgressButton.pressed.connect(_confirm_reset_progress)
%QuitButton.pressed.connect(_request_quit) %QuitButton.pressed.connect(_request_quit)
@ -105,6 +107,13 @@ func _ready() -> void:
call_deferred("_update_responsive_pause_stage") 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( func setup(
player: PlayerType, player: PlayerType,
save_manager: SaveManagerType, save_manager: SaveManagerType,

View file

@ -77,8 +77,8 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
script = ExtResource("4_page") script = ExtResource("4_page")
page_id = &"pause" 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")]) 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/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") initial_focus_path = NodePath("BubbleCluster/ResumeButton")
back_focus_path = NodePath("BubbleCluster/ResumeButton") back_focus_path = NodePath("BubbleCluster/ResumeButton")
maximum_layout_size = Vector2(720, 520) maximum_layout_size = Vector2(720, 520)
@ -135,6 +135,18 @@ minimum_font_size = 16
maximum_font_size = 23 maximum_font_size = 23
motion_phase = 2.05 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")] [node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0) custom_minimum_size = Vector2(0, 0)

View file

@ -38,6 +38,7 @@ enum PresentationMode {
@onready var _controls_page: SettingsBubblePage = %ControlsPage @onready var _controls_page: SettingsBubblePage = %ControlsPage
@onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage @onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage
@onready var _feedback: Label = %SettingsFeedback @onready var _feedback: Label = %SettingsFeedback
@onready var _display_name_edit: LineEdit = %DisplayNameEdit
@onready var _world_value: BubbleButton = %WorldValue @onready var _world_value: BubbleButton = %WorldValue
@onready var _ui_value: BubbleButton = %UIValue @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 _mouse_sensitivity: float = 0.005
var _controller_sensitivity: float = 2.5 var _controller_sensitivity: float = 2.5
var _invert_camera_y: bool = false var _invert_camera_y: bool = false
var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession
func _ready() -> void: func _ready() -> void:
@ -98,6 +101,10 @@ func _ready() -> void:
%DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input) %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input)
%ControlsBackButton.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) %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(): for index: int in _world_options.size():
_world_options[index].pressed.connect( _world_options[index].pressed.connect(
_set_world_pixelation.bind(index + 1) _set_world_pixelation.bind(index + 1)
@ -143,6 +150,33 @@ func _ready() -> void:
call_deferred("_refresh_panel_size") 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 124 plain-text characters."
func open_panel( func open_panel(
settings_manager: SettingsManagerType, settings_manager: SettingsManagerType,
presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL, presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
@ -158,6 +192,8 @@ func open_panel(
) )
_settings_manager = settings_manager _settings_manager = settings_manager
_load_controls() _load_controls()
if _network_profile != null:
_display_name_edit.text = _network_profile.display_name
_feedback.text = "" _feedback.text = ""
show() show()
_page_stack.clear() _page_stack.clear()

View file

@ -144,6 +144,31 @@ minimum_font_size = 14
maximum_font_size = 17 maximum_font_size = 17
motion_phase = 4.8 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="."] [node name="DisplayPage" type="Control" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false