Add multiplayer fish showcases

This commit is contained in:
Alexander Sellite 2026-08-01 17:12:29 -04:00
parent 3f4392fdd3
commit 904a9c2d9e
23 changed files with 966 additions and 46 deletions

View file

@ -0,0 +1,56 @@
class_name NetworkFishShowcaseProtocol
extends RefCounted
const CAPABILITY: StringName = &"fish_showcase_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_FISH_ID_LENGTH: int = 96
const MAX_WEIGHT_LB: float = 1000.0
const MAX_DISPLAY_SCALE: float = 20.0
static func validate_state(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
for key: String in [
"session_id",
"owner_peer_id",
"visible",
"fish_id",
"weight_lb",
"display_scale",
"revision",
]:
if not value.has(key):
return false
if (
typeof(value["session_id"]) != TYPE_STRING
or str(value["session_id"]).is_empty()
or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
or typeof(value["owner_peer_id"]) != TYPE_INT
or int(value["owner_peer_id"]) <= 0
or typeof(value["visible"]) != TYPE_BOOL
or typeof(value["fish_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
or str(value["fish_id"]).length() > MAX_FISH_ID_LENGTH
or typeof(value["weight_lb"]) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(value["weight_lb"]))
or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(value["display_scale"]))
or typeof(value["revision"]) != TYPE_INT
or int(value["revision"]) < 0
):
return false
if bool(value["visible"]):
return (
not str(value["fish_id"]).is_empty()
and float(value["weight_lb"]) > 0.0
and float(value["weight_lb"]) <= MAX_WEIGHT_LB
and float(value["display_scale"]) > 0.0
and float(value["display_scale"]) <= MAX_DISPLAY_SCALE
)
return (
str(value["fish_id"]).is_empty()
and is_zero_approx(float(value["weight_lb"]))
and is_zero_approx(float(value["display_scale"]))
)

View file

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

View file

@ -0,0 +1,253 @@
class_name NetworkFishShowcaseService
extends Node
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _fish_catalog: FishPoolType
var _local_inventory: FishInventoryType
var _local_hotbar: PlayerHotbarType
var _states: Dictionary[int, Dictionary] = {}
var _local_revision: int = 0
var _local_visible: bool = false
var _local_catch_id: StringName
func setup(
session: NetworkSession,
spawn_service: PlayerSpawnService,
fish_catalog: FishPoolType,
local_inventory: FishInventoryType,
local_hotbar: PlayerHotbarType,
) -> void:
_session = session
_spawn_service = spawn_service
_fish_catalog = fish_catalog
_local_inventory = local_inventory
_local_hotbar = local_hotbar
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed)
_spawn_service.avatar_spawned.connect(_on_avatar_spawned)
_local_inventory.catches_changed.connect(_on_local_inventory_changed)
_local_hotbar.selected_assignment_changed.connect(
_on_selected_assignment_changed
)
func toggle_selected_fish() -> bool:
if _local_hotbar == null or _local_inventory == null:
return false
var catch_id: StringName = _local_hotbar.get_selected_fish_catch_id()
var fish_catch: FishCatchType = _local_inventory.get_catch_by_id(catch_id)
if fish_catch == null or not fish_catch.is_valid():
return false
if _local_visible and _local_catch_id == catch_id:
_submit_local_state(null, false)
else:
_submit_local_state(fish_catch, true)
return true
func is_local_showcase_visible() -> bool:
return _local_visible
func get_local_showcase_catch_id() -> StringName:
return _local_catch_id
func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void:
_local_revision += 1
var local_peer_id: int = (
_session.get_local_peer_id() if _session != null else 1
)
var data: Dictionary = {
"session_id": (
_session.get_session_id()
if _session != null and not _session.get_session_id().is_empty()
else "local"
),
"owner_peer_id": local_peer_id,
"visible": should_show and fish_catch != null,
"fish_id": String(fish_catch.fish_id) if fish_catch != null else "",
"weight_lb": fish_catch.weight_lb if fish_catch != null else 0.0,
"display_scale": (
fish_catch.display_scale if fish_catch != null else 0.0
),
"revision": _local_revision,
}
_local_visible = bool(data["visible"])
_local_catch_id = (
fish_catch.catch_id if _local_visible else StringName()
)
_apply_state(data)
if _session == null or not _session.is_gameplay_session_active():
return
data["session_id"] = _session.get_session_id()
if _session.is_host():
_handle_showcase_state(local_peer_id, data)
elif _session.supports_server_capability(
NetworkFishShowcaseProtocol.CAPABILITY
):
submit_showcase_state.rpc_id(1, data)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishShowcaseProtocol.RELIABLE_CHANNEL,
)
func submit_showcase_state(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if _session.is_host() and _session.is_authenticated_peer(sender_id):
_handle_showcase_state(sender_id, data)
func _handle_showcase_state(peer_id: int, data: Dictionary) -> void:
if (
not _session.is_host()
or not NetworkFishShowcaseProtocol.validate_state(data)
or str(data["session_id"]) != _session.get_session_id()
or int(data["owner_peer_id"]) != peer_id
or (
peer_id != _session.get_local_peer_id()
and not _session.is_authenticated_peer(peer_id)
)
):
return
var previous: Dictionary = _states.get(peer_id, {})
if int(data["revision"]) <= int(previous.get("revision", -1)):
return
if not _state_matches_catalog(data):
return
var sanitized: Dictionary = data.duplicate(true)
_states[peer_id] = sanitized
_apply_state(sanitized)
receive_showcase_state.rpc(sanitized)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishShowcaseProtocol.RELIABLE_CHANNEL,
)
func receive_showcase_state(data: Dictionary) -> void:
if (
not NetworkFishShowcaseProtocol.validate_state(data)
or str(data["session_id"]) != _session.get_session_id()
or not _state_matches_catalog(data)
):
return
var peer_id: int = int(data["owner_peer_id"])
var previous: Dictionary = _states.get(peer_id, {})
if int(data["revision"]) <= int(previous.get("revision", -1)):
return
_states[peer_id] = data.duplicate(true)
_apply_state(data)
func _state_matches_catalog(data: Dictionary) -> bool:
if not bool(data["visible"]):
return true
var fish_id: StringName = StringName(str(data["fish_id"]))
var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
if fish == null or not fish.is_selectable():
return false
var weight_lb: float = float(data["weight_lb"])
var display_scale: float = float(data["display_scale"])
return (
weight_lb >= fish.get_minimum_weight()
and weight_lb <= fish.get_maximum_weight()
and is_equal_approx(
display_scale,
fish.get_display_scale_for_weight(weight_lb),
)
)
func _apply_state(data: Dictionary) -> void:
var peer_id: int = int(data["owner_peer_id"])
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
if not bool(data["visible"]):
avatar.set_held_fish(null, 1.0, false)
return
var fish: FishDataType = _fish_catalog.get_fish_by_id(
StringName(str(data["fish_id"]))
)
avatar.set_held_fish(fish, float(data["display_scale"]), true)
func _on_selected_assignment_changed(
_slot_index: int,
kind: int,
identity: StringName,
) -> void:
if (
_local_visible
and (
kind != PlayerHotbarType.AssignmentKind.FISH
or identity != _local_catch_id
)
):
_submit_local_state(null, false)
func _on_local_inventory_changed() -> void:
if (
_local_visible
and not _local_inventory.contains_catch_id(_local_catch_id)
):
_submit_local_state(null, false)
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
if not _session.is_host():
return
for state: Dictionary in _states.values():
if bool(state.get("visible", false)):
receive_showcase_state.rpc_id(peer_id, state)
func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
var state: Dictionary = _states.get(peer_id, {})
if not state.is_empty():
_apply_state(state)
func _on_peer_removed(peer_id: int) -> void:
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.set_held_fish(null, 1.0, false)
_states.erase(peer_id)
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state not in [
NetworkSession.State.INACTIVE,
NetworkSession.State.DISCONNECTING,
NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST,
]:
return
for peer_id: int in _states:
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.set_held_fish(null, 1.0, false)
_states.clear()
_local_visible = false
_local_catch_id = StringName()
var local_avatar: Player = _spawn_service.get_avatar(
_session.get_local_peer_id()
)
if local_avatar != null:
local_avatar.set_held_fish(null, 1.0, false)

View file

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

View file

@ -10,7 +10,7 @@ const MAX_PUBLIC_KEY_LENGTH: int = 8192
const MAX_SIGNATURE_LENGTH: int = 2048
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
# 6 reliable shop transactions, 7 reliable item/equipment/showcase lifecycle,
# 8 reliable ordered session chat, 9 reliable private session mail.
const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6
@ -187,6 +187,7 @@ static func make_server_hello(
"shop_v1",
"item_use_v1",
"equipment_v1",
"fish_showcase_v1",
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -360,7 +360,8 @@ 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",
"item_use_v1", "equipment_v1", "fish_showcase_v1",
"chat_v1",
"mail_v1",
"profile_v1",
"identity_v1",