diff --git a/fish/day_availability.tres b/fish/day_availability.tres new file mode 100644 index 0000000..6cf8205 --- /dev/null +++ b/fish/day_availability.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" load_steps=2 format=3] + +[ext_resource type="Script" path="res://fish/fish_availability.gd" id="1_availability"] + +[resource] +script = ExtResource("1_availability") +allow_day = true +allow_night = false +preferred_bait_tags = Array[StringName]([&"worm"]) +preferred_bait_weight_multiplier = 1.35 diff --git a/fish/fish_availability.gd b/fish/fish_availability.gd index f593d75..f46a973 100644 --- a/fish/fish_availability.gd +++ b/fish/fish_availability.gd @@ -10,22 +10,35 @@ const FishingContextType = preload("res://fishing/fishing_context.gd") @export var allow_night: bool = true @export var require_rain: bool = false @export var forbid_rain: bool = false +@export var require_fog: bool = false +@export var forbid_fog: bool = false @export var required_bait_tags: Array[StringName] = [] @export var preferred_bait_tags: Array[StringName] = [] @export_range(0.01, 10.0, 0.01) var preferred_bait_weight_multiplier: float = 1.25 func is_available(context: FishingContextType) -> bool: - if context == null or (require_rain and forbid_rain): + if ( + context == null + or (require_rain and forbid_rain) + or (require_fog and forbid_fog) + ): return false - if context.is_night and not allow_night: + if context.is_day_night_transition: + if not allow_day and not allow_night: + return false + elif context.is_night and not allow_night: return false - if not context.is_night and not allow_day: + elif not context.is_night and not allow_day: return false if require_rain and not context.is_raining: return false if forbid_rain and context.is_raining: return false + if require_fog and not context.is_foggy: + return false + if forbid_fog and context.is_foggy: + return false if ( not allowed_location_tags.is_empty() and not _has_any_match(allowed_location_tags, context.location_tags) diff --git a/fish/species/bluegill/bluegill.tres b/fish/species/bluegill/bluegill.tres index 0c72a89..9e98a5b 100644 --- a/fish/species/bluegill/bluegill.tres +++ b/fish/species/bluegill/bluegill.tres @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] -[ext_resource type="Resource" path="res://fish/default_availability.tres" id="3_availability"] +[ext_resource type="Resource" path="res://fish/day_availability.tres" id="3_availability"] [ext_resource type="Texture2D" path="res://fish/species/bluegill/fish_bluegill.png" id="4_texture"] [resource] diff --git a/fish/species/catfish_blue/catfish_blue.tres b/fish/species/catfish_blue/catfish_blue.tres index 9867c69..67065dd 100644 --- a/fish/species/catfish_blue/catfish_blue.tres +++ b/fish/species/catfish_blue/catfish_blue.tres @@ -8,6 +8,8 @@ [sub_resource type="Resource" id="BlueCatfishAvailability"] script = ExtResource("3_availability_script") allowed_location_tags = Array[StringName]([&"starter_pond"]) +allow_day = false +allow_night = true preferred_bait_tags = Array[StringName]([&"worm", &"minnow"]) preferred_bait_weight_multiplier = 1.35 diff --git a/fish/species/catfish_channel/catfish_channel.tres b/fish/species/catfish_channel/catfish_channel.tres index 679d947..ae8b0a4 100644 --- a/fish/species/catfish_channel/catfish_channel.tres +++ b/fish/species/catfish_channel/catfish_channel.tres @@ -8,6 +8,8 @@ [sub_resource type="Resource" id="ChannelCatfishAvailability"] script = ExtResource("3_availability_script") allowed_location_tags = Array[StringName]([&"starter_pond"]) +allow_day = false +allow_night = true preferred_bait_tags = Array[StringName]([&"worm"]) preferred_bait_weight_multiplier = 1.35 diff --git a/fish/species/catfish_flathead/catfish_flathead.tres b/fish/species/catfish_flathead/catfish_flathead.tres index c927bc3..d2cb8a7 100644 --- a/fish/species/catfish_flathead/catfish_flathead.tres +++ b/fish/species/catfish_flathead/catfish_flathead.tres @@ -8,6 +8,8 @@ [sub_resource type="Resource" id="FlatheadCatfishAvailability"] script = ExtResource("3_availability_script") allowed_location_tags = Array[StringName]([&"starter_pond"]) +allow_day = false +allow_night = true preferred_bait_tags = Array[StringName]([&"worm", &"minnow"]) preferred_bait_weight_multiplier = 1.35 diff --git a/fish/species/catfish_white/catfish_white.tres b/fish/species/catfish_white/catfish_white.tres index 7655346..2238d1f 100644 --- a/fish/species/catfish_white/catfish_white.tres +++ b/fish/species/catfish_white/catfish_white.tres @@ -8,6 +8,8 @@ [sub_resource type="Resource" id="WhiteCatfishAvailability"] script = ExtResource("3_availability_script") allowed_location_tags = Array[StringName]([&"starter_pond"]) +allow_day = false +allow_night = true preferred_bait_tags = Array[StringName]([&"worm"]) preferred_bait_weight_multiplier = 1.35 diff --git a/fish/species/sunfish/sunfish.tres b/fish/species/sunfish/sunfish.tres index c1d6200..14d2ced 100644 --- a/fish/species/sunfish/sunfish.tres +++ b/fish/species/sunfish/sunfish.tres @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] -[ext_resource type="Resource" path="res://fish/default_availability.tres" id="3_availability"] +[ext_resource type="Resource" path="res://fish/day_availability.tres" id="3_availability"] [ext_resource type="Texture2D" path="res://fish/species/sunfish/fish_sunfish.png" id="4_texture"] [resource] diff --git a/fishing/fishing_context.gd b/fishing/fishing_context.gd index b5b3984..b3f1acd 100644 --- a/fishing/fishing_context.gd +++ b/fishing/fishing_context.gd @@ -6,4 +6,6 @@ var water_type: WaterType.Type = WaterType.Type.FRESH_WATER var active_event_tags: Array[StringName] = [] var active_bait_tags: Array[StringName] = [] var is_night: bool = false +var is_day_night_transition: bool = false var is_raining: bool = false +var is_foggy: bool = false diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index bdeec55..0dcaf1f 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -37,6 +37,10 @@ const FishingSurfaceResolverType = preload( "res://fishing/fishing_surface_resolver.gd" ) const ArtShopStockType = preload("res://economy/art_shop_stock.gd") +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) signal status_changed(status: String) signal catch_display_changed( @@ -108,8 +112,6 @@ const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @export var deterministic_selection_seed: int = 24680 @export_category("Context Testing") -@export var context_is_night: bool = false -@export var context_is_raining: bool = false @export var context_event_tags: Array[StringName] = [] @export var context_bait_tags: Array[StringName] = [] @@ -132,6 +134,8 @@ var _network_item_use: NetworkItemUseService var _cooler_capacity: PlayerCoolerCapacityType var _network_session: NetworkSessionType var _network_fishing: NetworkFishingServiceType +var _world_time: WorldTimeServiceType +var _world_weather: WorldWeatherServiceType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -190,6 +194,8 @@ func setup( cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType = null, network_fishing: NetworkFishingServiceType = null, + world_time: WorldTimeServiceType = null, + world_weather: WorldWeatherServiceType = null, ) -> void: _local_player = local_player _local_inventory = local_inventory @@ -207,6 +213,8 @@ func setup( _cooler_capacity = cooler_capacity _network_session = network_session _network_fishing = network_fishing + _world_time = world_time + _world_weather = world_weather if _network_fishing != null: _network_fishing.local_cast_accepted.connect( _on_network_cast_accepted @@ -1296,8 +1304,12 @@ func _build_fishing_context( context.water_type = region.water_type context.active_event_tags = context_event_tags.duplicate() context.active_bait_tags = context_bait_tags.duplicate() - context.is_night = context_is_night - context.is_raining = context_is_raining + if _world_time != null: + context.is_night = _world_time.is_night_period() + context.is_day_night_transition = _world_time.is_transition() + if _world_weather != null: + context.is_raining = _world_weather.is_raining() + context.is_foggy = _world_weather.is_foggy() return context diff --git a/main/main.gd b/main/main.gd index d05639f..a08a53e 100644 --- a/main/main.gd +++ b/main/main.gd @@ -77,6 +77,17 @@ const PlayerAppearanceStoreType = preload( const NetworkProfileServiceType = preload( "res://network/network_profile_service.gd" ) +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldTimeVisualControllerType = preload( + "res://world/world_time_visual_controller.gd" +) +const NetworkWorldTimeServiceType = preload( + "res://network/network_world_time_service.gd" +) +const WorldWeatherServiceType = preload("res://world/world_weather_service.gd") +const NetworkWorldWeatherServiceType = preload( + "res://network/network_world_weather_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 const PLAYER_MENU_PATTERN_SCALE: float = 0.85 @@ -108,6 +119,17 @@ const SHOP_PATTERN_SCALE: float = 1.75 %WorldPixelationPostprocess ) @onready var _network_session: NetworkSessionType = %NetworkSession +@onready var _world_time: WorldTimeServiceType = %WorldTimeService +@onready var _world_time_visuals: WorldTimeVisualControllerType = ( + %WorldTimeVisualController +) +@onready var _network_world_time: NetworkWorldTimeServiceType = ( + %NetworkWorldTimeService +) +@onready var _world_weather: WorldWeatherServiceType = %WorldWeatherService +@onready var _network_world_weather: NetworkWorldWeatherServiceType = ( + %NetworkWorldWeatherService +) @onready var _data_root: PlayerDataRoot = %PlayerDataRoot @onready var _identity_backups: IdentityBackupService = %IdentityBackupService @onready var _network_profile: NetworkProfilePreferencesType = ( @@ -263,6 +285,15 @@ func _initialize_after_data_root() -> void: _server_trust, _host_bans, ) + _world_time_visuals.setup( + _world_time, + _test_world.get_world_environment(), + _test_world.get_sun(), + _world_weather, + _player, + ) + _network_world_time.setup(_network_session, _world_time) + _network_world_weather.setup(_network_session, _world_weather) _identity_backups.setup(_data_root, _player_identity, _host_identity) _network_profile_service.setup( _network_session, @@ -428,7 +459,9 @@ func _initialize_after_data_root() -> void: _network_item_use, _player.cooler_capacity, _network_session, - _network_fishing + _network_fishing, + _world_time, + _world_weather, ) _game_ui.setup( _player, @@ -460,6 +493,8 @@ func _initialize_after_data_root() -> void: _settings_manager, _network_surface_drawing, _player.art_unlocks, + _world_time, + _world_weather, ) _game_ui.setup_data_and_identity( _data_root, diff --git a/main/main.tscn b/main/main.tscn index dd7c804..1e60b8d 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -46,6 +46,11 @@ [ext_resource type="PackedScene" uid="uid://w7n4gjqq1juc" path="res://art/exported/characters/base/netfishing_base_character.glb" id="44_4pcu1"] [ext_resource type="Script" path="res://network/network_fish_showcase_service.gd" id="45_fish_showcase"] [ext_resource type="Script" path="res://network/network_surface_drawing_service.gd" id="46_surface_drawing"] +[ext_resource type="Script" path="res://world/world_time_service.gd" id="47_world_time"] +[ext_resource type="Script" path="res://world/world_time_visual_controller.gd" id="48_world_time_visuals"] +[ext_resource type="Script" path="res://network/network_world_time_service.gd" id="49_network_world_time"] +[ext_resource type="Script" path="res://world/world_weather_service.gd" id="50_world_weather"] +[ext_resource type="Script" path="res://network/network_world_weather_service.gd" id="51_network_world_weather"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -113,6 +118,26 @@ mouse_filter = 2 unique_name_in_owner = true script = ExtResource("39_fonts") +[node name="WorldTimeService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("47_world_time") + +[node name="WorldTimeVisualController" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("48_world_time_visuals") + +[node name="NetworkWorldTimeService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("49_network_world_time") + +[node name="WorldWeatherService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("50_world_weather") + +[node name="NetworkWorldWeatherService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("51_network_world_weather") + [node name="PlayerDataRoot" type="Node" parent="." unique_id=281868360] unique_name_in_owner = true script = ExtResource("37_data_root") diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 1e60145..ee1c277 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -20,6 +20,8 @@ const MAIL_RELIABLE_CHANNEL: int = 9 const ENET_CHANNEL_COUNT: int = 10 const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const ART_SHOP_CAPABILITY: String = "art_shop_v1" +const WORLD_TIME_CAPABILITY: String = "world_time_v1" +const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" enum RejectionCode { NONE, @@ -87,6 +89,8 @@ static func make_client_hello( "client_nonce": client_nonce, "capability_flags": PackedStringArray([ SURFACE_DRAWING_CAPABILITY, + WORLD_TIME_CAPABILITY, + WORLD_WEATHER_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -204,6 +208,8 @@ static func make_server_hello( "equipment_v1", "fish_showcase_v1", SURFACE_DRAWING_CAPABILITY, + WORLD_TIME_CAPABILITY, + WORLD_WEATHER_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 4ccb371..d7ecb6d 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -168,7 +168,11 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: NetworkProtocol.PROTOCOL_VERSION, _player_identity.fingerprint, _player_identity.public_pem, - PackedStringArray([NetworkProtocol.SURFACE_DRAWING_CAPABILITY]), + PackedStringArray([ + NetworkProtocol.SURFACE_DRAWING_CAPABILITY, + NetworkProtocol.WORLD_TIME_CAPABILITY, + NetworkProtocol.WORLD_WEATHER_CAPABILITY, + ]), ) _registry.update_appearance(1, _local_appearance_snapshot) var host_profile_hello := NetworkProtocol.make_client_hello( @@ -364,6 +368,8 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.ART_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", NetworkProtocol.SURFACE_DRAWING_CAPABILITY, + NetworkProtocol.WORLD_TIME_CAPABILITY, + NetworkProtocol.WORLD_WEATHER_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -1074,7 +1080,11 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.PROTOCOL_VERSION, _player_identity.fingerprint, _player_identity.public_pem, - PackedStringArray([NetworkProtocol.SURFACE_DRAWING_CAPABILITY]), + PackedStringArray([ + NetworkProtocol.SURFACE_DRAWING_CAPABILITY, + NetworkProtocol.WORLD_TIME_CAPABILITY, + NetworkProtocol.WORLD_WEATHER_CAPABILITY, + ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) var local_record := _registry.get_peer(local_peer_id) diff --git a/network/network_world_time_service.gd b/network/network_world_time_service.gd new file mode 100644 index 0000000..ad50255 --- /dev/null +++ b/network/network_world_time_service.gd @@ -0,0 +1,135 @@ +class_name NetworkWorldTimeService +extends Node + +const SYNC_INTERVAL_SECONDS: float = 5.0 +const MAX_SESSION_ID_LENGTH: int = 96 + +var _session: NetworkSession +var _world_time: WorldTimeService +var _sync_elapsed: float = 0.0 +var _sequence: int = 0 +var _last_received_sequence: int = -1 +var _active_session_id: String = "" + + +func setup(session: NetworkSession, world_time: WorldTimeService) -> void: + _session = session + _world_time = world_time + _session.state_changed.connect(_on_session_state_changed) + _session.peer_authenticated.connect(_on_peer_authenticated) + set_process(true) + + +func _process(delta: float) -> void: + if _session == null or _world_time == null or not _session.is_host(): + return + _sync_elapsed += delta + if _sync_elapsed < SYNC_INTERVAL_SECONDS: + return + _sync_elapsed = 0.0 + _broadcast_snapshot() + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if _world_time == null or _session == null: + return + if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]: + if _active_session_id != _session.get_session_id(): + _active_session_id = _session.get_session_id() + _sequence = 0 + _last_received_sequence = -1 + _sync_elapsed = 0.0 + _world_time.begin_session() + return + if state == NetworkSession.State.JOINED_CLIENT: + _active_session_id = _session.get_session_id() + _last_received_sequence = -1 + _sync_elapsed = 0.0 + if _session.supports_server_capability( + NetworkProtocol.WORLD_TIME_CAPABILITY + ): + _world_time.begin_session() + else: + _world_time.end_session() + return + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _active_session_id = "" + _sequence = 0 + _last_received_sequence = -1 + _sync_elapsed = 0.0 + _world_time.end_session() + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if ( + _session == null + or not _session.is_host() + or not _session.peer_supports_capability( + peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY + ) + ): + return + _send_snapshot(peer_id) + + +func _broadcast_snapshot() -> void: + if _session == null or not _session.is_host(): + return + for peer_id: int in _session.get_authenticated_peer_ids(): + if peer_id == _session.get_local_peer_id(): + continue + if _session.peer_supports_capability( + peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY + ): + _send_snapshot(peer_id) + + +func _send_snapshot(peer_id: int) -> void: + _sequence += 1 + receive_world_time_snapshot.rpc_id(peer_id, { + "session_id": _session.get_session_id(), + "time_hours": _world_time.get_time_hours(), + "sequence": _sequence, + }) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_world_time_snapshot(data: Dictionary) -> void: + if ( + _session == null + or _world_time == null + or not _session.is_joined_client() + or not _session.supports_server_capability( + NetworkProtocol.WORLD_TIME_CAPABILITY + ) + or not validate_snapshot(data) + or str(data["session_id"]) != _session.get_session_id() + ): + return + var sequence: int = int(data["sequence"]) + if sequence <= _last_received_sequence: + return + _last_received_sequence = sequence + _world_time.synchronize_time(float(data["time_hours"])) + + +static func validate_snapshot(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + typeof(value.get("session_id")) == TYPE_STRING + and not str(value["session_id"]).is_empty() + and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH + and typeof(value.get("time_hours")) in [TYPE_FLOAT, TYPE_INT] + and is_finite(float(value["time_hours"])) + and float(value["time_hours"]) >= 0.0 + and float(value["time_hours"]) < WorldTimeService.HOURS_PER_DAY + and typeof(value.get("sequence")) == TYPE_INT + and int(value["sequence"]) >= 0 + ) diff --git a/network/network_world_time_service.gd.uid b/network/network_world_time_service.gd.uid new file mode 100644 index 0000000..a68e332 --- /dev/null +++ b/network/network_world_time_service.gd.uid @@ -0,0 +1 @@ +uid://blig76aes25j diff --git a/network/network_world_weather_service.gd b/network/network_world_weather_service.gd new file mode 100644 index 0000000..ec0cab7 --- /dev/null +++ b/network/network_world_weather_service.gd @@ -0,0 +1,156 @@ +class_name NetworkWorldWeatherService +extends Node + +const SYNC_INTERVAL_SECONDS: float = 15.0 +const MAX_SESSION_ID_LENGTH: int = 96 +const MAX_REMAINING_SECONDS: float = 1800.0 + +var _session: NetworkSession +var _world_weather: WorldWeatherService +var _sync_elapsed: float = 0.0 +var _sequence: int = 0 +var _last_received_sequence: int = -1 +var _active_session_id: String = "" + + +func setup( + session: NetworkSession, + world_weather: WorldWeatherService, +) -> void: + _session = session + _world_weather = world_weather + _session.state_changed.connect(_on_session_state_changed) + _session.peer_authenticated.connect(_on_peer_authenticated) + _world_weather.weather_changed.connect(_on_weather_changed) + set_process(true) + + +func _process(delta: float) -> void: + if _session == null or _world_weather == null or not _session.is_host(): + return + _sync_elapsed += delta + if _sync_elapsed < SYNC_INTERVAL_SECONDS: + return + _sync_elapsed = 0.0 + _broadcast_snapshot() + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if _world_weather == null or _session == null: + return + if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]: + if _active_session_id != _session.get_session_id(): + _active_session_id = _session.get_session_id() + _sequence = 0 + _last_received_sequence = -1 + _sync_elapsed = 0.0 + _world_weather.begin_authoritative_session( + _active_session_id.hash() + ) + return + if state == NetworkSession.State.JOINED_CLIENT: + _active_session_id = _session.get_session_id() + _last_received_sequence = -1 + _sync_elapsed = 0.0 + if _session.supports_server_capability( + NetworkProtocol.WORLD_WEATHER_CAPABILITY + ): + _world_weather.begin_remote_session() + else: + _world_weather.end_session() + return + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _active_session_id = "" + _sequence = 0 + _last_received_sequence = -1 + _sync_elapsed = 0.0 + _world_weather.end_session() + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if ( + _session == null + or not _session.is_host() + or not _session.peer_supports_capability( + peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY + ) + ): + return + _send_snapshot(peer_id) + + +func _on_weather_changed( + _weather: WorldWeatherService.Weather, + _seconds_remaining: float, +) -> void: + if _session != null and _session.is_host(): + _broadcast_snapshot() + + +func _broadcast_snapshot() -> void: + if _session == null or not _session.is_host(): + return + for peer_id: int in _session.get_authenticated_peer_ids(): + if peer_id == _session.get_local_peer_id(): + continue + if _session.peer_supports_capability( + peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY + ): + _send_snapshot(peer_id) + + +func _send_snapshot(peer_id: int) -> void: + _sequence += 1 + receive_world_weather_snapshot.rpc_id(peer_id, { + "session_id": _session.get_session_id(), + "weather": int(_world_weather.get_weather()), + "seconds_remaining": _world_weather.get_seconds_remaining(), + "sequence": _sequence, + }) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_world_weather_snapshot(data: Dictionary) -> void: + if ( + _session == null + or _world_weather == null + or not _session.is_joined_client() + or not _session.supports_server_capability( + NetworkProtocol.WORLD_WEATHER_CAPABILITY + ) + or not validate_snapshot(data) + or str(data["session_id"]) != _session.get_session_id() + ): + return + var sequence: int = int(data["sequence"]) + if sequence <= _last_received_sequence: + return + _last_received_sequence = sequence + _world_weather.apply_authoritative_snapshot( + int(data["weather"]) as WorldWeatherService.Weather, + float(data["seconds_remaining"]), + ) + + +static func validate_snapshot(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + typeof(value.get("session_id")) == TYPE_STRING + and not str(value["session_id"]).is_empty() + and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH + and typeof(value.get("weather")) == TYPE_INT + and WorldWeatherService.is_valid_weather(int(value["weather"])) + and typeof(value.get("seconds_remaining")) in [TYPE_FLOAT, TYPE_INT] + and is_finite(float(value["seconds_remaining"])) + and float(value["seconds_remaining"]) >= 0.0 + and float(value["seconds_remaining"]) <= MAX_REMAINING_SECONDS + and typeof(value.get("sequence")) == TYPE_INT + and int(value["sequence"]) >= 0 + ) diff --git a/network/network_world_weather_service.gd.uid b/network/network_world_weather_service.gd.uid new file mode 100644 index 0000000..ffb9a1e --- /dev/null +++ b/network/network_world_weather_service.gd.uid @@ -0,0 +1 @@ +uid://llj46fx0oco7 diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index 5972da2..2a74663 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -107,12 +107,17 @@ func _validate_catalog_and_pools() -> void: var pond_context := FishingContextType.new() pond_context.location_tags = [&"starter_pond"] pond_context.water_type = WaterType.Type.FRESH_WATER + var night_pond_context := FishingContextType.new() + night_pond_context.location_tags = [&"starter_pond"] + night_pond_context.water_type = WaterType.Type.FRESH_WATER + night_pond_context.is_night = true var ocean_context := FishingContextType.new() ocean_context.location_tags = [&"coast", &"ocean"] ocean_context.water_type = WaterType.Type.SALT_WATER for fish_id: StringName in CATFISH_IDS: var fish: FishDataType = Catalog.get_fish_by_id(fish_id) - assert(fish.availability.is_available(pond_context)) + assert(not fish.availability.is_available(pond_context)) + assert(fish.availability.is_available(night_pond_context)) assert(not fish.availability.is_available(ocean_context)) var single_species_pool := FishPoolType.new() single_species_pool.candidates = [fish] @@ -122,7 +127,7 @@ func _validate_catalog_and_pools() -> void: selector.begin_roll() assert( selector.select_fish( - single_species_pool, pond_context, collection + single_species_pool, night_pond_context, collection ) == fish ) collection.free() diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index d3bc3c9..b201b9a 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -11,7 +11,11 @@ func _initialize() -> void: func _run() -> void: var main := MainScene.instantiate() root.add_child(main) - for _frame: int in 6: + 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"))) var save_manager := main.get("_save_manager") as PlayerSaveManager @@ -45,7 +49,7 @@ func _run() -> void: assert(not hotbar.visible) var entry_buttons: Dictionary = logbook.get("_entry_buttons") - assert(entry_buttons.size() == 0) + assert(entry_buttons.size() == 6) await _capture_if_requested("-unknown") logbook.call( "_select_category", WaterType.Type.FRESH_WATER diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd new file mode 100644 index 0000000..6f1c455 --- /dev/null +++ b/tests/world_time_multiplayer_validation.gd @@ -0,0 +1,186 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const TEST_PORT: int = 17983 +const INITIAL_HOST_TIME: float = 19.75 +const UPDATED_HOST_TIME: float = 20.75 +const TIME_TOLERANCE_HOURS: float = 0.05 + + +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("World time 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 world_time := main.get_node("%WorldTimeService") as WorldTimeService + var world_weather := ( + main.get_node("%WorldWeatherService") as WorldWeatherService + ) + assert(session.start_private_host(TEST_PORT)) + world_time.synchronize_time(INITIAL_HOST_TIME) + world_weather.apply_authoritative_snapshot( + WorldWeatherService.Weather.RAINY, 300.0 + ) + assert(session.set_host_open(true)) + + var remote_peer_id: int = 0 + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + remote_peer_id = peer_id + break + assert(remote_peer_id > 1) + assert(session.peer_supports_capability( + remote_peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY + )) + assert(session.peer_supports_capability( + remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY + )) + assert(world_time.get_phase() == WorldTimeService.Phase.DUSK) + + await create_timer(1.0).timeout + world_time.synchronize_time(UPDATED_HOST_TIME) + world_weather.apply_authoritative_snapshot( + WorldWeatherService.Weather.FOGGY, 300.0 + ) + var disconnect_deadline: int = Time.get_ticks_msec() + 12000 + 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("World time multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + 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 world_time := main.get_node("%WorldTimeService") as WorldTimeService + var world_weather := ( + main.get_node("%WorldWeatherService") as WorldWeatherService + ) + 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()) + assert(session.supports_server_capability( + NetworkProtocol.WORLD_TIME_CAPABILITY + )) + assert(session.supports_server_capability( + NetworkProtocol.WORLD_WEATHER_CAPABILITY + )) + + var initial_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < initial_deadline + and _wrapped_time_difference( + world_time.get_time_hours(), INITIAL_HOST_TIME + ) > TIME_TOLERANCE_HOURS + ): + await process_frame + assert(_wrapped_time_difference( + world_time.get_time_hours(), INITIAL_HOST_TIME + ) <= TIME_TOLERANCE_HOURS) + assert(world_time.get_phase() == WorldTimeService.Phase.DUSK) + var weather_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < weather_deadline + and not world_weather.is_raining() + ): + await process_frame + assert(world_weather.is_raining()) + var game_ui := main.get_node("%GameUI") as CanvasLayer + var chat_ui := game_ui.get_node("%ChatUI") as Control + var clock_panel := chat_ui.get_node("WorldClockPanel") as PanelContainer + var clock_label := clock_panel.get_node("WorldClockLabel") as Label + var weather_icon := chat_ui.get_node("WorldWeatherIcon") as WeatherIcon + var chat_panel := chat_ui.get_node("ChatPanel") as PanelContainer + assert(clock_panel.visible) + assert(clock_label.text == world_time.get_clock_text()) + assert(clock_label.text.ends_with(" pm")) + assert(weather_icon.visible) + assert(weather_icon.get_weather() == WorldWeatherService.Weather.RAINY) + assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y) + + var update_deadline: int = Time.get_ticks_msec() + 10000 + while ( + Time.get_ticks_msec() < update_deadline + and _wrapped_time_difference( + world_time.get_time_hours(), UPDATED_HOST_TIME + ) > TIME_TOLERANCE_HOURS + ): + await process_frame + assert(_wrapped_time_difference( + world_time.get_time_hours(), UPDATED_HOST_TIME + ) <= TIME_TOLERANCE_HOURS) + assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) + assert(clock_label.text == world_time.get_clock_text()) + var fog_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < fog_deadline + and not world_weather.is_foggy() + ): + await process_frame + assert(world_weather.is_foggy()) + assert(weather_icon.get_weather() == WorldWeatherService.Weather.FOGGY) + chat_ui.call("set_dock_right", true) + await process_frame + assert(clock_panel.position.x > 1000.0) + assert(weather_icon.position.x < clock_panel.position.x) + chat_ui.call("set_dock_right", false) + await process_frame + assert(clock_panel.position.x < 20.0) + assert(weather_icon.position.x > clock_panel.position.x) + print("World time multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main := 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 _wrapped_time_difference(left: float, right: float) -> float: + var direct: float = absf(left - right) + return minf(direct, WorldTimeService.HOURS_PER_DAY - direct) diff --git a/tests/world_time_multiplayer_validation.gd.uid b/tests/world_time_multiplayer_validation.gd.uid new file mode 100644 index 0000000..3e17cd7 --- /dev/null +++ b/tests/world_time_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://6fal0btp6sgr diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd new file mode 100644 index 0000000..1b74f7b --- /dev/null +++ b/tests/world_time_validation.gd @@ -0,0 +1,182 @@ +extends SceneTree + +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldTimeVisualControllerType = preload( + "res://world/world_time_visual_controller.gd" +) +const NetworkWorldTimeServiceType = preload( + "res://network/network_world_time_service.gd" +) +const FishingContextType = preload("res://fishing/fishing_context.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const FishableWaterRegionType = preload( + "res://world/fishable_water_region.gd" +) + +const Catalog: FishPoolType = preload("res://fish/pools/fish_catalog.tres") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_clock_boundaries_and_duration() + _validate_fishing_availability() + _validate_fishing_spot_context() + _validate_network_snapshot_bounds() + _validate_environment_presentation() + print("World time validation: PASS") + quit() + + +func _validate_clock_boundaries_and_duration() -> void: + assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 3600.0) + assert( + WorldTimeServiceType.phase_for_hour(7.49) + == WorldTimeServiceType.Phase.NIGHT + ) + assert( + WorldTimeServiceType.phase_for_hour(7.5) + == WorldTimeServiceType.Phase.DAWN + ) + assert( + WorldTimeServiceType.phase_for_hour(8.49) + == WorldTimeServiceType.Phase.DAWN + ) + assert( + WorldTimeServiceType.phase_for_hour(8.5) + == WorldTimeServiceType.Phase.DAY + ) + assert( + WorldTimeServiceType.phase_for_hour(19.5) + == WorldTimeServiceType.Phase.DUSK + ) + assert( + WorldTimeServiceType.phase_for_hour(20.5) + == WorldTimeServiceType.Phase.NIGHT + ) + assert(WorldTimeServiceType.format_clock_time(0.0) == "12:00 am") + assert(WorldTimeServiceType.format_clock_time(8.0) == "8:00 am") + assert(WorldTimeServiceType.format_clock_time(20.5) == "8:30 pm") + + var clock := WorldTimeServiceType.new() + root.add_child(clock) + clock.begin_session(8.0) + clock.advance_time(1800.0) + assert(is_equal_approx(clock.get_time_hours(), 20.0)) + assert(clock.is_night_period()) + assert(clock.is_transition()) + clock.advance_time(1800.0) + assert(is_equal_approx(clock.get_time_hours(), 8.0)) + assert(not clock.is_night_period()) + assert(clock.is_transition()) + clock.queue_free() + + +func _validate_fishing_availability() -> void: + var day_context := FishingContextType.new() + day_context.location_tags = [&"starter_pond"] + day_context.is_night = false + var night_context := FishingContextType.new() + night_context.location_tags = [&"starter_pond"] + night_context.is_night = true + var transition_context := FishingContextType.new() + transition_context.location_tags = [&"starter_pond"] + transition_context.is_night = true + transition_context.is_day_night_transition = true + + for fish_id: StringName in [&"bluegill", &"sunfish"]: + var day_fish: FishDataType = Catalog.get_fish_by_id(fish_id) + assert(day_fish.availability.is_available(day_context)) + assert(not day_fish.availability.is_available(night_context)) + assert(day_fish.availability.is_available(transition_context)) + for fish_id: StringName in [ + &"catfish_blue", + &"catfish_channel", + &"catfish_flathead", + &"catfish_white", + ]: + var night_fish: FishDataType = Catalog.get_fish_by_id(fish_id) + assert(not night_fish.availability.is_available(day_context)) + assert(night_fish.availability.is_available(night_context)) + assert(night_fish.availability.is_available(transition_context)) + for fish_id: StringName in [&"bass", &"carp"]: + var all_time_fish: FishDataType = Catalog.get_fish_by_id(fish_id) + assert(all_time_fish.availability.is_available(day_context)) + assert(all_time_fish.availability.is_available(night_context)) + assert(all_time_fish.availability.is_available(transition_context)) + + +func _validate_fishing_spot_context() -> void: + var clock := WorldTimeServiceType.new() + clock.begin_session(20.25) + var fishing_spot := FishingSpotType.new() + fishing_spot.set("_world_time", clock) + var region := FishableWaterRegionType.new() + region.location_tags = [&"starter_pond"] + region.water_type = WaterType.Type.FRESH_WATER + var dusk_context: FishingContext = fishing_spot.build_network_context(region) + assert(dusk_context.is_night) + assert(dusk_context.is_day_night_transition) + clock.synchronize_time(14.0) + var day_context: FishingContext = fishing_spot.build_network_context(region) + assert(not day_context.is_night) + assert(not day_context.is_day_night_transition) + region.free() + fishing_spot.free() + clock.free() + + +func _validate_network_snapshot_bounds() -> void: + assert(NetworkWorldTimeServiceType.validate_snapshot({ + "session_id": "session", + "time_hours": 8.25, + "sequence": 1, + })) + assert(not NetworkWorldTimeServiceType.validate_snapshot({ + "session_id": "session", + "time_hours": 24.0, + "sequence": 1, + })) + assert(not NetworkWorldTimeServiceType.validate_snapshot({ + "session_id": "", + "time_hours": 8.0, + "sequence": 1, + })) + + +func _validate_environment_presentation() -> void: + var world_root := Node3D.new() + root.add_child(world_root) + var world_environment := WorldEnvironment.new() + var environment := Environment.new() + var sky := Sky.new() + sky.sky_material = ProceduralSkyMaterial.new() + environment.sky = sky + environment.adjustment_enabled = true + environment.fog_enabled = true + world_environment.environment = environment + world_root.add_child(world_environment) + var sun := DirectionalLight3D.new() + world_root.add_child(sun) + var clock := WorldTimeServiceType.new() + world_root.add_child(clock) + var visuals := WorldTimeVisualControllerType.new() + world_root.add_child(visuals) + visuals.setup(clock, world_environment, sun) + visuals.apply_time_immediately(12.0) + var runtime_environment: Environment = world_environment.environment + var runtime_sky_material := ( + runtime_environment.sky.sky_material as ProceduralSkyMaterial + ) + var day_horizon: Color = runtime_sky_material.sky_horizon_color + var day_ambient_energy: float = runtime_environment.ambient_light_energy + visuals.apply_time_immediately(0.0) + assert(runtime_sky_material.sky_horizon_color != day_horizon) + assert(runtime_environment.ambient_light_energy < day_ambient_energy) + visuals.apply_time_immediately(20.0) + assert(runtime_sky_material.sky_horizon_color.r > day_horizon.r) + world_root.queue_free() diff --git a/tests/world_time_validation.gd.uid b/tests/world_time_validation.gd.uid new file mode 100644 index 0000000..b84e544 --- /dev/null +++ b/tests/world_time_validation.gd.uid @@ -0,0 +1 @@ +uid://buiitafgrgm4b diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd new file mode 100644 index 0000000..40c7754 --- /dev/null +++ b/tests/world_weather_validation.gd @@ -0,0 +1,190 @@ +extends SceneTree + +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) +const NetworkWorldWeatherServiceType = preload( + "res://network/network_world_weather_service.gd" +) +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldTimeVisualControllerType = preload( + "res://world/world_time_visual_controller.gd" +) +const FishingContextType = preload("res://fishing/fishing_context.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const FishAvailabilityType = preload("res://fish/fish_availability.gd") +const FishableWaterRegionType = preload( + "res://world/fishable_water_region.gd" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_weather_scheduler() + _validate_snapshot_bounds() + _validate_fishing_weather_seams() + _validate_fishing_spot_context() + _validate_weather_presentation() + print("World weather validation: PASS") + quit() + + +func _validate_weather_scheduler() -> void: + var weather := WorldWeatherServiceType.new() + root.add_child(weather) + weather.begin_authoritative_session(20260802) + assert(weather.get_weather() == WorldWeatherServiceType.Weather.SUNNY) + assert(_duration_is_valid(weather)) + weather.advance_weather(weather.get_seconds_remaining() + 0.01) + assert(weather.get_weather() == WorldWeatherServiceType.Weather.CLOUDY) + assert(_duration_is_valid(weather)) + weather.advance_weather(weather.get_seconds_remaining() + 0.01) + assert(weather.get_weather() in [ + WorldWeatherServiceType.Weather.SUNNY, + WorldWeatherServiceType.Weather.RAINY, + WorldWeatherServiceType.Weather.FOGGY, + ]) + assert(_duration_is_valid(weather)) + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.RAINY, 120.0 + ) + assert(weather.is_raining()) + assert(not weather.is_foggy()) + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.FOGGY, 120.0 + ) + assert(weather.is_foggy()) + assert(not weather.is_raining()) + weather.queue_free() + + +func _duration_is_valid(weather: WorldWeatherServiceType) -> bool: + var seconds: float = weather.get_seconds_remaining() + match weather.get_weather(): + WorldWeatherServiceType.Weather.SUNNY: + return seconds >= 480.0 and seconds <= 900.0 + WorldWeatherServiceType.Weather.CLOUDY: + return seconds >= 300.0 and seconds <= 720.0 + WorldWeatherServiceType.Weather.RAINY, WorldWeatherServiceType.Weather.FOGGY: + return seconds >= 300.0 and seconds <= 600.0 + return false + + +func _validate_snapshot_bounds() -> void: + assert(NetworkWorldWeatherServiceType.validate_snapshot({ + "session_id": "session", + "weather": int(WorldWeatherServiceType.Weather.FOGGY), + "seconds_remaining": 300.0, + "sequence": 2, + })) + assert(not NetworkWorldWeatherServiceType.validate_snapshot({ + "session_id": "session", + "weather": 99, + "seconds_remaining": 300.0, + "sequence": 2, + })) + assert(not NetworkWorldWeatherServiceType.validate_snapshot({ + "session_id": "session", + "weather": int(WorldWeatherServiceType.Weather.RAINY), + "seconds_remaining": 1801.0, + "sequence": 2, + })) + + +func _validate_fishing_weather_seams() -> void: + var clear_context := FishingContextType.new() + var rain_context := FishingContextType.new() + rain_context.is_raining = true + var fog_context := FishingContextType.new() + fog_context.is_foggy = true + fog_context.is_night = true + var rain_only := FishAvailabilityType.new() + rain_only.require_rain = true + assert(not rain_only.is_available(clear_context)) + assert(rain_only.is_available(rain_context)) + var fog_only := FishAvailabilityType.new() + fog_only.allow_day = false + fog_only.require_fog = true + assert(not fog_only.is_available(clear_context)) + assert(fog_only.is_available(fog_context)) + var no_fog := FishAvailabilityType.new() + no_fog.forbid_fog = true + assert(no_fog.is_available(clear_context)) + assert(not no_fog.is_available(fog_context)) + + +func _validate_fishing_spot_context() -> void: + var weather := WorldWeatherServiceType.new() + weather.begin_remote_session() + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.FOGGY, 200.0 + ) + var fishing_spot := FishingSpotType.new() + fishing_spot.set("_world_weather", weather) + var region := FishableWaterRegionType.new() + region.location_tags = [&"starter_pond"] + var context: FishingContext = fishing_spot.build_network_context(region) + assert(context.is_foggy) + assert(not context.is_raining) + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.RAINY, 200.0 + ) + context = fishing_spot.build_network_context(region) + assert(context.is_raining) + assert(not context.is_foggy) + region.free() + fishing_spot.free() + weather.free() + + +func _validate_weather_presentation() -> void: + var world_root := Node3D.new() + root.add_child(world_root) + var world_environment := WorldEnvironment.new() + var environment := Environment.new() + var sky := Sky.new() + sky.sky_material = ProceduralSkyMaterial.new() + environment.sky = sky + environment.adjustment_enabled = true + environment.fog_enabled = true + world_environment.environment = environment + world_root.add_child(world_environment) + var sun := DirectionalLight3D.new() + world_root.add_child(sun) + var clock := WorldTimeServiceType.new() + world_root.add_child(clock) + clock.begin_session(14.0) + var weather := WorldWeatherServiceType.new() + world_root.add_child(weather) + weather.begin_remote_session() + var rain_target := Node3D.new() + world_root.add_child(rain_target) + var visuals := WorldTimeVisualControllerType.new() + world_root.add_child(visuals) + visuals.setup( + clock, world_environment, sun, weather, rain_target + ) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.FOGGY + ) + var runtime_environment: Environment = world_environment.environment + assert(runtime_environment.fog_depth_begin <= 4.01) + assert(runtime_environment.fog_depth_end <= 42.01) + assert(runtime_environment.fog_sky_affect >= 0.93) + assert(runtime_environment.fog_aerial_perspective >= 0.91) + assert(runtime_environment.adjustment_saturation < 0.70) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.RAINY + ) + var rain := visuals.get_node("LocalRain") as GPUParticles3D + assert(rain != null) + assert(rain.emitting) + assert(rain.amount_ratio > 0.99) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.SUNNY + ) + assert(not rain.emitting) + world_root.queue_free() diff --git a/tests/world_weather_validation.gd.uid b/tests/world_weather_validation.gd.uid new file mode 100644 index 0000000..8694186 --- /dev/null +++ b/tests/world_weather_validation.gd.uid @@ -0,0 +1 @@ +uid://cdbmbag58okjb diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 71001f3..1ccaa00 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -20,6 +20,16 @@ const HANDLE_SIZE := Vector2(34, 42) const HANDLE_GAP: float = 6.0 const COLLAPSED_REVEAL_WIDTH: float = 8.0 const HINT_EDGE_MARGIN: float = 4.0 +const CLOCK_SIZE := Vector2(116.0, 34.0) +const CLOCK_EDGE_MARGIN: float = 10.0 +const CLOCK_PANEL_GAP: float = 8.0 +const WEATHER_ICON_SIZE := Vector2(34.0, 34.0) +const WEATHER_ICON_GAP: float = 6.0 +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) +const WeatherIconType = preload("res://ui/weather_icon.gd") enum PresentationState { COLLAPSED, @@ -44,6 +54,9 @@ var _height_button: Button var _unread_indicator: Label var _hint: Label var _speech_layer: Control +var _clock_panel: PanelContainer +var _clock_label: Label +var _weather_icon: WeatherIconType var _speech: Dictionary[int, Dictionary] = {} var _draft_save_timer: Timer var _opacity_tween: Tween @@ -62,6 +75,8 @@ var _prior_movement: bool = true var _prior_camera: bool = true var _output_scale: float = 1.0 var _dock_right: bool = false +var _world_time: WorldTimeServiceType +var _world_weather: WorldWeatherServiceType func _ready() -> void: @@ -79,6 +94,8 @@ func setup( player: Player, fishing_spot: FishingSpot, settings: PlayerSettingsManager, + world_time: WorldTimeServiceType, + world_weather: WorldWeatherServiceType, ) -> void: _service = service _session = session @@ -86,6 +103,29 @@ func setup( _player = player _fishing_spot = fishing_spot _settings = settings + _world_time = world_time + _world_weather = world_weather + if ( + _world_time != null + and not _world_time.time_changed.is_connected( + _on_world_time_changed + ) + ): + _world_time.time_changed.connect(_on_world_time_changed) + _on_world_time_changed( + _world_time.get_time_hours(), _world_time.get_phase() + ) + if ( + _world_weather != null + and not _world_weather.weather_changed.is_connected( + _on_world_weather_changed + ) + ): + _world_weather.weather_changed.connect(_on_world_weather_changed) + _on_world_weather_changed( + _world_weather.get_weather(), + _world_weather.get_seconds_remaining(), + ) set_dock_right(_settings.current_settings.chat_dock_right) _service.message_received.connect(_on_message) _service.local_message_confirmed.connect(_on_local_message_confirmed) @@ -184,6 +224,31 @@ func _exit_tree() -> void: func _build_ui() -> void: + _clock_panel = PanelContainer.new() + _clock_panel.name = "WorldClockPanel" + _clock_panel.size = CLOCK_SIZE + _clock_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _clock_panel.add_theme_stylebox_override( + "panel", _clock_panel_style() + ) + add_child(_clock_panel) + _clock_label = Label.new() + _clock_label.name = "WorldClockLabel" + _clock_label.text = "8:00 am" + _clock_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _clock_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _clock_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _clock_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont) + _clock_label.add_theme_font_size_override("font_size", 18) + _clock_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + _clock_panel.add_child(_clock_label) + _weather_icon = WeatherIconType.new() + _weather_icon.name = "WorldWeatherIcon" + _weather_icon.size = WEATHER_ICON_SIZE + _weather_icon.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_weather_icon) _panel = PanelContainer.new() _panel.name = "ChatPanel" _panel.mouse_filter = Control.MOUSE_FILTER_STOP @@ -326,6 +391,8 @@ func _build_ui() -> void: _draft_save_timer.timeout.connect(_flush_draft) add_child(_draft_save_timer) _panel.hide() + _clock_panel.hide() + _weather_icon.hide() _collapse_button.hide() _height_button.hide() _unread_indicator.hide() @@ -346,6 +413,16 @@ func _chat_panel_style() -> StyleBoxFlat: return style +func _clock_panel_style() -> StyleBoxFlat: + var style := _borderless_style(Color(0.025, 0.13, 0.19, 0.94)) + style.set_corner_radius_all(12) + style.content_margin_left = 10 + style.content_margin_right = 10 + style.content_margin_top = 4 + style.content_margin_bottom = 4 + return style + + func _borderless_style(color: Color) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color @@ -525,11 +602,15 @@ func _refresh_history() -> void: func _refresh_visibility() -> void: if not _available: _panel.hide() + _clock_panel.hide() + _weather_icon.hide() _collapse_button.hide() _height_button.hide() _unread_indicator.hide() _hint.hide() return + _clock_panel.show() + _weather_icon.show() _collapse_button.show() var collapsed := _presentation_state == PresentationState.COLLAPSED _panel.show() @@ -737,6 +818,25 @@ func _layout_presentation(animate: bool) -> void: panel_x = -(PANEL_WIDTH - COLLAPSED_REVEAL_WIDTH) if collapsed else 0.0 var target_position := Vector2(panel_x, bottom - height) var target_size := Vector2(PANEL_WIDTH, height) + var clock_x: float = ( + viewport_width - CLOCK_SIZE.x - CLOCK_EDGE_MARGIN + if _dock_right else CLOCK_EDGE_MARGIN + ) + var clock_position := Vector2( + clock_x, + maxf( + CLOCK_EDGE_MARGIN, + target_position.y - CLOCK_SIZE.y - CLOCK_PANEL_GAP, + ), + ) + var weather_icon_x: float = ( + clock_position.x - WEATHER_ICON_GAP - WEATHER_ICON_SIZE.x + if _dock_right + else clock_position.x + CLOCK_SIZE.x + WEATHER_ICON_GAP + ) + var weather_icon_position := Vector2( + weather_icon_x, clock_position.y + ) var handle_stack_height := HANDLE_SIZE.y * 2.0 + HANDLE_GAP var handle_stack_top := ( bottom - COMPACT_HEIGHT * 0.5 - handle_stack_height * 0.5 @@ -766,6 +866,10 @@ func _layout_presentation(animate: bool) -> void: if not animate: _panel.position = target_position _panel.size = target_size + _clock_panel.position = clock_position + _clock_panel.size = CLOCK_SIZE + _weather_icon.position = weather_icon_position + _weather_icon.size = WEATHER_ICON_SIZE _collapse_button.position = collapse_position _height_button.position = height_position _unread_indicator.position = unread_position @@ -779,6 +883,18 @@ func _layout_presentation(animate: bool) -> void: _height_tween.tween_property( _panel, "size", target_size, UIMotion.CHAT_RESIZE_DURATION ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + _height_tween.tween_property( + _clock_panel, + "position", + clock_position, + UIMotion.CHAT_RESIZE_DURATION, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + _height_tween.tween_property( + _weather_icon, + "position", + weather_icon_position, + UIMotion.CHAT_RESIZE_DURATION, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _collapse_button, "position", @@ -836,6 +952,22 @@ func _on_viewport_resized() -> void: _layout_presentation(false) +func _on_world_time_changed( + _time_hours: float, + _phase: WorldTimeService.Phase, +) -> void: + if _clock_label != null and _world_time != null: + _clock_label.text = _world_time.get_clock_text() + + +func _on_world_weather_changed( + weather: WorldWeatherService.Weather, + _seconds_remaining: float, +) -> void: + if _weather_icon != null: + _weather_icon.set_weather(weather) + + func _on_exterior_handle_entered() -> void: _panel_hovered = true _update_panel_opacity(true) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index d1796d6..f7e2d09 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -39,6 +39,10 @@ const SurfaceDrawingToolbarType = preload( const PlayerSettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested @@ -145,13 +149,15 @@ func setup( settings_manager: PlayerSettingsManagerType, surface_drawing: NetworkSurfaceDrawingService, art_unlocks: PlayerArtUnlocks, + world_time: WorldTimeServiceType, + world_weather: WorldWeatherServiceType, ) -> void: _player = player _fishing_spot = fishing_spot _item_effects = item_effects _chat_ui.setup( network_chat_service, network_session, spawn_service, player, - fishing_spot, settings_manager + fishing_spot, settings_manager, world_time, world_weather ) _title_settings_panel.setup_network_profile( network_profile, network_session diff --git a/ui/weather_icon.gd b/ui/weather_icon.gd new file mode 100644 index 0000000..2b8ed6e --- /dev/null +++ b/ui/weather_icon.gd @@ -0,0 +1,80 @@ +class_name WeatherIcon +extends Control + +const BUBBLE_COLOR := Color(0.025, 0.13, 0.19, 0.94) +const ICON_COLOR := Color(0.78, 0.91, 0.95) +const SUN_COLOR := Color(0.98, 0.82, 0.34) +const RAIN_COLOR := Color(0.28, 0.73, 0.82) + +var _weather: WorldWeatherService.Weather = WorldWeatherService.Weather.SUNNY + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_PASS + focus_mode = Control.FOCUS_NONE + custom_minimum_size = Vector2(34.0, 34.0) + tooltip_text = "sunny" + queue_redraw() + + +func set_weather(weather: WorldWeatherService.Weather) -> void: + if _weather == weather: + return + _weather = weather + tooltip_text = WorldWeatherService.weather_name(_weather) + queue_redraw() + + +func get_weather() -> WorldWeatherService.Weather: + return _weather + + +func _draw() -> void: + var center: Vector2 = size * 0.5 + var radius: float = minf(size.x, size.y) * 0.5 + draw_circle(center, radius, BUBBLE_COLOR) + match _weather: + WorldWeatherService.Weather.SUNNY: + _draw_sun(center) + WorldWeatherService.Weather.CLOUDY: + _draw_cloud(center + Vector2(0.0, 1.0)) + WorldWeatherService.Weather.RAINY: + _draw_cloud(center + Vector2(0.0, -2.0)) + for x_offset: float in PackedFloat32Array([-6.0, 0.0, 6.0]): + draw_line( + center + Vector2(x_offset + 1.0, 5.0), + center + Vector2(x_offset - 1.0, 9.0), + RAIN_COLOR, + 2.0, + true, + ) + WorldWeatherService.Weather.FOGGY: + for y_offset: float in PackedFloat32Array([-6.0, 0.0, 6.0]): + draw_line( + center + Vector2(-9.0, y_offset), + center + Vector2(9.0, y_offset), + ICON_COLOR, + 2.0, + true, + ) + + +func _draw_sun(center: Vector2) -> void: + draw_circle(center, 5.0, SUN_COLOR) + for index: int in 8: + var angle: float = TAU * float(index) / 8.0 + var direction := Vector2(cos(angle), sin(angle)) + draw_line( + center + direction * 8.0, + center + direction * 11.0, + SUN_COLOR, + 2.0, + true, + ) + + +func _draw_cloud(center: Vector2) -> void: + draw_circle(center + Vector2(-5.0, 0.0), 5.0, ICON_COLOR) + draw_circle(center + Vector2(0.0, -3.0), 6.0, ICON_COLOR) + draw_circle(center + Vector2(6.0, 0.0), 5.0, ICON_COLOR) + draw_rect(Rect2(center + Vector2(-9.0, 0.0), Vector2(18.0, 5.0)), ICON_COLOR) diff --git a/ui/weather_icon.gd.uid b/ui/weather_icon.gd.uid new file mode 100644 index 0000000..0be0e28 --- /dev/null +++ b/ui/weather_icon.gd.uid @@ -0,0 +1 @@ +uid://crcognj0s8ntq diff --git a/world/test_world.gd b/world/test_world.gd index d136724..189c119 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -15,6 +15,8 @@ const FishingShopInteractionType = preload( @onready var _fishing_shop: FishingShopInteractionType = ( _starter_island.get_fishing_shop() ) +@onready var _world_environment: WorldEnvironment = $Environment/WorldEnvironment +@onready var _sun: DirectionalLight3D = $Environment/Sun func get_player_water_triggers() -> Array[PlayerWaterTrigger]: @@ -40,6 +42,14 @@ func get_player_spawn_transform() -> Transform3D: return _starter_island.get_player_spawn_transform() +func get_world_environment() -> WorldEnvironment: + return _world_environment + + +func get_sun() -> DirectionalLight3D: + return _sun + + func get_fishable_water_regions() -> Array[FishableWaterRegion]: var waters: Array[FishableWaterRegion] = [] for region: WorldRegion in _get_regions(): diff --git a/world/world_time_service.gd b/world/world_time_service.gd new file mode 100644 index 0000000..aef3054 --- /dev/null +++ b/world/world_time_service.gd @@ -0,0 +1,126 @@ +class_name WorldTimeService +extends Node + +signal time_changed(time_hours: float, phase: Phase) +signal phase_changed(phase: Phase) + +enum Phase { + DAWN, + DAY, + DUSK, + NIGHT, +} + +const HOURS_PER_DAY: float = 24.0 +const REAL_SECONDS_PER_CYCLE: float = 60.0 * 60.0 +const HOURS_PER_REAL_SECOND: float = HOURS_PER_DAY / REAL_SECONDS_PER_CYCLE +const DAY_START_HOUR: float = 8.0 +const NIGHT_START_HOUR: float = 20.0 +const TRANSITION_HALF_HOURS: float = 0.5 +const DAWN_START_HOUR: float = DAY_START_HOUR - TRANSITION_HALF_HOURS +const DAWN_END_HOUR: float = DAY_START_HOUR + TRANSITION_HALF_HOURS +const DUSK_START_HOUR: float = NIGHT_START_HOUR - TRANSITION_HALF_HOURS +const DUSK_END_HOUR: float = NIGHT_START_HOUR + TRANSITION_HALF_HOURS +const DEFAULT_START_HOUR: float = DAY_START_HOUR + +var _time_hours: float = DEFAULT_START_HOUR +var _running: bool = false +var _phase: Phase = Phase.DAWN + + +func _ready() -> void: + set_process(false) + + +func _process(delta: float) -> void: + advance_time(delta) + + +func begin_session(start_hour: float = DEFAULT_START_HOUR) -> void: + _running = true + set_process(true) + _set_time_hours(start_hour, true) + + +func end_session() -> void: + _running = false + set_process(false) + _set_time_hours(DEFAULT_START_HOUR, true) + + +func advance_time(real_seconds: float) -> void: + if not _running or real_seconds <= 0.0: + return + _set_time_hours( + _time_hours + real_seconds * HOURS_PER_REAL_SECOND, + false, + ) + + +func synchronize_time(authoritative_time_hours: float) -> void: + if not is_finite(authoritative_time_hours): + return + _set_time_hours(authoritative_time_hours, true) + + +func get_time_hours() -> float: + return _time_hours + + +func get_phase() -> Phase: + return _phase + + +func is_night_period() -> bool: + return _time_hours < DAY_START_HOUR or _time_hours >= NIGHT_START_HOUR + + +func is_transition() -> bool: + return _phase in [Phase.DAWN, Phase.DUSK] + + +func get_clock_text() -> String: + return format_clock_time(_time_hours) + + +static func phase_for_hour(time_hours: float) -> Phase: + var hour: float = _normalized_hour(time_hours) + if hour >= DAWN_START_HOUR and hour < DAWN_END_HOUR: + return Phase.DAWN + if hour >= DAWN_END_HOUR and hour < DUSK_START_HOUR: + return Phase.DAY + if hour >= DUSK_START_HOUR and hour < DUSK_END_HOUR: + return Phase.DUSK + return Phase.NIGHT + + +static func format_clock_time(time_hours: float) -> String: + var hour: float = _normalized_hour(time_hours) + var total_minutes: int = floori(hour * 60.0) + var hour_24: int = floori(float(total_minutes) / 60.0) + var minute: int = total_minutes % 60 + var hour_12: int = hour_24 % 12 + if hour_12 == 0: + hour_12 = 12 + return "%d:%02d %s" % [ + hour_12, + minute, + "am" if hour_24 < 12 else "pm", + ] + + +func _set_time_hours(time_hours: float, force_emit: bool) -> void: + var normalized: float = _normalized_hour(time_hours) + var next_phase: Phase = phase_for_hour(normalized) + var phase_was_changed: bool = next_phase != _phase + var time_was_changed: bool = not is_equal_approx(normalized, _time_hours) + _time_hours = normalized + _phase = next_phase + if phase_was_changed: + phase_changed.emit(_phase) + if force_emit or time_was_changed or phase_was_changed: + time_changed.emit(_time_hours, _phase) + + +static func _normalized_hour(time_hours: float) -> float: + return fposmod(time_hours, HOURS_PER_DAY) diff --git a/world/world_time_service.gd.uid b/world/world_time_service.gd.uid new file mode 100644 index 0000000..29e3163 --- /dev/null +++ b/world/world_time_service.gd.uid @@ -0,0 +1 @@ +uid://5lceyulaglpf diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd new file mode 100644 index 0000000..6edd306 --- /dev/null +++ b/world/world_time_visual_controller.gd @@ -0,0 +1,389 @@ +class_name WorldTimeVisualController +extends Node + +const UPDATE_INTERVAL_SECONDS: float = 0.1 +const SUN_YAW_DEGREES: float = -32.0 +const WEATHER_TRANSITION_SECONDS: float = 10.0 +const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0) + +const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) +const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) +const DAY_GROUND_BOTTOM := Color(0.157, 0.361, 0.439) +const DAY_GROUND_HORIZON := Color(0.549, 0.749, 0.765) +const DAY_AMBIENT := Color(0.82, 0.90, 0.92) +const DAY_FOG := Color(0.549, 0.749, 0.765) +const DAY_SUN := Color(0.88, 0.94, 0.96) + +const NIGHT_SKY_TOP := Color(0.026, 0.050, 0.105) +const NIGHT_SKY_HORIZON := Color(0.10, 0.17, 0.28) +const NIGHT_GROUND_BOTTOM := Color(0.020, 0.040, 0.080) +const NIGHT_GROUND_HORIZON := Color(0.075, 0.135, 0.22) +const NIGHT_AMBIENT := Color(0.28, 0.35, 0.48) +const NIGHT_FOG := Color(0.13, 0.21, 0.32) +const NIGHT_SUN := Color(0.35, 0.44, 0.62) + +const WARM_SKY_TOP := Color(0.31, 0.30, 0.42) +const WARM_SKY_HORIZON := Color(0.96, 0.48, 0.22) +const WARM_GROUND_BOTTOM := Color(0.20, 0.14, 0.16) +const WARM_GROUND_HORIZON := Color(0.82, 0.34, 0.18) +const WARM_AMBIENT := Color(0.92, 0.58, 0.40) +const WARM_FOG := Color(0.74, 0.39, 0.27) +const WARM_SUN := Color(1.0, 0.58, 0.30) + +const CLOUDY_TINT := Color(0.53, 0.61, 0.66) +const RAINY_TINT := Color(0.31, 0.42, 0.50) +const FOGGY_TINT := Color(0.62, 0.70, 0.72) + +var _time_service: WorldTimeService +var _weather_service: WorldWeatherService +var _world_environment: WorldEnvironment +var _sun: DirectionalLight3D +var _rain_target: Node3D +var _environment: Environment +var _sky_material: ProceduralSkyMaterial +var _rain: GPUParticles3D +var _elapsed: float = 0.0 +var _weather_from: WorldWeatherService.Weather = ( + WorldWeatherService.Weather.SUNNY +) +var _weather_to: WorldWeatherService.Weather = ( + WorldWeatherService.Weather.SUNNY +) +var _weather_transition: float = 1.0 + + +func setup( + time_service: WorldTimeService, + world_environment: WorldEnvironment, + sun: DirectionalLight3D, + weather_service: WorldWeatherService = null, + rain_target: Node3D = null, +) -> void: + _time_service = time_service + _weather_service = weather_service + _world_environment = world_environment + _sun = sun + _rain_target = rain_target + if not _prepare_runtime_environment(): + set_process(false) + return + _prepare_rain() + if _weather_service != null: + _weather_from = _weather_service.get_weather() + _weather_to = _weather_from + _weather_transition = 1.0 + if not _weather_service.weather_changed.is_connected( + _on_weather_changed + ): + _weather_service.weather_changed.connect(_on_weather_changed) + set_process(true) + _apply_time(_time_service.get_time_hours()) + + +func _process(delta: float) -> void: + if _time_service == null: + return + _update_rain_position() + if _weather_transition < 1.0: + _weather_transition = minf( + _weather_transition + delta / WEATHER_TRANSITION_SECONDS, + 1.0, + ) + _elapsed += delta + if _elapsed < UPDATE_INTERVAL_SECONDS: + return + _elapsed = 0.0 + _apply_time(_time_service.get_time_hours()) + + +func apply_time_immediately(time_hours: float) -> void: + _apply_time(time_hours) + + +func apply_weather_immediately( + weather: WorldWeatherService.Weather, +) -> void: + _weather_from = weather + _weather_to = weather + _weather_transition = 1.0 + if _time_service != null: + _apply_time(_time_service.get_time_hours()) + + +func _prepare_runtime_environment() -> bool: + if ( + _time_service == null + or _world_environment == null + or _world_environment.environment == null + or _sun == null + ): + push_error("World time visuals require an environment and sun.") + return false + _environment = _world_environment.environment.duplicate(true) as Environment + if _environment == null or _environment.sky == null: + push_error("World time visuals could not duplicate the world environment.") + return false + var runtime_sky: Sky = _environment.sky.duplicate(true) as Sky + if runtime_sky == null or runtime_sky.sky_material == null: + push_error("World time visuals require a procedural sky material.") + return false + _sky_material = ( + runtime_sky.sky_material.duplicate(true) as ProceduralSkyMaterial + ) + if _sky_material == null: + push_error("World time visuals require a ProceduralSkyMaterial.") + return false + runtime_sky.sky_material = _sky_material + _environment.sky = runtime_sky + _world_environment.environment = _environment + return true + + +func _prepare_rain() -> void: + _rain = GPUParticles3D.new() + _rain.name = "LocalRain" + _rain.amount = 480 + _rain.amount_ratio = 0.0 + _rain.lifetime = 1.25 + _rain.fixed_fps = 30 + _rain.local_coords = false + _rain.visibility_aabb = AABB( + Vector3(-7.0, -9.0, -7.0), Vector3(14.0, 12.0, 14.0) + ) + var process_material := ParticleProcessMaterial.new() + process_material.emission_shape = ( + ParticleProcessMaterial.EMISSION_SHAPE_BOX + ) + process_material.emission_box_extents = Vector3(6.5, 1.0, 6.5) + process_material.direction = Vector3.DOWN + process_material.spread = 5.0 + process_material.initial_velocity_min = 11.0 + process_material.initial_velocity_max = 15.0 + process_material.gravity = Vector3(0.0, -2.0, 0.0) + _rain.process_material = process_material + var drop_mesh := BoxMesh.new() + drop_mesh.size = Vector3(0.018, 0.42, 0.018) + var drop_material := StandardMaterial3D.new() + drop_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + drop_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + drop_material.albedo_color = Color(0.48, 0.78, 0.90, 0.72) + drop_mesh.material = drop_material + _rain.draw_pass_1 = drop_mesh + add_child(_rain) + _update_rain_position() + + +func _apply_time(time_hours: float) -> void: + if _environment == null or _sky_material == null or _sun == null: + return + var hour: float = fposmod(time_hours, WorldTimeService.HOURS_PER_DAY) + var daylight: float = _daylight_amount(hour) + var warmth: float = _transition_warmth(hour) + var sky_top: Color = _blended_color( + NIGHT_SKY_TOP, DAY_SKY_TOP, WARM_SKY_TOP, daylight, warmth + ) + var sky_horizon: Color = _blended_color( + NIGHT_SKY_HORIZON, + DAY_SKY_HORIZON, + WARM_SKY_HORIZON, + daylight, + warmth, + ) + var ground_bottom: Color = _blended_color( + NIGHT_GROUND_BOTTOM, + DAY_GROUND_BOTTOM, + WARM_GROUND_BOTTOM, + daylight, + warmth, + ) + var ground_horizon: Color = _blended_color( + NIGHT_GROUND_HORIZON, + DAY_GROUND_HORIZON, + WARM_GROUND_HORIZON, + daylight, + warmth, + ) + var ambient: Color = _blended_color( + NIGHT_AMBIENT, DAY_AMBIENT, WARM_AMBIENT, daylight, warmth + ) + var fog_color: Color = _blended_color( + NIGHT_FOG, DAY_FOG, WARM_FOG, daylight, warmth + ) + var weather_tint: Color = _weather_color() + var tint_strength: float = _weather_value( + 0.0, 0.30, 0.48, 0.62 + ) + _sky_material.sky_top_color = sky_top.lerp(weather_tint, tint_strength) + _sky_material.sky_horizon_color = sky_horizon.lerp( + weather_tint, tint_strength + ) + _sky_material.ground_bottom_color = ground_bottom.lerp( + weather_tint, tint_strength * 0.62 + ) + _sky_material.ground_horizon_color = ground_horizon.lerp( + weather_tint, tint_strength * 0.76 + ) + _environment.background_energy_multiplier = ( + lerpf(0.52, 0.85, daylight) + * _weather_value(1.0, 0.82, 0.66, 0.74) + ) + _environment.ambient_light_color = ambient.lerp( + weather_tint, tint_strength * 0.45 + ) + _environment.ambient_light_energy = ( + lerpf(0.66, 1.08, daylight) + * _weather_value(1.0, 0.88, 0.76, 0.82) + ) + _environment.fog_light_color = fog_color.lerp( + weather_tint, tint_strength * 0.82 + ) + _environment.fog_light_energy = ( + lerpf(0.56, 0.82, daylight) + * _weather_value(1.0, 0.92, 0.82, 1.05) + ) + _environment.fog_aerial_perspective = _weather_value( + 0.35, 0.48, 0.62, 0.92 + ) + _environment.fog_sky_affect = _weather_value( + 0.35, 0.48, 0.68, 0.94 + ) + _environment.fog_depth_curve = _weather_value( + 1.6, 1.5, 1.4, 1.18 + ) + _environment.fog_depth_begin = _weather_value( + 42.0, 32.0, 20.0, 4.0 + ) + _environment.fog_depth_end = _weather_value( + 170.0, 140.0, 95.0, 42.0 + ) + _environment.adjustment_brightness = ( + lerpf(0.93, 0.98, daylight) + * _weather_value(1.0, 0.97, 0.92, 0.96) + ) + _environment.adjustment_saturation = ( + lerpf(0.82, 0.91, daylight) + * _weather_value(1.0, 0.88, 0.78, 0.70) + ) + _sun.rotation_degrees = Vector3( + -360.0 * fposmod(hour - WorldTimeService.DAY_START_HOUR, 24.0) / 24.0, + SUN_YAW_DEGREES, + 0.0, + ) + _sun.light_color = _blended_color( + NIGHT_SUN, DAY_SUN, WARM_SUN, daylight, warmth + ) + _sun.light_energy = ( + lerpf(0.0, 0.10, daylight) + * _weather_value(1.0, 0.55, 0.25, 0.35) + ) + _update_rain_amount() + + +func _on_weather_changed( + weather: WorldWeatherService.Weather, + _seconds_remaining: float, +) -> void: + if weather == _weather_to: + return + _weather_from = _weather_to + _weather_to = weather + _weather_transition = 0.0 + + +func _weather_value( + sunny: float, + cloudy: float, + rainy: float, + foggy: float, +) -> float: + return lerpf( + _weather_state_value(_weather_from, sunny, cloudy, rainy, foggy), + _weather_state_value(_weather_to, sunny, cloudy, rainy, foggy), + _weather_transition, + ) + + +func _weather_state_value( + weather: WorldWeatherService.Weather, + sunny: float, + cloudy: float, + rainy: float, + foggy: float, +) -> float: + match weather: + WorldWeatherService.Weather.CLOUDY: + return cloudy + WorldWeatherService.Weather.RAINY: + return rainy + WorldWeatherService.Weather.FOGGY: + return foggy + return sunny + + +func _weather_color() -> Color: + return _weather_state_color(_weather_from).lerp( + _weather_state_color(_weather_to), _weather_transition + ) + + +func _weather_state_color( + weather: WorldWeatherService.Weather, +) -> Color: + match weather: + WorldWeatherService.Weather.CLOUDY: + return CLOUDY_TINT + WorldWeatherService.Weather.RAINY: + return RAINY_TINT + WorldWeatherService.Weather.FOGGY: + return FOGGY_TINT + return Color.WHITE + + +func _update_rain_amount() -> void: + if _rain == null: + return + var rain_amount: float = _weather_value(0.0, 0.0, 1.0, 0.0) + _rain.amount_ratio = rain_amount + _rain.emitting = rain_amount > 0.01 + + +func _update_rain_position() -> void: + if _rain == null or _rain_target == null: + return + _rain.global_position = _rain_target.global_position + RAIN_EMITTER_OFFSET + + +func _daylight_amount(hour: float) -> float: + if hour >= WorldTimeService.DAWN_START_HOUR and hour < WorldTimeService.DAWN_END_HOUR: + return smoothstep( + WorldTimeService.DAWN_START_HOUR, + WorldTimeService.DAWN_END_HOUR, + hour, + ) + if hour >= WorldTimeService.DAWN_END_HOUR and hour < WorldTimeService.DUSK_START_HOUR: + return 1.0 + if hour >= WorldTimeService.DUSK_START_HOUR and hour < WorldTimeService.DUSK_END_HOUR: + return 1.0 - smoothstep( + WorldTimeService.DUSK_START_HOUR, + WorldTimeService.DUSK_END_HOUR, + hour, + ) + return 0.0 + + +func _transition_warmth(hour: float) -> float: + if hour >= WorldTimeService.DAWN_START_HOUR and hour < WorldTimeService.DAWN_END_HOUR: + return 1.0 - absf(hour - WorldTimeService.DAY_START_HOUR) / WorldTimeService.TRANSITION_HALF_HOURS + if hour >= WorldTimeService.DUSK_START_HOUR and hour < WorldTimeService.DUSK_END_HOUR: + return 1.0 - absf(hour - WorldTimeService.NIGHT_START_HOUR) / WorldTimeService.TRANSITION_HALF_HOURS + return 0.0 + + +func _blended_color( + night_color: Color, + day_color: Color, + warm_color: Color, + daylight: float, + warmth: float, +) -> Color: + return night_color.lerp(day_color, daylight).lerp(warm_color, warmth) diff --git a/world/world_time_visual_controller.gd.uid b/world/world_time_visual_controller.gd.uid new file mode 100644 index 0000000..52bbfc3 --- /dev/null +++ b/world/world_time_visual_controller.gd.uid @@ -0,0 +1 @@ +uid://svirs8vbypad diff --git a/world/world_weather_service.gd b/world/world_weather_service.gd new file mode 100644 index 0000000..d01728c --- /dev/null +++ b/world/world_weather_service.gd @@ -0,0 +1,152 @@ +class_name WorldWeatherService +extends Node + +signal weather_changed(weather: Weather, seconds_remaining: float) + +enum Weather { + SUNNY, + CLOUDY, + RAINY, + FOGGY, +} + +const DEFAULT_WEATHER: Weather = Weather.SUNNY +const SUNNY_DURATION_RANGE := Vector2(480.0, 900.0) +const CLOUDY_DURATION_RANGE := Vector2(300.0, 720.0) +const RAINY_DURATION_RANGE := Vector2(300.0, 600.0) +const FOGGY_DURATION_RANGE := Vector2(300.0, 600.0) + +var _weather: Weather = DEFAULT_WEATHER +var _seconds_remaining: float = SUNNY_DURATION_RANGE.x +var _running_authority: bool = false +var _rng := RandomNumberGenerator.new() + + +func _ready() -> void: + set_process(false) + + +func _process(delta: float) -> void: + advance_weather(delta) + + +func begin_authoritative_session(seed_value: int) -> void: + _rng.seed = seed_value + _running_authority = true + set_process(true) + _set_weather(DEFAULT_WEATHER, _roll_duration(DEFAULT_WEATHER), true) + + +func begin_remote_session() -> void: + _running_authority = false + set_process(false) + _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true) + + +func end_session() -> void: + _running_authority = false + set_process(false) + _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true) + + +func advance_weather(real_seconds: float) -> void: + if not _running_authority or real_seconds <= 0.0: + return + _seconds_remaining -= real_seconds + while _seconds_remaining <= 0.0: + var overrun: float = -_seconds_remaining + var next_weather: Weather = _choose_next_weather() + _set_weather(next_weather, _roll_duration(next_weather), true) + _seconds_remaining -= overrun + + +func apply_authoritative_snapshot( + weather: Weather, + seconds_remaining: float, +) -> void: + if not is_valid_weather(int(weather)) or not is_finite(seconds_remaining): + return + _set_weather(weather, maxf(seconds_remaining, 0.0), false) + + +func get_weather() -> Weather: + return _weather + + +func get_seconds_remaining() -> float: + return _seconds_remaining + + +func is_raining() -> bool: + return _weather == Weather.RAINY + + +func is_foggy() -> bool: + return _weather == Weather.FOGGY + + +func get_weather_name() -> String: + return weather_name(_weather) + + +static func is_valid_weather(value: int) -> bool: + return value >= Weather.SUNNY and value <= Weather.FOGGY + + +static func weather_name(weather: Weather) -> String: + match weather: + Weather.SUNNY: + return "sunny" + Weather.CLOUDY: + return "cloudy" + Weather.RAINY: + return "rainy" + Weather.FOGGY: + return "foggy" + return "unknown" + + +func _set_weather( + weather: Weather, + seconds_remaining: float, + force_emit: bool, +) -> void: + var changed: bool = weather != _weather + _weather = weather + _seconds_remaining = maxf(seconds_remaining, 0.0) + if force_emit or changed: + weather_changed.emit(_weather, _seconds_remaining) + + +func _choose_next_weather() -> Weather: + match _weather: + Weather.SUNNY: + return Weather.CLOUDY + Weather.RAINY, Weather.FOGGY: + return Weather.CLOUDY + Weather.CLOUDY: + var roll: float = _rng.randf() + if roll < 0.45: + return Weather.SUNNY + if roll < 0.80: + return Weather.RAINY + return Weather.FOGGY + return Weather.SUNNY + + +func _roll_duration(weather: Weather) -> float: + var duration_range: Vector2 = _duration_range(weather) + return _rng.randf_range(duration_range.x, duration_range.y) + + +func _duration_range(weather: Weather) -> Vector2: + match weather: + Weather.SUNNY: + return SUNNY_DURATION_RANGE + Weather.CLOUDY: + return CLOUDY_DURATION_RANGE + Weather.RAINY: + return RAINY_DURATION_RANGE + Weather.FOGGY: + return FOGGY_DURATION_RANGE + return SUNNY_DURATION_RANGE diff --git a/world/world_weather_service.gd.uid b/world/world_weather_service.gd.uid new file mode 100644 index 0000000..8eed48d --- /dev/null +++ b/world/world_weather_service.gd.uid @@ -0,0 +1 @@ +uid://v4c54isq6w74