Add host-authoritative multiplayer fishing

This commit is contained in:
Alexander Sellite 2026-07-29 16:29:25 -04:00
parent b0e2e84ba4
commit d3e3bf4053
17 changed files with 1672 additions and 23 deletions

View file

@ -135,6 +135,53 @@ static func from_save_dict(
return fish_catch if fish_catch.is_valid() else null return fish_catch if fish_catch.is_valid() else null
static func from_network_dict(
data: Dictionary,
resolved_fish: FishDataType,
) -> FishCatch:
if resolved_fish == null:
return null
for required_key: String in [
"catch_id",
"fish_id",
"weight_lb",
"display_scale",
"sale_value",
]:
if not data.has(required_key):
return null
var loaded_id := StringName(str(data.get("catch_id", "")))
var loaded_fish_id := StringName(str(data.get("fish_id", "")))
var loaded_weight: float = _read_safe_float(
data.get("weight_lb"), 0.0, MAX_SAFE_WEIGHT_LB
)
var loaded_scale: float = _read_safe_float(
data.get("display_scale"), 0.0, MAX_SAFE_DISPLAY_SCALE
)
var loaded_value: int = _read_safe_integer(
data.get("sale_value"), -1, MAX_SAFE_SALE_VALUE
)
if (
loaded_id.is_empty()
or loaded_id.length() > 160
or loaded_fish_id != resolved_fish.id
or loaded_weight <= 0.0
or loaded_scale <= 0.0
or loaded_value < 0
):
return null
var fish_catch := FishCatch.new()
fish_catch.fish = resolved_fish
fish_catch.fish_id = loaded_fish_id
fish_catch.catch_id = loaded_id
fish_catch.catch_sequence = 0
fish_catch.weight_lb = loaded_weight
fish_catch.display_scale = loaded_scale
fish_catch.sale_value = loaded_value
fish_catch.is_favorited = false
return fish_catch if fish_catch.is_valid() else null
static func _read_safe_integer( static func _read_safe_integer(
value: Variant, value: Variant,
invalid_value: int, invalid_value: int,

View file

@ -122,6 +122,21 @@ func start_encounter(
_emit_encounter_update() _emit_encounter_update()
func start_authoritative_encounter(
profile: CatchDifficultyProfileType,
reel_speed: float,
click_power: int,
seed: int,
) -> void:
var previous_test_mode: bool = use_deterministic_test_seed
var previous_seed: int = deterministic_test_seed
use_deterministic_test_seed = true
deterministic_test_seed = seed
start_encounter(profile, reel_speed, click_power)
use_deterministic_test_seed = previous_test_mode
deterministic_test_seed = previous_seed
func set_reel_input(held: bool) -> void: func set_reel_input(held: bool) -> void:
_reel_input_held = held _reel_input_held = held
if not held: if not held:

View file

@ -27,6 +27,9 @@ const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd" "res://world/fishable_water_region.gd"
) )
const NetworkSessionType = preload("res://network/network_session.gd") const NetworkSessionType = preload("res://network/network_session.gd")
const NetworkFishingServiceType = preload(
"res://network/network_fishing_service.gd"
)
signal status_changed(status: String) signal status_changed(status: String)
signal catch_display_changed( signal catch_display_changed(
@ -106,6 +109,7 @@ var _fishing_upgrades: PlayerFishingUpgradesType
var _item_effects: PlayerItemEffectsType var _item_effects: PlayerItemEffectsType
var _cooler_capacity: PlayerCoolerCapacityType var _cooler_capacity: PlayerCoolerCapacityType
var _network_session: NetworkSessionType var _network_session: NetworkSessionType
var _network_fishing: NetworkFishingServiceType
var _active_player: PlayerType var _active_player: PlayerType
var _state_time_remaining: float = 0.0 var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0 var _cast_charge: float = 0.0
@ -129,6 +133,8 @@ var _pending_catch: FishCatchType
var _showcase_ready: bool = false var _showcase_ready: bool = false
var _put_away_press_armed: bool = false var _put_away_press_armed: bool = false
var _showcase_restore_generation: int = 0 var _showcase_restore_generation: int = 0
var _network_auto_click_accumulator: float = 0.0
var _network_active_barrier_index: int = -1
func _ready() -> void: func _ready() -> void:
@ -150,6 +156,7 @@ func setup(
item_effects: PlayerItemEffectsType, item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType = null, network_session: NetworkSessionType = null,
network_fishing: NetworkFishingServiceType = null,
) -> void: ) -> void:
_local_player = local_player _local_player = local_player
_local_inventory = local_inventory _local_inventory = local_inventory
@ -161,6 +168,26 @@ func setup(
_item_effects = item_effects _item_effects = item_effects
_cooler_capacity = cooler_capacity _cooler_capacity = cooler_capacity
_network_session = network_session _network_session = network_session
_network_fishing = network_fishing
if _network_fishing != null:
_network_fishing.local_cast_accepted.connect(
_on_network_cast_accepted
)
_network_fishing.local_cast_rejected.connect(
_on_network_cast_rejected
)
_network_fishing.local_bite_started.connect(
_on_network_bite_started
)
_network_fishing.local_snapshot_received.connect(
_on_network_fishing_snapshot
)
_network_fishing.local_catch_received.connect(
_on_network_catch_received
)
_network_fishing.local_attempt_ended.connect(
_on_network_attempt_ended
)
if not _local_inventory.catches_changed.is_connected( if not _local_inventory.catches_changed.is_connected(
_on_cooler_availability_changed _on_cooler_availability_changed
): ):
@ -350,14 +377,18 @@ func _process(delta: float) -> void:
if not Input.is_action_pressed("fish_primary"): if not Input.is_action_pressed("fish_primary"):
_confirm_cast() _confirm_cast()
FishingState.WAITING_FOR_BITE: FishingState.WAITING_FOR_BITE:
if _network_fishing == null:
_update_waiting_for_bite(delta) _update_waiting_for_bite(delta)
FishingState.FIGHTING: FishingState.FIGHTING:
if _network_fishing == null:
_catch_controller.set_effective_stats( _catch_controller.set_effective_stats(
_get_effective_reel_speed(), _get_effective_reel_speed(),
_get_effective_barrier_damage() _get_effective_barrier_damage()
) )
if not Input.is_action_pressed("fish_primary"): if not Input.is_action_pressed("fish_primary"):
_catch_controller.set_reel_input(false) _catch_controller.set_reel_input(false)
else:
_update_network_auto_click(delta)
FishingState.COOLDOWN: FishingState.COOLDOWN:
_state_time_remaining -= delta _state_time_remaining -= delta
if _state_time_remaining <= 0.0: if _state_time_remaining <= 0.0:
@ -375,14 +406,6 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if not event.is_action("fish_primary"): if not event.is_action("fish_primary"):
return return
if not _can_use_shared_gameplay():
if event.is_pressed():
status_changed.emit(
"Fishing in joined games is coming in the next multiplayer phase."
)
get_viewport().set_input_as_handled()
return
if event.is_pressed(): if event.is_pressed():
match state: match state:
FishingState.READY: FishingState.READY:
@ -414,10 +437,15 @@ func _unhandled_input(event: InputEvent) -> void:
_begin_aiming(_local_player) _begin_aiming(_local_player)
FishingState.WAITING_FOR_BITE: FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = true _withdrawal_input_held = true
if _network_fishing != null:
_network_fishing.submit_local_input(true, true)
_presentation.set_line_mode( _presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT FishingPresentationType.LineMode.TAUT
) )
FishingState.FIGHTING: FishingState.FIGHTING:
if _network_fishing != null:
_network_fishing.submit_local_input(true, true)
else:
_catch_controller.handle_primary_pressed() _catch_controller.handle_primary_pressed()
_presentation.set_line_mode( _presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT FishingPresentationType.LineMode.TAUT
@ -435,9 +463,14 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif state == FishingState.WAITING_FOR_BITE: elif state == FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = false _withdrawal_input_held = false
if _network_fishing != null:
_network_fishing.submit_local_input(false, false)
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif state == FishingState.FIGHTING: elif state == FishingState.FIGHTING:
if _network_fishing != null:
_network_fishing.submit_local_input(false, false)
else:
_catch_controller.set_reel_input(false) _catch_controller.set_reel_input(false)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -558,6 +591,15 @@ func _confirm_cast() -> void:
func _on_cast_completed() -> void: func _on_cast_completed() -> void:
if state != FishingState.CASTING: if state != FishingState.CASTING:
return return
if _network_fishing != null:
_network_fishing.request_local_cast(
_cast_origin_position,
_cast_target,
_cast_charge,
_build_network_evidence()
)
status_changed.emit("checking the water...")
return
_selected_water_region = get_fishable_water_region(_cast_target) _selected_water_region = get_fishable_water_region(_cast_target)
_cast_landing_is_fishable = _selected_water_region != null _cast_landing_is_fishable = _selected_water_region != null
@ -593,6 +635,8 @@ func _on_cast_completed() -> void:
) )
_withdrawal_progress = 0.0 _withdrawal_progress = 0.0
_withdrawal_input_held = false _withdrawal_input_held = false
_network_auto_click_accumulator = 0.0
_network_active_barrier_index = -1
_bobber_water_position = _cast_target _bobber_water_position = _cast_target
_withdrawal_endpoint = Vector3( _withdrawal_endpoint = Vector3(
_cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance, _cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance,
@ -948,6 +992,9 @@ func _return_to_ready() -> void:
func _cancel_attempt() -> void: func _cancel_attempt() -> void:
if _network_fishing != null and _network_fishing.has_local_attempt():
_network_fishing.cancel_local_attempt("Fishing cancelled.")
return
_cleanup_attempt("fishing cancelled.", &"cancel") _cleanup_attempt("fishing cancelled.", &"cancel")
@ -1045,6 +1092,169 @@ func _build_fishing_context(
return context return context
func build_network_context(
region: FishableWaterRegionType,
) -> FishingContextType:
return _build_fishing_context(region)
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
)
var discovered_ids: Array[String] = []
if _local_collection_log != null:
for fish_id: StringName in _local_collection_log.get_discovered_ids():
discovered_ids.append(str(fish_id))
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
),
"rarity_multipliers": rarity_multipliers,
"discovered_fish_ids": discovered_ids,
"capacity_available": not _is_cooler_full(),
}
func _on_network_cast_accepted(
_attempt_id: String,
target: Vector3,
) -> void:
if state != FishingState.CASTING:
return
_cast_target = target
_bobber_water_position = target
state = FishingState.WAITING_FOR_BITE
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
status_changed.emit("waiting for a bite...")
func _on_network_cast_rejected(message: String) -> void:
if state not in [FishingState.CASTING, FishingState.AIMING_CAST]:
return
_cleanup_attempt(message, &"invalid")
func _on_network_bite_started(_attempt_id: String) -> void:
if state != FishingState.WAITING_FOR_BITE:
return
state = FishingState.FIGHTING
_withdrawal_input_held = false
_network_auto_click_accumulator = 0.0
_network_active_barrier_index = -1
_fight_start_position = _bobber_water_position
bite_activated.emit()
status_changed.emit("fish on!")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_reeling()
_presentation.show_bite()
_network_fishing.submit_local_input(
Input.is_action_pressed("fish_primary"),
false
)
func _on_network_fishing_snapshot(snapshot: Dictionary) -> void:
if state not in [
FishingState.WAITING_FOR_BITE,
FishingState.FIGHTING,
]:
return
var positions := PackedFloat32Array(snapshot.get("barrier_positions", []))
var health := PackedInt32Array(snapshot.get("barrier_health", []))
var maximum_health := PackedInt32Array(
snapshot.get("barrier_max_health", [])
)
var progress: float = float(snapshot.get("progress", 0.0))
var chase_progress: float = float(snapshot.get("chase_progress", 0.0))
if state == FishingState.FIGHTING:
_network_active_barrier_index = int(
snapshot.get("active_barrier_index", -1)
)
catch_display_changed.emit(
progress,
chase_progress,
positions,
health,
maximum_health,
int(snapshot.get("active_barrier_index", -1)),
bool(snapshot.get("visible", false))
)
var bobber_data: Array = snapshot.get("bobber_position", [])
if bobber_data.size() == 3:
_bobber_water_position = Vector3(
float(bobber_data[0]),
float(bobber_data[1]),
float(bobber_data[2])
)
if state == FishingState.WAITING_FOR_BITE:
_presentation.show_withdrawal_position(_bobber_water_position)
else:
_presentation.show_reel_position(
_bobber_water_position,
Input.is_action_pressed("fish_primary")
)
func _update_network_auto_click(delta: float) -> void:
if (
not _catch_controller.auto_click_enabled
or not Input.is_action_pressed("fish_primary")
or _network_active_barrier_index < 0
):
_network_auto_click_accumulator = 0.0
return
_network_auto_click_accumulator += delta
var interval: float = maxf(
_catch_controller.auto_click_interval,
0.05
)
while _network_auto_click_accumulator >= interval:
_network_auto_click_accumulator -= interval
_network_fishing.submit_local_input(true, true)
func _on_network_catch_received(fish_catch: FishCatchType) -> void:
if state != FishingState.FIGHTING or fish_catch == null:
return
_pending_catch = fish_catch
state = FishingState.SHOWING_CATCH
_showcase_ready = false
_put_away_press_armed = false
catch_display_changed.emit(
0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
PackedInt32Array(), -1, false
)
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.play_outcome(&"catch")
func _on_network_attempt_ended(
outcome: StringName,
message: String,
) -> void:
if state not in [
FishingState.CASTING,
FishingState.WAITING_FOR_BITE,
FishingState.FIGHTING,
]:
return
_cleanup_attempt(
message if not message.is_empty() else "Fishing attempt ended.",
&"escape" if outcome == &"escape" else &"cancel"
)
func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3: func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3:
if from.is_equal_approx(to): if from.is_equal_approx(to):
return from return from

View file

@ -0,0 +1,91 @@
class_name RemoteFishingPresentation
extends Node3D
var _owner: Player
var _bobber: MeshInstance3D
var _line: MeshInstance3D
var _line_mesh := ImmediateMesh.new()
var _target: Vector3
var _active: bool = false
func setup(owner: Player) -> void:
_owner = owner
_bobber = MeshInstance3D.new()
var bobber_mesh := SphereMesh.new()
bobber_mesh.radius = 0.12
bobber_mesh.height = 0.24
_bobber.mesh = bobber_mesh
var bobber_material := StandardMaterial3D.new()
bobber_material.albedo_color = Color(0.92, 0.12, 0.08, 1.0)
bobber_material.roughness = 0.45
_bobber.material_override = bobber_material
add_child(_bobber)
_line = MeshInstance3D.new()
_line.mesh = _line_mesh
var line_material := StandardMaterial3D.new()
line_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
line_material.albedo_color = Color(0.9, 0.9, 0.82, 1.0)
_line.material_override = line_material
add_child(_line)
cleanup()
func show_cast(target: Vector3) -> void:
if _owner == null or not target.is_finite():
return
_active = true
_target = target
_bobber.global_position = target
_bobber.visible = true
_line.visible = true
_owner.set_active_item_is_rod(true)
_redraw_line()
func update_bobber(position: Vector3) -> void:
if not _active or not position.is_finite():
return
_target = position
_bobber.global_position = position
_redraw_line()
func show_bite() -> void:
if not _active:
return
var tween: Tween = create_tween()
tween.tween_property(_bobber, "scale", Vector3.ONE * 0.7, 0.08)
tween.tween_property(_bobber, "scale", Vector3.ONE, 0.12)
func cleanup() -> void:
_active = false
if _bobber != null:
_bobber.visible = false
_bobber.scale = Vector3.ONE
if _line != null:
_line.visible = false
_line_mesh.clear_surfaces()
func _process(_delta: float) -> void:
if _active:
_redraw_line()
func _redraw_line() -> void:
if _owner == null or not is_instance_valid(_owner):
cleanup()
return
var tip: Marker3D = _owner.get_fishing_rod_tip()
if tip == null:
return
_line_mesh.clear_surfaces()
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
_line_mesh.surface_add_vertex(to_local(tip.global_position))
var midpoint: Vector3 = tip.global_position.lerp(_target, 0.5)
midpoint.y -= 0.12
_line_mesh.surface_add_vertex(to_local(midpoint))
_line_mesh.surface_add_vertex(to_local(_target))
_line_mesh.surface_end()

View file

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

View file

@ -43,6 +43,9 @@ const SavedServerStoreType = preload(
const PlayerSpawnServiceType = preload( const PlayerSpawnServiceType = preload(
"res://network/player_spawn_service.gd" "res://network/player_spawn_service.gd"
) )
const NetworkFishingServiceType = preload(
"res://network/network_fishing_service.gd"
)
const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_SILENCE_DB: float = -80.0
@ -77,6 +80,9 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
@onready var _player_spawn_service: PlayerSpawnServiceType = ( @onready var _player_spawn_service: PlayerSpawnServiceType = (
%PlayerSpawnService %PlayerSpawnService
) )
@onready var _network_fishing: NetworkFishingServiceType = (
%NetworkFishingService
)
@onready var _players_root: Node3D = $Players @onready var _players_root: Node3D = $Players
var _gameplay_started: bool = false var _gameplay_started: bool = false
@ -136,6 +142,17 @@ func _ready() -> void:
_player.cooler_capacity _player.cooler_capacity
) )
_save_manager.set_autosave_enabled(false) _save_manager.set_autosave_enabled(false)
_network_fishing.setup(
_network_session,
_player_spawn_service,
_fishing_spot,
_player.inventory,
_player.collection_log,
_player.cooler_capacity,
_save_manager,
item_catalog,
fish_catalog
)
_fishing_spot.setup( _fishing_spot.setup(
_player, _player,
_player.inventory, _player.inventory,
@ -146,7 +163,8 @@ func _ready() -> void:
_player.fishing_upgrades, _player.fishing_upgrades,
_player.item_effects, _player.item_effects,
_player.cooler_capacity, _player.cooler_capacity,
_network_session _network_session,
_network_fishing
) )
_game_ui.setup( _game_ui.setup(
_player, _player,
@ -562,6 +580,10 @@ func _on_remote_recovery_requested(
var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id) var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id)
if avatar == null: if avatar == null:
return return
_network_fishing.cancel_peer_attempt(
peer_id,
"Fishing attempt ended."
)
var target_position: Vector3 = _test_world.get_player_spawn_transform().origin var target_position: Vector3 = _test_world.get_player_spawn_transform().origin
var nearest_distance: float = INF var nearest_distance: float = INF
for point: SafeRespawnPoint in _test_world.get_safe_respawn_points(): for point: SafeRespawnPoint in _test_world.get_safe_respawn_points():

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=21 format=3] [gd_scene load_steps=22 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"]
@ -20,6 +20,7 @@
[ext_resource type="Script" path="res://network/network_profile_preferences.gd" id="18_network_profile"] [ext_resource type="Script" path="res://network/network_profile_preferences.gd" id="18_network_profile"]
[ext_resource type="Script" path="res://network/saved_server_store.gd" id="19_saved_servers"] [ext_resource type="Script" path="res://network/saved_server_store.gd" id="19_saved_servers"]
[ext_resource type="Script" path="res://network/player_spawn_service.gd" id="20_spawn_service"] [ext_resource type="Script" path="res://network/player_spawn_service.gd" id="20_spawn_service"]
[ext_resource type="Script" path="res://network/network_fishing_service.gd" id="21_network_fishing"]
[node name="Main" type="Node3D"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
@ -44,6 +45,10 @@ script = ExtResource("19_saved_servers")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("20_spawn_service") script = ExtResource("20_spawn_service")
[node name="NetworkFishingService" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("21_network_fishing")
[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,7 @@ 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, 3) var error: Error = enet_peer.create_server(port, max_clients, 5)
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 +27,7 @@ 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, 3) var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 5)
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,38 @@
class_name NetworkFishingAttempt
extends RefCounted
enum Phase {
CASTING,
WAITING_FOR_BITE,
FIGHTING,
PENDING_CAPACITY,
CAUGHT,
ESCAPED,
CANCELLED,
}
var owner_peer_id: int = 0
var request_id: String = ""
var attempt_id: String = ""
var result_id: String = ""
var session_id: String = ""
var phase: Phase = Phase.CASTING
var origin: Vector3
var target: Vector3
var bobber_position: Vector3
var fish_id: StringName
var encounter_seed: int = 0
var bite_time_remaining: float = 0.0
var withdrawal_progress: float = 0.0
var reel_speed: float = 0.0
var barrier_damage: int = 1
var input_held: bool = false
var last_input_sequence: int = 0
var catch_payload: Dictionary = {}
var capacity_nonce: String = ""
var capacity_deadline: float = 0.0
var controller: CatchController
func is_terminal() -> bool:
return phase in [Phase.CAUGHT, Phase.ESCAPED, Phase.CANCELLED]

View file

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

View file

@ -0,0 +1,142 @@
class_name NetworkFishingProtocol
extends RefCounted
const MAX_ID_LENGTH: int = 96
const MAX_EVIDENCE_FISH_IDS: int = 256
const MAX_CAST_DISTANCE: float = 30.0
const MAX_REEL_SPEED: float = 10.0
const MAX_BARRIER_DAMAGE: int = 100
const INPUT_CHANNEL: int = 3
const SNAPSHOT_CHANNEL: int = 4
const INPUT_RATE: float = 1.0 / 25.0
const SNAPSHOT_RATE: float = 1.0 / 15.0
enum Outcome {
CATCH,
ESCAPE,
CANCELLED,
}
static func validate_cast_request(data: Variant) -> String:
if typeof(data) != TYPE_DICTIONARY:
return "Malformed fishing request."
var payload: Dictionary = data
for key: String in [
"request_id",
"session_id",
"origin",
"target",
"charge",
"rod_id",
"reel_speed",
"barrier_damage",
"bite_multiplier",
"rarity_multipliers",
"discovered_fish_ids",
"capacity_available",
]:
if not payload.has(key):
return "Malformed fishing request."
if (
typeof(payload["request_id"]) != TYPE_STRING
or typeof(payload["session_id"]) != TYPE_STRING
or typeof(payload["origin"]) != TYPE_ARRAY
or typeof(payload["target"]) != TYPE_ARRAY
or typeof(payload["charge"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["rod_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(payload["reel_speed"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["barrier_damage"]) != TYPE_INT
or typeof(payload["bite_multiplier"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY
or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY
or typeof(payload["capacity_available"]) != TYPE_BOOL
):
return "Malformed fishing request."
var request_id: String = payload["request_id"]
var session_id: String = payload["session_id"]
var origin: Array = payload["origin"]
var target: Array = payload["target"]
var charge: float = float(payload["charge"])
var reel_speed: float = float(payload["reel_speed"])
var barrier_damage: int = payload["barrier_damage"]
var bite_multiplier: float = float(payload["bite_multiplier"])
var rarity: Array = payload["rarity_multipliers"]
var discovered: Array = payload["discovered_fish_ids"]
if (
request_id.is_empty()
or request_id.length() > MAX_ID_LENGTH
or session_id.is_empty()
or session_id.length() > MAX_ID_LENGTH
or origin.size() != 3
or target.size() != 3
or not _array_is_finite_vector3(origin)
or not _array_is_finite_vector3(target)
or not is_finite(charge)
or charge < 0.0
or charge > 1.01
or not is_finite(reel_speed)
or reel_speed <= 0.0
or reel_speed > MAX_REEL_SPEED
or barrier_damage < 1
or barrier_damage > MAX_BARRIER_DAMAGE
or not is_finite(bite_multiplier)
or bite_multiplier < 0.1
or bite_multiplier > 10.0
or rarity.size() > 16
or discovered.size() > MAX_EVIDENCE_FISH_IDS
or str(payload["rod_id"]).is_empty()
or str(payload["rod_id"]).length() > 96
):
return "Fishing request values are outside allowed limits."
for value: Variant in rarity:
if (
typeof(value) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(value))
or float(value) < 0.0
or float(value) > 10.0
):
return "Fishing rarity evidence is invalid."
for value: Variant in discovered:
if (
typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]
or str(value).is_empty()
or str(value).length() > 96
):
return "Fishing discovery evidence is invalid."
return ""
static func validate_input(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var payload: Dictionary = data
return (
typeof(payload.get("attempt_id")) == TYPE_STRING
and not str(payload["attempt_id"]).is_empty()
and str(payload["attempt_id"]).length() <= MAX_ID_LENGTH
and typeof(payload.get("sequence")) == TYPE_INT
and int(payload["sequence"]) > 0
and typeof(payload.get("held")) == TYPE_BOOL
and typeof(payload.get("pressed")) == TYPE_BOOL
)
static func vector3_to_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
static func array_to_vector3(value: Array) -> Vector3:
if value.size() != 3:
return Vector3.INF
return Vector3(float(value[0]), float(value[1]), float(value[2]))
static func _array_is_finite_vector3(value: Array) -> bool:
for component: Variant in value:
if (
typeof(component) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(component))
):
return false
return true

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol class_name NetworkProtocol
extends RefCounted extends RefCounted
const PROTOCOL_VERSION: int = 1 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 = 48
const MAX_PROFILE_ID_LENGTH: int = 96 const MAX_PROFILE_ID_LENGTH: int = 96
@ -99,7 +99,10 @@ static func make_server_hello(
"server_display_name": "NETFISHING", "server_display_name": "NETFISHING",
"player_count": player_count, "player_count": player_count,
"max_players": max_players, "max_players": max_players,
"capability_flags": PackedStringArray(["movement_v1"]), "capability_flags": PackedStringArray([
"movement_v1",
"fishing_v1",
]),
} }

View file

@ -254,6 +254,26 @@ func can_use_host_gameplay() -> bool:
return is_host() return is_host()
func is_gameplay_session_active() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT]
func is_authenticated_peer(peer_id: int) -> bool:
return _registry.has_peer(peer_id)
func get_session_id() -> String:
return _session_id
func get_operation_generation() -> int:
return _operation_generation
func get_local_peer_id() -> int:
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func _process(delta: float) -> void: func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0 var now: float = Time.get_ticks_msec() / 1000.0
if ( if (

View file

@ -506,6 +506,10 @@ func set_water_recovery_active(active: bool) -> void:
velocity = Vector3.ZERO velocity = Vector3.ZERO
func is_water_recovery_active() -> bool:
return _water_recovery_active
func prepare_for_water_recovery() -> void: func prepare_for_water_recovery() -> void:
_restore_gameplay_presentation_for_recovery() _restore_gameplay_presentation_for_recovery()