Compare commits

...

4 commits

36 changed files with 1141 additions and 137 deletions

View file

@ -1,6 +1,6 @@
NETfishing NETfishing
v0.6.8-alpha v0.7.1-alpha
Alpha 0.6.8 Alpha 0.7.1
Thank you for trying this early private playtest. Thank you for trying this early private playtest.

View file

@ -9,7 +9,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.6.8-alpha/windows-x86_64/NETfishing.exe" export_path="builds/v0.7.1-alpha/windows-x86_64/NETfishing.exe"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -35,11 +35,11 @@ application/modify_resources=true
application/icon="res://art/exported/system_icons/netfishing.ico" application/icon="res://art/exported/system_icons/netfishing.ico"
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=4 application/icon_interpolation=4
application/file_version="0.6.8.0" application/file_version="0.7.1.0"
application/product_version="0.6.8.0" application/product_version="0.7.1.0"
application/company_name="Woofmeow" application/company_name="Woofmeow"
application/product_name="NETfishing" application/product_name="NETfishing"
application/file_description="NETfishing v0.6.8-alpha" application/file_description="NETfishing v0.7.1-alpha"
application/copyright="Copyright © 2026 Woofmeow" application/copyright="Copyright © 2026 Woofmeow"
application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0 application/export_angle=0
@ -62,7 +62,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.6.8-alpha/linux-arm64/NETfishing.arm64" export_path="builds/v0.7.1-alpha/linux-arm64/NETfishing.arm64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -92,7 +92,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.6.8-alpha/macos/NETfishing.zip" export_path="builds/v0.7.1-alpha/macos/NETfishing.zip"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -108,8 +108,8 @@ custom_template/release=""
application/bundle_identifier="io.woofmeow.netfishing" application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon="res://art/exported/system_icons/netfishing_1024.png"
application/icon_interpolation=0 application/icon_interpolation=0
application/short_version="0.6.8" application/short_version="0.7.1"
application/version="0.6.8" application/version="0.7.1"
application/architecture="universal" application/architecture="universal"
codesign/enable=false codesign/enable=false
notarization/enable=false notarization/enable=false
@ -125,7 +125,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*,tests/*" exclude_filter="builds/*,scripts/*,tests/*"
export_path="builds/v0.6.8-alpha/server-linux-x86_64/NETfishingServer.x86_64" export_path="builds/v0.7.1-alpha/server-linux-x86_64/NETfishingServer.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -155,7 +155,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.6.8-alpha/android/NETfishing.apk" export_path="builds/v0.7.1-alpha/android/NETfishing.apk"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true architectures/arm64-v8a=true
architectures/x86=false architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
version/code=60800 version/code=70100
version/name="v0.6.8-alpha" version/name="v0.7.1-alpha"
package/unique_name="io.woofmeow.netfishing" package/unique_name="io.woofmeow.netfishing"
package/name="NETfishing" package/name="NETfishing"
package/signed=true package/signed=true
@ -214,7 +214,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.6.8-alpha/linux-x86_64/NETfishing.x86_64" export_path="builds/v0.7.1-alpha/linux-x86_64/NETfishing.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""

View file

@ -868,7 +868,7 @@ func _on_cast_completed() -> void:
_cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance _cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance
) )
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
status_changed.emit("waiting for a bite...") status_changed.emit("")
func _consume_active_bait() -> void: func _consume_active_bait() -> void:
@ -1131,10 +1131,7 @@ func _on_catch_escaped() -> void:
return return
_stop_fight_audio() _stop_fight_audio()
var fish_name: String = "the fish" _cleanup_attempt("", &"escape")
if _selected_fish != null and not _selected_fish.display_name.is_empty():
fish_name = "the %s" % _selected_fish.display_name
_cleanup_attempt("%s got away!" % fish_name, &"escape")
func _on_outcome_completed(outcome: StringName) -> void: func _on_outcome_completed(outcome: StringName) -> void:
@ -1597,7 +1594,7 @@ func _on_network_cast_accepted(
_network_input_resend_elapsed = 0.0 _network_input_resend_elapsed = 0.0
_presentation.show_withdrawal_position(_bobber_water_position) _presentation.show_withdrawal_position(_bobber_water_position)
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
status_changed.emit("waiting for a bite...") status_changed.emit("")
func _on_network_cast_rejected(message: String) -> void: func _on_network_cast_rejected(message: String) -> void:

View file

@ -150,6 +150,10 @@ func get_plan_id() -> String:
return _active_plan_id return _active_plan_id
func has_active_board() -> bool:
return validate_board(_active_board)
func get_time_until_refresh_text() -> String: func get_time_until_refresh_text() -> String:
if _active_plan_id.is_empty() or _world_time == null: if _active_plan_id.is_empty() or _world_time == null:
return "daily jobs unavailable" return "daily jobs unavailable"

View file

@ -334,6 +334,7 @@ func _start_dedicated_server() -> void:
if not _network_session.set_host_open(true): if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.") _fail_dedicated_server("Could not open the dedicated server.")
return return
_player_jobs.begin_progression_session()
if config.public_listing and not _discovery.set_discoverable(true): if config.public_listing and not _discovery.set_discoverable(true):
_network_session.disconnect_session("Discovery setup failed.") _network_session.disconnect_session("Discovery setup failed.")
_fail_dedicated_server(_discovery.get_host_status_message()) _fail_dedicated_server(_discovery.get_host_status_message())
@ -560,7 +561,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_player_list.set_surface_drawing_service( _network_player_list.set_surface_drawing_service(
_network_surface_drawing _network_surface_drawing
) )
_network_chat.setup(_network_session) _network_chat.setup(_network_session, _world_time, _world_weather)
_network_fishing.setup( _network_fishing.setup(
_network_session, _network_session,
_player_spawn_service, _player_spawn_service,
@ -1555,8 +1556,13 @@ func _on_pause_join_game_requested(endpoint: String) -> void:
_join_requested_from_title = false _join_requested_from_title = false
_pending_join_endpoint = endpoint _pending_join_endpoint = endpoint
_game_ui.get_pause_menu().close_for_title_transition() _game_ui.get_pause_menu().close_for_title_transition()
var preserved_public_join: bool = (
_discovery.preserve_public_join_for_session_switch()
)
_network_session.disconnect_session("Connecting to another game.") _network_session.disconnect_session("Connecting to another game.")
if not _network_session.join_direct(endpoint): if not _network_session.join_direct(endpoint):
if preserved_public_join:
_discovery.cancel_pending_public_join()
_handle_failed_session_switch( _handle_failed_session_switch(
"Could not begin the direct connection." "Could not begin the direct connection."
) )
@ -1641,14 +1647,16 @@ func _handle_failed_session_switch(message: String) -> void:
_show_title_music(true) _show_title_music(true)
func _on_network_server_lost() -> void: func _on_network_server_lost(message: String) -> void:
if not _gameplay_started: if not _gameplay_started:
return return
_join_requested_from_title = false
_join_requested_from_pause = false
_pending_join_endpoint = ""
_set_gameplay_active(false) _set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen() var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.reopen() title_screen.reopen_to_menu()
title_screen.open_join_game_page(_pending_join_endpoint) title_screen.report_network_error(message)
title_screen.report_network_error("The server connection was lost.")
_show_title_music(true) _show_title_music(true)

View file

@ -85,6 +85,7 @@ var _join_request_in_flight: bool = false
var _pending_join_endpoint: String = "" var _pending_join_endpoint: String = ""
var _pending_join_token: String = "" var _pending_join_token: String = ""
var _pending_join_room_id: String = "" var _pending_join_room_id: String = ""
var _preserve_pending_join_on_inactive: bool = false
var _upnp_thread: Thread var _upnp_thread: Thread
var _upnp_mapping_in_progress: bool = false var _upnp_mapping_in_progress: bool = false
var _upnp_operation_is_renewal: bool = false var _upnp_operation_is_renewal: bool = false
@ -291,6 +292,17 @@ func is_public_join_preparing() -> bool:
return _join_request_in_flight return _join_request_in_flight
func preserve_public_join_for_session_switch() -> bool:
_preserve_pending_join_on_inactive = not _pending_join_token.is_empty()
return _preserve_pending_join_on_inactive
func cancel_pending_public_join() -> void:
_preserve_pending_join_on_inactive = false
_set_public_join_state(PublicJoinState.IDLE)
_clear_pending_join()
func prepare_public_join(room: Dictionary) -> bool: func prepare_public_join(room: Dictionary) -> bool:
if _join_request_in_flight: if _join_request_in_flight:
return false return false
@ -913,18 +925,30 @@ func _discovery_version_mismatch_message(required_version: String) -> String:
func _on_session_state_changed(state: NetworkSession.State) -> void: func _on_session_state_changed(state: NetworkSession.State) -> void:
if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty(): if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
_preserve_pending_join_on_inactive = false
_set_public_join_state(PublicJoinState.CONNECTING) _set_public_join_state(PublicJoinState.CONNECTING)
_join_probe_timer.start() _join_probe_timer.start()
call_deferred("_send_pending_join_probe") call_deferred("_send_pending_join_probe")
elif state in [ elif state in [
NetworkSession.State.JOINED_CLIENT, NetworkSession.State.JOINED_CLIENT,
NetworkSession.State.SERVER_LOST, NetworkSession.State.SERVER_LOST,
NetworkSession.State.INACTIVE,
]: ]:
_preserve_pending_join_on_inactive = false
_set_public_join_state(PublicJoinState.IDLE) _set_public_join_state(PublicJoinState.IDLE)
_join_probe_timer.stop() _join_probe_timer.stop()
_clear_pending_join() _clear_pending_join()
elif state == NetworkSession.State.INACTIVE:
if (
_preserve_pending_join_on_inactive
and not _pending_join_token.is_empty()
):
_preserve_pending_join_on_inactive = false
else:
_set_public_join_state(PublicJoinState.IDLE)
_join_probe_timer.stop()
_clear_pending_join()
elif state == NetworkSession.State.CONNECTION_FAILED: elif state == NetworkSession.State.CONNECTION_FAILED:
_preserve_pending_join_on_inactive = false
_set_public_join_state(PublicJoinState.ERROR) _set_public_join_state(PublicJoinState.ERROR)
_join_probe_timer.stop() _join_probe_timer.stop()
_clear_pending_join() _clear_pending_join()

View file

@ -5,6 +5,7 @@ const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5 const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0 const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180 const CALL_COOLDOWN_MILLISECONDS: int = 180
const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500
const CALL_PITCH_VARIANTS: Array[float] = [ const CALL_PITCH_VARIANTS: Array[float] = [
0.96, 0.96,
1.03, 1.03,
@ -22,6 +23,7 @@ signal message_received(message: Dictionary)
signal local_message_confirmed(message: Dictionary) signal local_message_confirmed(message: Dictionary)
signal send_rejected(message: String) signal send_rejected(message: String)
signal history_replaced(messages: Array[Dictionary]) signal history_replaced(messages: Array[Dictionary])
signal world_command_finished(success: bool, message: String)
signal character_call_received( signal character_call_received(
peer_id: int, peer_id: int,
call_id: String, call_id: String,
@ -35,13 +37,22 @@ var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {} var _rate_times: Dictionary[int, Array] = {}
var _last_call_msec: Dictionary[int, int] = {} var _last_call_msec: Dictionary[int, int] = {}
var _call_variant_indices: Dictionary[int, int] = {} var _call_variant_indices: Dictionary[int, int] = {}
var _last_world_command_msec: Dictionary[int, int] = {}
var _sequence: int = 0 var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {} var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore var _relationships: PlayerRelationshipStore
var _world_time: WorldTimeService
var _world_weather: WorldWeatherService
func setup(session: NetworkSession) -> void: func setup(
session: NetworkSession,
world_time: WorldTimeService,
world_weather: WorldWeatherService,
) -> void:
_session = session _session = session
_world_time = world_time
_world_weather = world_weather
_session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed) _session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed) _session.state_changed.connect(_on_session_state_changed)
@ -242,6 +253,188 @@ func broadcast_system_message(body: String) -> bool:
return true return true
func request_world_time_change(phase_name: String) -> bool:
var normalized: String = phase_name.strip_edges().to_lower()
if not _valid_time_phase(normalized) or _session == null:
return false
if _session.is_host():
return _apply_world_time_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
)
):
return false
submit_world_time_command.rpc_id(1, normalized)
return true
func request_world_weather_change(weather_name: String) -> bool:
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty() or _session == null:
return false
if _session.is_host():
return _apply_world_weather_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
)
):
return false
submit_world_weather_command.rpc_id(1, normalized)
return true
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_time_command(phase_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id, false, "Only the host or an operator can change world time."
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_time_change(
phase_name.strip_edges().to_lower()
)
_send_world_command_result(
sender_id,
success,
"" if success else "World time could not be changed.",
)
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_weather_command(weather_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id,
false,
"Only the host or an operator can change world weather.",
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_weather_change(
_normalized_weather_name(weather_name)
)
_send_world_command_result(
sender_id,
success,
"" if success else "World weather could not be changed.",
)
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func receive_world_command_result(success: bool, message: String) -> void:
if _session == null or not _session.is_joined_client():
return
world_command_finished.emit(success, message.left(120))
func _valid_world_command_sender(peer_id: int) -> bool:
return (
_session != null
and _session.is_host()
and peer_id > 1
and _session.is_authenticated_peer(peer_id)
and _session.is_peer_operator(peer_id)
)
func _apply_world_time_change(phase_name: String) -> bool:
if _world_time == null or not _valid_time_phase(phase_name):
return false
var target_hour: float = _time_phase_hour(phase_name)
if not _world_time.set_authoritative_time(target_hour):
return false
broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
return true
func _apply_world_weather_change(weather_name: String) -> bool:
if _world_weather == null:
return false
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty():
return false
var target_weather: WorldWeatherService.Weather = (
_weather_for_name(normalized)
)
if not _world_weather.set_authoritative_weather(target_weather):
return false
broadcast_system_message("World weather set to %s." % normalized)
return true
func _send_world_command_result(
peer_id: int,
success: bool,
message: String,
) -> void:
if peer_id > 1 and _session.is_authenticated_peer(peer_id):
receive_world_command_result.rpc_id(peer_id, success, message.left(120))
func _consume_world_command_rate(peer_id: int) -> bool:
var now_msec: int = Time.get_ticks_msec()
var last_msec: int = _last_world_command_msec.get(
peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS
)
if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS:
return false
_last_world_command_msec[peer_id] = now_msec
return true
static func _valid_time_phase(phase_name: String) -> bool:
return phase_name in ["dawn", "day", "dusk", "night"]
static func _time_phase_hour(phase_name: String) -> float:
match phase_name:
"dawn":
return WorldTimeService.DAWN_START_HOUR
"day":
return WorldTimeService.DAWN_END_HOUR
"dusk":
return WorldTimeService.DUSK_START_HOUR
"night":
return WorldTimeService.DUSK_END_HOUR
return -1.0
static func _normalized_weather_name(weather_name: String) -> String:
var normalized: String = weather_name.strip_edges().to_lower()
return "clear" if normalized == "sunny" else normalized if normalized in [
"clear", "cloudy", "rainy", "foggy"
] else ""
static func _weather_for_name(
weather_name: String,
) -> WorldWeatherService.Weather:
match weather_name:
"cloudy":
return WorldWeatherService.Weather.CLOUDY
"rainy":
return WorldWeatherService.Weather.RAINY
"foggy":
return WorldWeatherService.Weather.FOGGY
return WorldWeatherService.Weather.SUNNY
func get_history() -> Array[Dictionary]: func get_history() -> Array[Dictionary]:
var result: Array[Dictionary] = [] var result: Array[Dictionary] = []
for message: Dictionary in _history: for message: Dictionary in _history:
@ -422,6 +615,7 @@ func _on_peer_removed(peer_id: int) -> void:
_rate_times.erase(peer_id) _rate_times.erase(peer_id)
_last_call_msec.erase(peer_id) _last_call_msec.erase(peer_id)
_call_variant_indices.erase(peer_id) _call_variant_indices.erase(peer_id)
_last_world_command_msec.erase(peer_id)
if not _session.is_host(): if not _session.is_host():
return return
var display_name: String = _peer_names.get(peer_id, "Player") var display_name: String = _peer_names.get(peer_id, "Player")
@ -481,6 +675,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_rate_times.clear() _rate_times.clear()
_last_call_msec.clear() _last_call_msec.clear()
_call_variant_indices.clear() _call_variant_indices.clear()
_last_world_command_msec.clear()
_peer_names.clear() _peer_names.clear()
_sequence = 0 _sequence = 0
history_replaced.emit([]) history_replaced.emit([])

View file

@ -953,7 +953,7 @@ func _on_attempt_escaped(peer_id: int) -> void:
if attempt == null: if attempt == null:
return return
attempt.phase = NetworkFishingAttempt.Phase.ESCAPED attempt.phase = NetworkFishingAttempt.Phase.ESCAPED
_broadcast_public_outcome(attempt, &"escape", "The fish got away!") _broadcast_public_outcome(attempt, &"escape", "")
_dispose_attempt(peer_id) _dispose_attempt(peer_id)

View file

@ -2,11 +2,13 @@ class_name NetworkJobService
extends Node extends Node
const MAX_SESSION_ID_LENGTH: int = 96 const MAX_SESSION_ID_LENGTH: int = 96
const BOARD_REQUEST_INTERVAL_SECONDS: float = 1.5
var _session: NetworkSession var _session: NetworkSession
var _jobs: PlayerJobService var _jobs: PlayerJobService
var _sequence: int = 0 var _sequence: int = 0
var _last_received_sequence: int = -1 var _last_received_sequence: int = -1
var _board_request_accumulator: float = 0.0
func setup(session: NetworkSession, jobs: PlayerJobService) -> void: func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
@ -15,13 +17,36 @@ func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
_session.state_changed.connect(_on_session_state_changed) _session.state_changed.connect(_on_session_state_changed)
_session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_authenticated.connect(_on_peer_authenticated)
_jobs.board_changed.connect(_on_board_changed) _jobs.board_changed.connect(_on_board_changed)
set_process(true)
func _process(delta: float) -> void:
if (
_session == null
or _jobs == null
or not _session.is_joined_client()
or not _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
or _jobs.has_active_board()
):
_board_request_accumulator = 0.0
return
_board_request_accumulator += delta
if _board_request_accumulator < BOARD_REQUEST_INTERVAL_SECONDS:
return
_board_request_accumulator = 0.0
_request_remote_board()
func _on_session_state_changed(state: NetworkSession.State) -> void: func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0 _sequence = 0
_last_received_sequence = -1 _last_received_sequence = -1
_board_request_accumulator = 0.0
if state == NetworkSession.State.JOINED_CLIENT: if state == NetworkSession.State.JOINED_CLIENT:
if not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY): if _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY):
_request_remote_board()
else:
_jobs.clear_remote_board() _jobs.clear_remote_board()
elif state in [ elif state in [
NetworkSession.State.INACTIVE, NetworkSession.State.INACTIVE,
@ -69,6 +94,33 @@ func _send_board(peer_id: int) -> void:
}) })
func _request_remote_board() -> void:
if (
_session != null
and _session.is_joined_client()
and _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
):
request_job_board.rpc_id(1)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_job_board() -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
_session == null
or not _session.is_host()
or sender_id <= 1
or not _session.is_authenticated_peer(sender_id)
or not _session.peer_supports_capability(
sender_id, NetworkProtocol.JOBS_CAPABILITY
)
):
return
_send_board(sender_id)
@rpc("authority", "call_remote", "reliable", 0) @rpc("authority", "call_remote", "reliable", 0)
func receive_job_board(data: Dictionary) -> void: func receive_job_board(data: Dictionary) -> void:
if ( if (
@ -86,6 +138,7 @@ func receive_job_board(data: Dictionary) -> void:
var board: Dictionary = data.get("board", {}) var board: Dictionary = data.get("board", {})
if _jobs.apply_remote_board(board): if _jobs.apply_remote_board(board):
_last_received_sequence = sequence _last_received_sequence = sequence
_board_request_accumulator = 0.0
static func validate_snapshot(value: Variant) -> bool: static func validate_snapshot(value: Variant) -> bool:

View file

@ -0,0 +1,94 @@
class_name NetworkPlayerAnimationProtocol
extends RefCounted
const FORMAT_VERSION: int = 1
const MAX_STATE_ID_LENGTH: int = 64
const MAX_ACTION_SEQUENCE: int = 2147483647
const MAX_ACTION_ELAPSED_SECONDS: float = 86400.0
const LOCOMOTION_IDLE: StringName = &"idle"
const LOCOMOTION_WALKING: StringName = &"walking"
const LOCOMOTION_RUNNING: StringName = &"running"
static func make_state(
locomotion_id: StringName,
grounded: bool,
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"format_version": FORMAT_VERSION,
"locomotion_id": String(locomotion_id),
"grounded": grounded,
"action": make_action_state(
action_id, action_sequence, action_elapsed
),
}
static func make_action_state(
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"id": String(action_id),
"sequence": action_sequence,
"elapsed": action_elapsed,
}
static func validate_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var state: Dictionary = value
return (
typeof(state.get("format_version")) == TYPE_INT
and int(state["format_version"]) == FORMAT_VERSION
and valid_state_id(state.get("locomotion_id"), false)
and typeof(state.get("grounded")) == TYPE_BOOL
and validate_action_state(state.get("action"))
)
static func validate_action_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var action: Dictionary = value
if (
not valid_state_id(action.get("id"), true)
or typeof(action.get("sequence")) != TYPE_INT
or typeof(action.get("elapsed")) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var sequence: int = int(action["sequence"])
var elapsed: float = float(action["elapsed"])
return (
sequence >= 0
and sequence <= MAX_ACTION_SEQUENCE
and is_finite(elapsed)
and elapsed >= 0.0
and elapsed <= MAX_ACTION_ELAPSED_SECONDS
)
static func valid_state_id(value: Variant, allow_empty: bool) -> bool:
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
var state_id: String = str(value)
if state_id.is_empty():
return allow_empty
if state_id.length() > MAX_STATE_ID_LENGTH:
return false
for index: int in state_id.length():
var codepoint: int = state_id.unicode_at(index)
var valid_character: bool = (
codepoint >= 97 and codepoint <= 122
or codepoint >= 48 and codepoint <= 57
or codepoint == 95
)
if not valid_character:
return false
return true

View file

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

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol class_name NetworkProtocol
extends RefCounted extends RefCounted
const PROTOCOL_VERSION: int = 3 const PROTOCOL_VERSION: int = 4
const GAME_BUILD: String = "prealpha" const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_DISPLAY_NAME_LENGTH: int = 24

View file

@ -9,6 +9,7 @@ const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 30.0 const INPUT_INTERVAL: float = 1.0 / 30.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
signal state_changed(state: State) signal state_changed(state: State)
signal status_message_changed(message: String) signal status_message_changed(message: String)
@ -33,7 +34,7 @@ signal server_trust_required(
) )
signal peer_identity_observed(peer_id: int, status: String) signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool) signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost signal server_lost(message: String)
signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed( signal remote_recovery_presentation_changed(
peer_id: int, peer_id: int,
@ -874,7 +875,7 @@ func _on_server_disconnected() -> void:
) )
_moderation_disconnect_message = "" _moderation_disconnect_message = ""
_set_state(State.SERVER_LOST, message) _set_state(State.SERVER_LOST, message)
server_lost.emit() server_lost.emit(message)
connection_error.emit(message) connection_error.emit(message)
@ -1107,7 +1108,6 @@ func submit_client_identity_proof(data: Dictionary) -> void:
@rpc("authority", "call_remote", "reliable", 0) @rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_disconnect(message: String) -> void: func receive_moderation_disconnect(message: String) -> void:
_moderation_disconnect_message = message.left(120) _moderation_disconnect_message = message.left(120)
connection_error.emit(_moderation_disconnect_message)
@rpc("authority", "call_remote", "reliable", 0) @rpc("authority", "call_remote", "reliable", 0)
@ -1741,9 +1741,10 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
or typeof(data.get("sprint")) != TYPE_BOOL or typeof(data.get("sprint")) != TYPE_BOOL
or typeof(data.get("sneak")) != TYPE_BOOL or typeof(data.get("sneak")) != TYPE_BOOL
or typeof(data.get("slow_walk")) != TYPE_BOOL or typeof(data.get("slow_walk")) != TYPE_BOOL
or ( or typeof(data.get("sitting")) != TYPE_BOOL
data.has("sitting") or typeof(data.get("casting")) != TYPE_BOOL
and typeof(data.get("sitting")) != TYPE_BOOL or not NetworkPlayerAnimationProtocol.validate_action_state(
data.get("animation_action")
) )
or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT] or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
): ):
@ -1751,11 +1752,18 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
var axis: Array = data["axis"] var axis: Array = data["axis"]
if axis.size() != 2: if axis.size() != 2:
return false return false
if (
typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var x: float = float(axis[0]) var x: float = float(axis[0])
var y: float = float(axis[1]) var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"]) var camera_yaw: float = float(data["camera_yaw"])
return ( return (
is_finite(x) int(data["sequence"]) > 0
and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
and is_finite(x)
and is_finite(y) and is_finite(y)
and is_finite(camera_yaw) and is_finite(camera_yaw)
and absf(x) <= 1.01 and absf(x) <= 1.01

View file

@ -68,6 +68,10 @@ const CHARACTER_FISHING_ANIMATION: StringName = &"fishing"
const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit" const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit"
const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting" const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting"
const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit" const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit"
# Add future networked emote animation IDs here. The protocol accepts unknown
# safe IDs so newer clients can extend it, but Player only presents actions
# explicitly approved by this catalog.
const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = []
enum FishingVisualPhase { enum FishingVisualPhase {
NONE, NONE,
@ -84,6 +88,12 @@ enum PocketVisualTarget {
ART_KIT, ART_KIT,
CATCH_SHOWCASE, CATCH_SHOWCASE,
} }
enum LocomotionState {
IDLE,
WALKING,
RUNNING,
}
const FIGHTING_EYES_ID: String = "alligator_eyes" const FIGHTING_EYES_ID: String = "alligator_eyes"
const BLINK_EYES_ID: String = "closed" const BLINK_EYES_ID: String = "closed"
const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5) const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5)
@ -393,8 +403,18 @@ var _last_network_input_sequence: int = 0
var _network_target_position: Vector3 var _network_target_position: Vector3
var _network_target_velocity: Vector3 var _network_target_velocity: Vector3
var _network_target_visual_yaw: float = 0.0 var _network_target_visual_yaw: float = 0.0
var _network_target_grounded: bool = false
var _network_target_locomotion_state: LocomotionState = LocomotionState.IDLE
var _network_target_animation_action_id: StringName = &""
var _network_target_animation_action_sequence: int = 0
var _network_target_animation_action_elapsed: float = 0.0
var _network_snapshot_ready: bool = false var _network_snapshot_ready: bool = false
var _network_snapshot_age: float = 0.0 var _network_snapshot_age: float = 0.0
var _animation_action_id: StringName = &""
var _animation_action_sequence: int = 0
var _animation_action_elapsed: float = 0.0
var _presented_animation_action_id: StringName = &""
var _presented_animation_action_sequence: int = -1
var _character_animation_name: StringName = &"" var _character_animation_name: StringName = &""
var _sitting: bool = false var _sitting: bool = false
var _sit_after_landing: bool = false var _sit_after_landing: bool = false
@ -475,6 +495,72 @@ func set_controller_mapping_manager(
_controller_mapping_manager = mapping_manager _controller_mapping_manager = mapping_manager
func begin_animation_action(
action_id: StringName,
elapsed_seconds: float = 0.0,
) -> bool:
if (
action_id.is_empty()
or not supports_network_animation_action(action_id)
or _character_animation_player == null
or not _character_animation_player.has_animation(action_id)
):
return false
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
var action_state: Dictionary = (
NetworkPlayerAnimationProtocol.make_action_state(
action_id, next_sequence, elapsed_seconds
)
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return false
_apply_animation_action_state(action_state)
return true
func end_animation_action() -> void:
if _animation_action_id.is_empty():
return
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
_apply_animation_action_state(
NetworkPlayerAnimationProtocol.make_action_state(
&"", next_sequence, 0.0
)
)
func _apply_animation_action_state(action_state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return
var action_id := StringName(str(action_state["id"]))
if (
not action_id.is_empty()
and not supports_network_animation_action(action_id)
):
action_id = &""
var action_sequence: int = int(action_state["sequence"])
var action_changed: bool = (
action_id != _animation_action_id
or action_sequence != _animation_action_sequence
)
_animation_action_id = action_id
_animation_action_sequence = action_sequence
_animation_action_elapsed = float(action_state["elapsed"])
if action_changed:
_character_animation_name = &""
_update_character_animation()
func _initialize_fishing_rod() -> void: func _initialize_fishing_rod() -> void:
var skeleton := get_node_or_null( var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D" "Visuals/CharacterRig/CharacterRig/Skeleton3D"
@ -613,6 +699,11 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _animation_action_id.is_empty():
_animation_action_elapsed = minf(
_animation_action_elapsed + delta,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
)
_update_blink(delta) _update_blink(delta)
_update_character_animation() _update_character_animation()
_update_sprint_dust(delta) _update_sprint_dust(delta)
@ -705,7 +796,7 @@ func _update_sprint_dust(delta: float) -> void:
if _sprint_dust == null: if _sprint_dust == null:
return return
var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z) var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z)
var grounded: bool = is_on_floor() var grounded: bool = _get_presented_grounded()
if _water_recovery_active: if _water_recovery_active:
_sprint_dust_airborne = false _sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0 _sprint_dust_fall_speed = 0.0
@ -730,18 +821,11 @@ func _update_sprint_dust(delta: float) -> void:
) )
_sprint_dust_airborne = false _sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0 _sprint_dust_fall_speed = 0.0
var horizontal_speed_squared: float = horizontal_velocity.length_squared()
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
var should_emit: bool = ( var should_emit: bool = (
grounded grounded
and not _sitting and not _sitting
and not _water_recovery_active and not _water_recovery_active
and horizontal_speed_squared and _get_presented_locomotion_state() == LocomotionState.RUNNING
> running_threshold * running_threshold
) )
_sprint_dust.update_trail( _sprint_dust.update_trail(
delta, delta,
@ -778,18 +862,17 @@ func _get_controller_zoom_strength() -> float:
func _update_character_animation() -> void: func _update_character_animation() -> void:
if _character_animation_player == null: if _character_animation_player == null:
return return
var horizontal_speed_squared: float = ( var locomotion_state: LocomotionState = (
velocity.x * velocity.x + velocity.z * velocity.z _get_presented_locomotion_state()
) )
var is_walking: bool = horizontal_speed_squared > 0.0025 var is_walking: bool = locomotion_state != LocomotionState.IDLE
var fastest_non_sprint_speed := maxf( var is_running: bool = locomotion_state == LocomotionState.RUNNING
walk_speed, var animation_action: Dictionary = _get_presented_animation_action()
maxf(sneak_speed, slow_walk_speed) var animation_action_id := StringName(str(animation_action.get("id", "")))
) var animation_action_available: bool = (
var running_threshold := fastest_non_sprint_speed + 0.5 not animation_action_id.is_empty()
var is_running := ( and supports_network_animation_action(animation_action_id)
is_walking and _character_animation_player.has_animation(animation_action_id)
and horizontal_speed_squared > running_threshold * running_threshold
) )
var held_show_item_visible: bool = ( var held_show_item_visible: bool = (
_held_fish_visible or _held_art_kit_visible _held_fish_visible or _held_art_kit_visible
@ -878,6 +961,8 @@ func _update_character_animation() -> void:
CHARACTER_FISHING_ANIMATION, CHARACTER_FISHING_ANIMATION,
CHARACTER_IDLE_ANIMATION, CHARACTER_IDLE_ANIMATION,
] ]
elif animation_action_available:
requested_animation = [animation_action_id]
elif _sitting: elif _sitting:
if held_show_item_visible: if held_show_item_visible:
requested_animation = [ requested_animation = [
@ -934,13 +1019,45 @@ func _update_character_animation() -> void:
break break
if next_animation.is_empty(): if next_animation.is_empty():
return return
if _character_animation_name == next_animation: var action_selected: bool = (
animation_action_available and next_animation == animation_action_id
)
var action_sequence: int = int(animation_action.get("sequence", 0))
var action_changed: bool = (
action_selected
and (
animation_action_id != _presented_animation_action_id
or action_sequence != _presented_animation_action_sequence
)
)
if not action_selected:
_presented_animation_action_id = &""
_presented_animation_action_sequence = -1
if _character_animation_name == next_animation and not action_changed:
return return
_character_animation_player.play(next_animation) _character_animation_player.play(next_animation)
_character_animation_name = next_animation _character_animation_name = next_animation
if action_selected:
_presented_animation_action_id = animation_action_id
_presented_animation_action_sequence = action_sequence
var elapsed: float = float(animation_action.get("elapsed", 0.0))
var animation: Animation = _character_animation_player.get_animation(
next_animation
)
if elapsed > 0.0 and animation != null and animation.length > 0.0:
_character_animation_player.seek(
fposmod(elapsed, animation.length), true
)
func _on_character_animation_finished(animation_name: StringName) -> void: func _on_character_animation_finished(animation_name: StringName) -> void:
if (
local_control_enabled
and not _animation_action_id.is_empty()
and animation_name == _animation_action_id
):
end_animation_action()
return
if animation_name in [ if animation_name in [
CHARACTER_POCKET_IDLE_IDLE_ANIMATION, CHARACTER_POCKET_IDLE_IDLE_ANIMATION,
CHARACTER_POCKET_IDLE_SHOW_ANIMATION, CHARACTER_POCKET_IDLE_SHOW_ANIMATION,
@ -1278,6 +1395,11 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_interpolation_enabled = not authoritative_simulation _network_interpolation_enabled = not authoritative_simulation
_network_snapshot_ready = false _network_snapshot_ready = false
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_network_target_grounded = false
_network_target_locomotion_state = LocomotionState.IDLE
_network_target_animation_action_id = &""
_network_target_animation_action_sequence = 0
_network_target_animation_action_elapsed = 0.0
_camera.current = false _camera.current = false
@ -1301,6 +1423,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"casting": ( "casting": (
_fishing_visual_phase == FishingVisualPhase.CASTING _fishing_visual_phase == FishingVisualPhase.CASTING
), ),
"animation_action": _make_animation_action_state(),
} }
var axis: Vector2 = Input.get_vector( var axis: Vector2 = Input.get_vector(
"move_left", "move_left",
@ -1318,6 +1441,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"slow_walk": Input.is_action_pressed("slow_walk"), "slow_walk": Input.is_action_pressed("slow_walk"),
"sitting": _sitting, "sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
"animation_action": _make_animation_action_state(),
} }
@ -1335,6 +1459,7 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_network_sprint = bool(data.get("sprint", false)) _network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false)) _network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false)) _network_slow_walk = bool(data.get("slow_walk", false))
_apply_animation_action_state(data.get("animation_action", {}))
_apply_network_casting(bool(data.get("casting", false))) _apply_network_casting(bool(data.get("casting", false)))
var sitting_requested: bool = bool(data.get("sitting", false)) var sitting_requested: bool = bool(data.get("sitting", false))
if sitting_requested and not is_on_floor(): if sitting_requested and not is_on_floor():
@ -1352,7 +1477,13 @@ func make_network_snapshot(peer_id: int) -> Dictionary:
"position": [global_position.x, global_position.y, global_position.z], "position": [global_position.x, global_position.y, global_position.z],
"velocity": [velocity.x, velocity.y, velocity.z], "velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": _visuals.rotation.y, "visual_yaw": _visuals.rotation.y,
"grounded": is_on_floor(), "animation_state": NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
),
"sitting": _sitting, "sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
} }
@ -1365,6 +1496,7 @@ func push_network_snapshot(snapshot: Dictionary) -> void:
_network_target_position = parsed["position"] _network_target_position = parsed["position"]
_network_target_velocity = parsed["velocity"] _network_target_velocity = parsed["velocity"]
_network_target_visual_yaw = parsed["visual_yaw"] _network_target_visual_yaw = parsed["visual_yaw"]
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_apply_network_casting(bool(parsed["casting"])) _apply_network_casting(bool(parsed["casting"]))
_set_sitting(bool(parsed["sitting"])) _set_sitting(bool(parsed["sitting"]))
@ -1412,6 +1544,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
_network_target_position = global_position _network_target_position = global_position
_network_target_velocity = velocity _network_target_velocity = velocity
_network_target_visual_yaw = _visuals.rotation.y _network_target_visual_yaw = _visuals.rotation.y
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_network_snapshot_ready = true _network_snapshot_ready = true
@ -1437,6 +1570,12 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
float(network_velocity[2]) float(network_velocity[2])
) )
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0)) var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
var animation_state_value: Variant = snapshot.get("animation_state")
if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value):
return {}
var animation_state: Dictionary = (
animation_state_value as Dictionary
).duplicate(true)
if ( if (
snapshot.has("acknowledged_input") snapshot.has("acknowledged_input")
and ( and (
@ -1464,12 +1603,119 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
"position": parsed_position, "position": parsed_position,
"velocity": parsed_velocity, "velocity": parsed_velocity,
"visual_yaw": visual_yaw, "visual_yaw": visual_yaw,
"animation_state": animation_state,
"acknowledged_input": acknowledged_input, "acknowledged_input": acknowledged_input,
"sitting": sitting, "sitting": sitting,
"casting": casting, "casting": casting,
} }
func _make_animation_action_state() -> Dictionary:
return NetworkPlayerAnimationProtocol.make_action_state(
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
)
func _apply_network_target_animation_state(state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return
_network_target_grounded = bool(state["grounded"])
_network_target_locomotion_state = _locomotion_state_from_id(
StringName(str(state["locomotion_id"]))
)
var action: Dictionary = state["action"]
_network_target_animation_action_id = StringName(str(action["id"]))
_network_target_animation_action_sequence = int(action["sequence"])
_network_target_animation_action_elapsed = float(action["elapsed"])
func _get_authoritative_locomotion_state() -> LocomotionState:
if (
_network_authoritative_simulation
and _is_movement_input_enabled()
and not _water_recovery_active
and not _sitting
and _network_axis.length_squared() > 0.0025
):
return (
LocomotionState.RUNNING
if _network_sprint
else LocomotionState.WALKING
)
return _locomotion_state_from_velocity()
func _get_authoritative_locomotion_id() -> StringName:
return _locomotion_id_from_state(_get_authoritative_locomotion_state())
func _get_presented_locomotion_state() -> LocomotionState:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_locomotion_state
return _locomotion_state_from_velocity()
func _get_presented_animation_action() -> Dictionary:
if _network_interpolation_enabled and _network_snapshot_ready:
return NetworkPlayerAnimationProtocol.make_action_state(
_network_target_animation_action_id,
_network_target_animation_action_sequence,
minf(
_network_target_animation_action_elapsed + _network_snapshot_age,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
),
)
return _make_animation_action_state()
func _locomotion_id_from_state(state: LocomotionState) -> StringName:
match state:
LocomotionState.WALKING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING
LocomotionState.RUNNING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING
_:
return NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE
func _locomotion_state_from_id(state_id: StringName) -> LocomotionState:
match state_id:
NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING:
return LocomotionState.WALKING
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING:
return LocomotionState.RUNNING
_:
return LocomotionState.IDLE
static func supports_network_animation_action(action_id: StringName) -> bool:
return action_id in NETWORK_ANIMATION_ACTION_IDS
func _locomotion_state_from_velocity() -> LocomotionState:
var horizontal_speed_squared: float = (
velocity.x * velocity.x + velocity.z * velocity.z
)
if horizontal_speed_squared <= 0.0025:
return LocomotionState.IDLE
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
if horizontal_speed_squared > running_threshold * running_threshold:
return LocomotionState.RUNNING
return LocomotionState.WALKING
func _get_presented_grounded() -> bool:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_grounded
return is_on_floor()
func _apply_network_casting(casting: bool) -> void: func _apply_network_casting(casting: bool) -> void:
if casting: if casting:
if _fishing_visual_phase == FishingVisualPhase.NONE: if _fishing_visual_phase == FishingVisualPhase.NONE:

View file

@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="NETFISHING" config/name="NETFISHING"
config/version="0.6.8-alpha" config/version="0.7.1-alpha"
run/main_scene="res://main/main.tscn" run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/netfishing_256.png" config/icon="res://art/exported/system_icons/netfishing_256.png"

View file

@ -4,14 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.8-alpha" readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.7.1-alpha"
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.8-alpha-windows-x86_64.zip" readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.8-alpha-linux-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then

View file

@ -17,6 +17,7 @@ readonly -a QUICK_TESTS=(
"tests/fishing_surface_validation.gd" "tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd" "tests/fur_pattern_validation.gd"
"tests/logbook_validation.gd" "tests/logbook_validation.gd"
"tests/network_player_animation_protocol_validation.gd"
"tests/player_experience_validation.gd" "tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd" "tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd" "tests/surface_drawing_validation.gd"
@ -49,6 +50,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd" "tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"

View file

@ -181,7 +181,7 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true) var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0 invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert( assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY NetworkProtocol.FISH_QUALITY_CAPABILITY

View file

@ -25,7 +25,7 @@ func _run() -> void:
_validate_mail_round_trip() _validate_mail_round_trip()
_validate_collection_mastery() _validate_collection_mastery()
_validate_version_four_migration() _validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS") print("Fish quality validation: PASS")

View file

@ -31,6 +31,9 @@ func _run() -> void:
"%NetworkFishingService" "%NetworkFishingService"
) as NetworkFishingService ) as NetworkFishingService
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
var game_ui := main.get_node("UIPresentation/UIViewport/GameUI")
var fishing_status := game_ui.get_node("%StatusLabel") as Label
var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer
var player := main.get("_player") as Player var player := main.get("_player") as Player
assert(session.is_host()) assert(session.is_host())
assert(not session.is_open_host()) assert(not session.is_open_host())
@ -71,6 +74,9 @@ func _run() -> void:
await process_frame await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE)
assert(service.has_local_attempt()) assert(service.has_local_attempt())
assert(fishing_status.text.is_empty())
assert(not fishing_status.visible)
assert(not fishing_panel.visible)
var attempts: Dictionary = service.get("_attempts") var attempts: Dictionary = service.get("_attempts")
var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id())
assert(attempt != null) assert(attempt != null)
@ -164,9 +170,12 @@ func _run() -> void:
<= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality] <= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality]
) )
reference_controller.queue_free() reference_controller.queue_free()
service.call("_cancel_attempt", session.get_local_peer_id(), "") service.call("_on_attempt_escaped", session.get_local_peer_id())
await process_frame await process_frame
assert(not service.has_local_attempt()) assert(not service.has_local_attempt())
assert(fishing_status.text.is_empty())
assert(not fishing_status.visible)
assert(not fishing_panel.visible)
print("Fishing authority validation: PASS") print("Fishing authority validation: PASS")
session.disconnect_session("") session.disconnect_session("")

View file

@ -147,12 +147,6 @@ func _run_host() -> void:
await process_frame await process_frame
assert(remote_animation_player.current_animation == &"retract_sit") assert(remote_animation_player.current_animation == &"retract_sit")
var completion_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < completion_deadline
and session.get_authenticated_peer_ids().size() == 2
):
await process_frame
print("Fishing multiplayer host validation: PASS") print("Fishing multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
@ -337,7 +331,14 @@ func _run_client() -> void:
while Time.get_ticks_msec() < host_observation_deadline: while Time.get_ticks_msec() < host_observation_deadline:
await process_frame await process_frame
print("Fishing multiplayer client validation: PASS") print("Fishing multiplayer client validation: PASS")
session.disconnect_session("") var host_completion_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < host_completion_deadline
and session.is_joined_client()
):
await process_frame
if session.is_joined_client():
session.disconnect_session("")
main.queue_free() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
await process_frame await process_frame

View file

@ -23,13 +23,12 @@ func _run() -> void:
func _run_host() -> void: func _run_host() -> void:
var main: Node = await _create_initialized_main() var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
var jobs := main.get_node("%PlayerJobService") as PlayerJobService var jobs := main.get_node("%PlayerJobService") as PlayerJobService
assert(session.start_private_host(TEST_PORT)) assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
assert(save_manager.initialize_new_game()) jobs.begin_progression_session()
main.call("_enter_gameplay")
await process_frame await process_frame
assert(session.set_host_open(true)) assert(session.set_host_open(true))
assert(session.is_dedicated_host())
var host_board: Dictionary = jobs.get_host_board_network_data() var host_board: Dictionary = jobs.get_host_board_network_data()
assert(PlayerJobService.validate_board(host_board)) assert(PlayerJobService.validate_board(host_board))
@ -87,6 +86,20 @@ func _run_client() -> void:
assert(not jobs.get_plan_id().is_empty()) assert(not jobs.get_plan_id().is_empty())
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT) assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
var preserved_plan_id: String = jobs.get_plan_id()
jobs.clear_remote_board()
assert(not jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
var retry_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < retry_deadline
and not jobs.has_active_board()
):
await process_frame
assert(jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
assert(weather.get_daily_plan_id().is_empty()) assert(weather.get_daily_plan_id().is_empty())
var game_ui := main.get_node("%GameUI") as GameUI var game_ui := main.get_node("%GameUI") as GameUI
var player_menu := game_ui.get("_player_menu") as PlayerMenu var player_menu := game_ui.get("_player_menu") as PlayerMenu

View file

@ -85,6 +85,7 @@ func _run() -> void:
(player_menu.get_node("%NavigationCluster") as Control).get_child_count() (player_menu.get_node("%NavigationCluster") as Control).get_child_count()
== 7 == 7
) )
await _validate_unavailable_fishnet_layout()
var wallet_before: int = player.wallet.get_balance() var wallet_before: int = player.wallet.get_balance()
var experience_before: int = player.experience.get_total_experience() var experience_before: int = player.experience.get_total_experience()
@ -162,6 +163,24 @@ func _run() -> void:
var pause_menu := game_ui.get_pause_menu() var pause_menu := game_ui.get_pause_menu()
var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage
assert(join_page != null) assert(join_page != null)
pause_menu.show()
join_page.set_status("Connection timed out.")
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
var join_status := join_page.get_node("%Status") as Label
assert(join_status.text == "Could not reach the server.")
join_page.hide()
join_page.call("_set_mode", JoinGamePage.Mode.DIRECT)
join_page.call("_set_mode", JoinGamePage.Mode.DISCOVER)
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
assert(join_status.text == "2 public rooms found")
join_page.close_page()
pause_menu.hide()
join_page.call("_refresh") join_page.call("_refresh")
var session_summary := ( var session_summary := (
join_page.get_node("%SessionSummary") as Label join_page.get_node("%SessionSummary") as Label
@ -187,14 +206,63 @@ func _run() -> void:
assert(int(save_data.get("save_version", -1)) == 7) assert(int(save_data.get("save_version", -1)) == 7)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
session.disconnect_session("Job system validation complete.") _validate_pause_session_switch(main, session)
main.queue_free() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
await process_frame await process_frame
await create_timer(0.1).timeout
print("Job system validation: PASS") print("Job system validation: PASS")
quit() quit()
func _validate_pause_session_switch(
main: Node,
session: NetworkSession,
) -> void:
var discovery := main.get_node("%DiscoveryClient") as DiscoveryClient
discovery.set("_pending_join_endpoint", "127.0.0.1:18141")
discovery.set("_pending_join_room_id", "session-switch-room")
discovery.set("_pending_join_token", "session-switch-token")
discovery.call(
"_set_public_join_state",
DiscoveryClient.PublicJoinState.CONNECTING,
)
main.call("_on_pause_join_game_requested", "127.0.0.1:18141")
assert(session.state == NetworkSession.State.CONNECTING)
assert(discovery.get("_pending_join_token") == "session-switch-token")
var join_probe_timer := discovery.get("_join_probe_timer") as Timer
assert(join_probe_timer != null and not join_probe_timer.is_stopped())
session.cancel_connection()
assert(session.state == NetworkSession.State.INACTIVE)
assert(str(discovery.get("_pending_join_token")).is_empty())
func _validate_unavailable_fishnet_layout() -> void:
var unavailable_page := TheNetPage.new()
unavailable_page.size = Vector2(1280.0, 720.0)
root.add_child(unavailable_page)
for _frame: int in 3:
await process_frame
unavailable_page.call("_refresh_forecast", true)
await process_frame
var forecast_list := unavailable_page.get("_forecast_list") as HBoxContainer
assert(forecast_list != null)
assert(forecast_list.size.x >= 272.0)
assert(forecast_list.size.y <= 66.0)
assert(forecast_list.get_child_count() == 1)
var placeholder := forecast_list.get_child(0) as Label
assert(placeholder != null)
assert(placeholder.size.x >= 252.0)
assert(placeholder.size.y <= 66.0)
assert(placeholder.autowrap_mode == TextServer.AUTOWRAP_OFF)
var tabs := unavailable_page.get("_tabs") as HBoxContainer
assert(tabs != null)
assert(tabs.position.y + tabs.size.y < UtilityPageStyle.LAPTOP_RECT.end.y)
unavailable_page.queue_free()
for _frame: int in 2:
await process_frame
func _validate_weather_guarantees(board: Dictionary) -> void: func _validate_weather_guarantees(board: Dictionary) -> void:
var jobs: Array = board.get("jobs", []) var jobs: Array = board.get("jobs", [])
var schedule: Array = board.get("weather_schedule", []) var schedule: Array = board.get("weather_schedule", [])

View file

@ -0,0 +1,163 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18141
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Movement multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
var player := main.get("_player") as Player
player.configure_network_remote(true)
player.apply_authoritative_network_input(_movement_input(1, true))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(2, false))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(3, false, false))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Movement multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var host_avatar: Player = spawn_service.get_avatar(1)
assert(host_avatar != null)
var animation_player := host_avatar.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail
var saw_running: bool = false
var saw_walking: bool = false
var saw_animation_advance: bool = false
var saw_dust: bool = false
var previous_animation: StringName = &""
var previous_animation_position: float = -1.0
var observation_deadline: int = Time.get_ticks_msec() + 7000
while Time.get_ticks_msec() < observation_deadline:
await process_frame
var current_animation: StringName = animation_player.current_animation
saw_running = saw_running or current_animation.begins_with("running")
saw_walking = saw_walking or current_animation.begins_with("walking")
var current_position: float = (
animation_player.current_animation_position
)
if (
current_animation == previous_animation
and previous_animation_position >= 0.0
and absf(current_position - previous_animation_position) > 0.001
):
saw_animation_advance = true
previous_animation = current_animation
previous_animation_position = current_position
saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0
if saw_running and saw_walking and saw_animation_advance and saw_dust:
break
assert(saw_running)
assert(saw_walking)
assert(saw_animation_advance)
assert(saw_dust)
print("Movement multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _movement_input(
sequence: int,
sprinting: bool,
moving: bool = true,
) -> Dictionary:
return {
"sequence": sequence,
"axis": [0.0, -1.0] if moving else [0.0, 0.0],
"camera_yaw": 0.0,
"jump": false,
"sprint": sprinting,
"sneak": false,
"slow_walk": false,
"sitting": false,
"casting": false,
"animation_action": (
NetworkPlayerAnimationProtocol.make_action_state()
),
}
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0

View file

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

View file

@ -0,0 +1,49 @@
extends SceneTree
func _initialize() -> void:
var idle := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
assert(NetworkPlayerAnimationProtocol.validate_state(idle))
var emote := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING,
false,
&"wave_hello",
17,
1.25,
)
assert(NetworkPlayerAnimationProtocol.validate_state(emote))
var future_locomotion := NetworkPlayerAnimationProtocol.make_state(
&"swimming_fast",
false,
)
assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion))
var extra_future_field: Dictionary = emote.duplicate(true)
extra_future_field["future_layer"] = {"id": "umbrella"}
assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field))
var invalid_action: Dictionary = emote.duplicate(true)
invalid_action["action"] = {
"id": "../unsafe",
"sequence": 17,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_action))
var invalid_sequence: Dictionary = emote.duplicate(true)
invalid_sequence["action"] = {
"id": "wave_hello",
"sequence": -1,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_sequence))
var invalid_elapsed: Dictionary = emote.duplicate(true)
invalid_elapsed["action"] = {
"id": "wave_hello",
"sequence": 17,
"elapsed": INF,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed))
print("Network player animation protocol validation: PASS")
quit()

View file

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

View file

@ -88,6 +88,11 @@ func _run_host() -> void:
entry.revision, entry.revision,
)) ))
assert(not session.is_peer_operator(remote_peer_id)) assert(not session.is_peer_operator(remote_peer_id))
await create_timer(2.0).timeout
assert(session.kick_authenticated_peer(
remote_peer_id,
remote_record.identity_fingerprint,
))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000 var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while ( while (
@ -173,9 +178,25 @@ func _run_client() -> void:
assert(not (tabs.get_child(2) as Button).visible) assert(not (tabs.get_child(2) as Button).visible)
assert(int(players_page.get("_current_tab")) == 0) assert(int(players_page.get("_current_tab")) == 0)
service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT) service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
await create_timer(1.0).timeout var disconnect_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.state != NetworkSession.State.SERVER_LOST
):
await process_frame
assert(session.state == NetworkSession.State.SERVER_LOST)
assert(not bool(main.get("_gameplay_started")))
var game_ui := main.get_node("%GameUI") as GameUI
var title_screen := game_ui.get_title_screen()
assert(title_screen.visible)
assert(not title_screen.is_awaiting_start_input())
assert((title_screen.get_node("%Center") as Control).visible)
assert((title_screen.get_node("%ButtonCenter") as Control).visible)
assert(not (title_screen.get_node("%JoinGamePage") as Control).visible)
var feedback := title_screen.get_node("%FeedbackLabel") as RichTextLabel
assert(feedback.visible)
assert("removed by the host" in feedback.text.to_lower())
print("Operator multiplayer client validation: PASS") print("Operator multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
await process_frame await process_frame

View file

@ -3,7 +3,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn") const MainScene = preload("res://main/main.tscn")
const TEST_PORT: int = 17983 const TEST_PORT: int = 17983
const INITIAL_HOST_TIME: float = 19.75 const INITIAL_HOST_TIME: float = 19.75
const UPDATED_HOST_TIME: float = 20.75 const UPDATED_HOST_TIME: float = WorldTimeService.DUSK_END_HOUR
const TIME_TOLERANCE_HOURS: float = 0.05 const TIME_TOLERANCE_HOURS: float = 0.05
@ -61,14 +61,31 @@ func _run_host() -> void:
assert(session.peer_supports_capability( assert(session.peer_supports_capability(
remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
)) ))
var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
remote_peer_id
)
assert(remote_record != null and remote_record.identity_authenticated)
assert(session.set_peer_operator(
remote_peer_id,
remote_record.identity_fingerprint,
true,
))
assert(world_time.get_phase() == WorldTimeService.Phase.DUSK) assert(world_time.get_phase() == WorldTimeService.Phase.DUSK)
await create_timer(1.0).timeout var command_deadline: int = Time.get_ticks_msec() + 10000
world_time.synchronize_time(UPDATED_HOST_TIME) while (
Time.get_ticks_msec() < command_deadline
and _wrapped_time_difference(
world_time.get_time_hours(), UPDATED_HOST_TIME
) > TIME_TOLERANCE_HOURS
):
await process_frame
assert(is_equal_approx( assert(is_equal_approx(
world_time.get_persistent_time_hours(), UPDATED_HOST_TIME world_time.get_persistent_time_hours(), UPDATED_HOST_TIME
)) ))
assert(chat_ui.call("_handle_chat_command", "/weather foggy")) var fog_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy():
await process_frame
assert( assert(
world_weather.get_persistent_weather() world_weather.get_persistent_weather()
== WorldWeatherService.Weather.FOGGY == WorldWeatherService.Weather.FOGGY
@ -153,6 +170,11 @@ func _run_client() -> void:
assert(is_equal_approx(clock_panel.position.y, 10.0)) assert(is_equal_approx(clock_panel.position.y, 10.0))
assert(is_equal_approx(weather_icon.position.y, 10.0)) assert(is_equal_approx(weather_icon.position.y, 10.0))
assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y) assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y)
var operator_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
await process_frame
assert(session.is_local_operator())
assert(chat_ui.call("_handle_chat_command", "/time night"))
var update_deadline: int = Time.get_ticks_msec() + 10000 var update_deadline: int = Time.get_ticks_msec() + 10000
while ( while (
@ -168,6 +190,8 @@ func _run_client() -> void:
assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT)
assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25)) assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25))
assert(clock_label.text == world_time.get_clock_text()) assert(clock_label.text == world_time.get_clock_text())
await create_timer(0.6).timeout
assert(chat_ui.call("_handle_chat_command", "/weather foggy"))
var fog_deadline: int = Time.get_ticks_msec() + 8000 var fog_deadline: int = Time.get_ticks_msec() + 8000
while ( while (
Time.get_ticks_msec() < fog_deadline Time.get_ticks_msec() < fog_deadline

View file

@ -456,8 +456,14 @@ func _validate_weather_presentation() -> void:
assert(rain.emitting) assert(rain.emitting)
assert(rain.amount_ratio > 0.99) assert(rain.amount_ratio > 0.99)
assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT) assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT)
assert(rain.visibility_aabb == (
WorldTimeVisualControllerType.RAIN_VISIBILITY_AABB
))
var rain_material := rain.process_material as ParticleProcessMaterial var rain_material := rain.process_material as ParticleProcessMaterial
assert(rain_material != null) assert(rain_material != null)
assert(rain_material.emission_box_extents.is_equal_approx(
WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS
))
assert(is_equal_approx( assert(is_equal_approx(
rain_material.initial_velocity_min, rain_material.initial_velocity_min,
WorldTimeVisualControllerType.RAIN_VELOCITY_MIN, WorldTimeVisualControllerType.RAIN_VELOCITY_MIN,

View file

@ -181,6 +181,7 @@ func setup(
_service.local_message_confirmed.connect(_on_local_message_confirmed) _service.local_message_confirmed.connect(_on_local_message_confirmed)
_service.history_replaced.connect(_on_history) _service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected) _service.send_rejected.connect(_on_rejected)
_service.world_command_finished.connect(_on_world_command_finished)
_session.peer_removed.connect(_on_peer_removed) _session.peer_removed.connect(_on_peer_removed)
_entry.text = _settings.current_settings.chat_draft _entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length() _entry.caret_column = _entry.text.length()
@ -789,34 +790,16 @@ func _handle_time_command(parts: PackedStringArray) -> void:
if parts.size() != 2: if parts.size() != 2:
_set_status("Usage: /time [dawn, day, dusk, night]") _set_status("Usage: /time [dawn, day, dusk, night]")
return return
if _session == null or not _session.is_host(): if _service == null or _world_time == null:
_set_status("Only the host can change world time.")
return
if _world_time == null:
_set_status("World time is unavailable.") _set_status("World time is unavailable.")
return return
var phase_name := String(parts[1]).to_lower() var phase_name := String(parts[1]).to_lower()
var target_hour: float if phase_name not in ["dawn", "day", "dusk", "night"]:
match phase_name: _set_status("Usage: /time [dawn, day, dusk, night]")
"dawn": return
target_hour = WorldTimeServiceType.DAWN_START_HOUR if not _service.request_world_time_change(phase_name):
"day": _set_status("Only the host or an operator can change world time.")
target_hour = WorldTimeServiceType.DAWN_END_HOUR
"dusk":
target_hour = WorldTimeServiceType.DUSK_START_HOUR
"night":
target_hour = WorldTimeServiceType.DUSK_END_HOUR
_:
_set_status("Usage: /time [dawn, day, dusk, night]")
return
if not _world_time.set_authoritative_time(target_hour):
_set_status("World time could not be changed.")
return return
_set_status("")
_service.broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
close_chat() close_chat()
@ -824,37 +807,31 @@ func _handle_weather_command(parts: PackedStringArray) -> void:
if parts.size() != 2: if parts.size() != 2:
_set_status("Usage: /weather [clear, cloudy, rainy, foggy]") _set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
return return
if _session == null or not _session.is_host(): if _service == null or _world_weather == null:
_set_status("Only the host can change world weather.")
return
if _world_weather == null:
_set_status("World weather is unavailable.") _set_status("World weather is unavailable.")
return return
var weather_name := String(parts[1]).to_lower() var weather_name := String(parts[1]).to_lower()
var target_weather: WorldWeatherServiceType.Weather
match weather_name: match weather_name:
"clear", "sunny": "clear", "sunny":
target_weather = WorldWeatherServiceType.Weather.SUNNY
weather_name = "clear" weather_name = "clear"
"cloudy": "cloudy", "rainy", "foggy":
target_weather = WorldWeatherServiceType.Weather.CLOUDY pass
"rainy":
target_weather = WorldWeatherServiceType.Weather.RAINY
"foggy":
target_weather = WorldWeatherServiceType.Weather.FOGGY
_: _:
_set_status("Usage: /weather [clear, cloudy, rainy, foggy]") _set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
return return
if not _world_weather.set_authoritative_weather(target_weather): if not _service.request_world_weather_change(weather_name):
_set_status("World weather could not be changed.") _set_status("Only the host or an operator can change world weather.")
return return
_set_status("")
_service.broadcast_system_message(
"World weather set to %s." % weather_name
)
close_chat() close_chat()
func _on_world_command_finished(success: bool, message: String) -> void:
if success:
_set_status("")
elif not message.is_empty():
_set_status(message)
func _on_local_message_confirmed(message: Dictionary) -> void: func _on_local_message_confirmed(message: Dictionary) -> void:
if ( if (
not _send_pending not _send_pending

View file

@ -62,6 +62,7 @@ var _discovery_refresh_timer: Timer
var _editing_entry_id: String = "" var _editing_entry_id: String = ""
var _name_entry_active: bool = false var _name_entry_active: bool = false
var _delete_armed: bool = false var _delete_armed: bool = false
var _connection_error_latched: bool = false
func _ready() -> void: func _ready() -> void:
@ -181,7 +182,7 @@ func open_page(preserved_endpoint: String = "") -> void:
_address.text = "127.0.0.1:7777" _address.text = "127.0.0.1:7777"
show() show()
UtilityPageStyle.animate_in(self) UtilityPageStyle.animate_in(self)
_set_mode(_mode) _set_mode(_mode, false)
if _mode == Mode.DIRECT: if _mode == Mode.DIRECT:
_address.grab_focus() _address.grab_focus()
_address.select_all() _address.select_all()
@ -201,10 +202,13 @@ func get_endpoint_text() -> String:
func set_status(message: String) -> void: func set_status(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true) _set_status(_friendly_connection_message(message), true)
func _set_mode(mode: Mode) -> void: func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
if clear_connection_error:
_connection_error_latched = false
_mode = mode _mode = mode
_selected_entry = null _selected_entry = null
_selected_discovery_index = -1 _selected_discovery_index = -1
@ -225,6 +229,7 @@ func _set_mode(mode: Mode) -> void:
func _request_join() -> void: func _request_join() -> void:
_connection_error_latched = false
if _network_session.state in [ if _network_session.state in [
NetworkSession.State.CONNECTION_FAILED, NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST, NetworkSession.State.SERVER_LOST,
@ -267,6 +272,10 @@ func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_status_changed(message: String, is_error: bool) -> void: func _on_public_join_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree(): if _mode == Mode.DISCOVER and is_visible_in_tree():
if is_error:
_connection_error_latched = true
elif _connection_error_latched:
return
_set_status(message, is_error) _set_status(message, is_error)
_refresh() _refresh()
@ -708,7 +717,11 @@ func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
func _on_discovery_status_changed(message: String, is_error: bool) -> void: func _on_discovery_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree(): if (
_mode == Mode.DISCOVER
and is_visible_in_tree()
and not _connection_error_latched
):
_set_status(message, is_error) _set_status(message, is_error)
@ -740,6 +753,7 @@ func _friendly_connection_message(message: String) -> String:
return "Connection cancelled." return "Connection cancelled."
if ( if (
"timeout" in normalized "timeout" in normalized
or "timed out" in normalized
or "unavailable" in normalized or "unavailable" in normalized
or "refused" in normalized or "refused" in normalized
or "reach" in normalized or "reach" in normalized
@ -769,6 +783,7 @@ func _clear_edit_state() -> void:
func _on_cancel_pressed() -> void: func _on_cancel_pressed() -> void:
_connection_error_latched = false
if _network_session != null: if _network_session != null:
_network_session.cancel_connection() _network_session.cancel_connection()
_refresh() _refresh()
@ -795,11 +810,14 @@ func _on_state_changed(_state: NetworkSession.State) -> void:
func _on_status_message_changed(message: String) -> void: func _on_status_message_changed(message: String) -> void:
if _connection_error_latched:
return
_set_status(_friendly_connection_message(message)) _set_status(_friendly_connection_message(message))
_refresh() _refresh()
func _on_connection_error(message: String) -> void: func _on_connection_error(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true) _set_status(_friendly_connection_message(message), true)
_refresh() _refresh()

View file

@ -158,6 +158,8 @@ func _build_laptop() -> void:
) )
forecast_stack.add_child(forecast_heading) forecast_stack.add_child(forecast_heading)
_forecast_list = HBoxContainer.new() _forecast_list = HBoxContainer.new()
_forecast_list.custom_minimum_size = Vector2(272.0, 66.0)
_forecast_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER _forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER
_forecast_list.add_theme_constant_override("separation", 6) _forecast_list.add_theme_constant_override("separation", 6)
forecast_stack.add_child(_forecast_list) forecast_stack.add_child(_forecast_list)
@ -501,7 +503,10 @@ func _daily_refresh_text() -> String:
func _add_forecast_empty(message: String) -> void: func _add_forecast_empty(message: String) -> void:
var label := Label.new() var label := Label.new()
label.text = message label.text = message
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.custom_minimum_size = Vector2(252.0, 66.0)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.autowrap_mode = TextServer.AUTOWRAP_OFF
label.add_theme_color_override( label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
) )

View file

@ -276,6 +276,19 @@ func reopen() -> void:
_start_entry_prompt_animation() _start_entry_prompt_animation()
func reopen_to_menu() -> void:
reopen()
_awaiting_start_input = false
_stop_entry_prompt_animation()
_start_prompt_center.hide()
_presentation_center.show()
_button_center.show()
var bubble_field := get_node_or_null("%BubbleField") as Control
if bubble_field != null:
bubble_field.show()
_set_title_bubbles_interactive(true)
func open_join_game_page(endpoint: String = "") -> void: func open_join_game_page(endpoint: String = "") -> void:
_cancel_title_entry_transition() _cancel_title_entry_transition()
_awaiting_start_input = false _awaiting_start_input = false

View file

@ -221,7 +221,7 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "v0.6.8-alpha" text = "v0.7.1-alpha"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]

View file

@ -5,7 +5,12 @@ const UPDATE_INTERVAL_SECONDS: float = 0.1
const SUN_YAW_DEGREES: float = -32.0 const SUN_YAW_DEGREES: float = -32.0
const WEATHER_TRANSITION_SECONDS: float = 10.0 const WEATHER_TRANSITION_SECONDS: float = 10.0
const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0) const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0)
const RAIN_PARTICLE_AMOUNT: int = 560 const RAIN_EMISSION_EXTENTS := Vector3(13.0, 1.0, 13.0)
const RAIN_VISIBILITY_AABB := AABB(
Vector3(-13.5, -9.0, -13.5),
Vector3(27.0, 12.0, 27.0),
)
const RAIN_PARTICLE_AMOUNT: int = 2240
const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MIN: float = 16.0
const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_VELOCITY_MAX: float = 20.0
const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014)
@ -187,14 +192,12 @@ func _prepare_rain() -> void:
_rain.lifetime = 1.25 _rain.lifetime = 1.25
_rain.fixed_fps = 30 _rain.fixed_fps = 30
_rain.local_coords = false _rain.local_coords = false
_rain.visibility_aabb = AABB( _rain.visibility_aabb = RAIN_VISIBILITY_AABB
Vector3(-7.0, -9.0, -7.0), Vector3(14.0, 12.0, 14.0)
)
var process_material := ParticleProcessMaterial.new() var process_material := ParticleProcessMaterial.new()
process_material.emission_shape = ( process_material.emission_shape = (
ParticleProcessMaterial.EMISSION_SHAPE_BOX ParticleProcessMaterial.EMISSION_SHAPE_BOX
) )
process_material.emission_box_extents = Vector3(6.5, 1.0, 6.5) process_material.emission_box_extents = RAIN_EMISSION_EXTENTS
process_material.direction = Vector3.DOWN process_material.direction = Vector3.DOWN
process_material.spread = 5.0 process_material.spread = 5.0
process_material.initial_velocity_min = RAIN_VELOCITY_MIN process_material.initial_velocity_min = RAIN_VELOCITY_MIN