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) const MAX_PERSISTED_SECONDS: float = 1800.0 const DAILY_PLAN_SEGMENT_HOURS: float = 2.0 const DAILY_PLAN_SEGMENT_COUNT: int = 12 var _weather: Weather = DEFAULT_WEATHER var _seconds_remaining: float = SUNNY_DURATION_RANGE.x var _persistent_weather: Weather = DEFAULT_WEATHER var _persistent_seconds_remaining: float = SUNNY_DURATION_RANGE.x var _has_persistent_state: bool = false var _persistence_tracking_enabled: bool = false var _running_authority: bool = false var _rng := RandomNumberGenerator.new() var _daily_plan_id: String = "" var _daily_schedule: Array[Dictionary] = [] var _world_time: WorldTimeService 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) if _daily_schedule.is_empty() or _world_time == null: if _has_persistent_state: _set_weather( _persistent_weather, _persistent_seconds_remaining, true, ) else: _set_weather( DEFAULT_WEATHER, _roll_duration(DEFAULT_WEATHER), true, ) else: _update_scheduled_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 if not _daily_schedule.is_empty() and _world_time != null: _update_scheduled_weather(false) 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 if _persistence_tracking_enabled: _store_persistent_state() 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 set_persistence_tracking_enabled(enabled: bool) -> void: if _persistence_tracking_enabled == enabled: return if _persistence_tracking_enabled: _store_persistent_state() _persistence_tracking_enabled = enabled func restore_persistent_state( weather: Weather, seconds_remaining: float, ) -> bool: if not is_valid_persistent_state(weather, seconds_remaining): return false _persistent_weather = weather _persistent_seconds_remaining = seconds_remaining _has_persistent_state = true if _persistence_tracking_enabled: _set_weather( _persistent_weather, _persistent_seconds_remaining, true, ) return true func reset_persistent_state() -> void: _has_persistent_state = false _persistent_weather = DEFAULT_WEATHER _persistent_seconds_remaining = SUNNY_DURATION_RANGE.x if _persistence_tracking_enabled and _running_authority: _set_weather( DEFAULT_WEATHER, _roll_duration(DEFAULT_WEATHER), true, ) func has_persistent_state() -> bool: return _has_persistent_state func get_persistent_weather() -> Weather: return _persistent_weather func get_persistent_seconds_remaining() -> float: return _persistent_seconds_remaining 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) func configure_daily_plan( plan_id: String, schedule: Array, world_time: WorldTimeService, ) -> bool: if ( plan_id.is_empty() or not is_valid_daily_plan_schedule(schedule) or world_time == null ): return false _daily_plan_id = plan_id _daily_schedule.clear() for value: Variant in schedule: _daily_schedule.append((value as Dictionary).duplicate(true)) _world_time = world_time if _running_authority: _update_scheduled_weather(true) return true func clear_daily_plan() -> void: _daily_plan_id = "" _daily_schedule.clear() _world_time = null func get_daily_plan_id() -> String: return _daily_plan_id static func is_valid_weather(value: int) -> bool: return value >= Weather.SUNNY and value <= Weather.FOGGY static func is_valid_persistent_state( weather: Weather, seconds_remaining: float, ) -> bool: return ( is_valid_weather(int(weather)) and is_finite(seconds_remaining) and seconds_remaining >= 0.0 and seconds_remaining <= MAX_PERSISTED_SECONDS ) static func is_valid_daily_plan_schedule(value: Variant) -> bool: if typeof(value) != TYPE_ARRAY: return false var schedule: Array = value if schedule.size() != DAILY_PLAN_SEGMENT_COUNT: return false for entry_value: Variant in schedule: if typeof(entry_value) != TYPE_DICTIONARY: return false var entry: Dictionary = entry_value if ( typeof(entry.get("start_hour")) not in [TYPE_FLOAT, TYPE_INT] or not is_finite(float(entry.get("start_hour", -1.0))) or float(entry.get("start_hour", -1.0)) < 0.0 or float(entry.get("start_hour", -1.0)) >= 24.0 or not _is_bounded_integer( entry.get("weather"), Weather.SUNNY, Weather.FOGGY ) or not is_valid_weather(int(entry.get("weather", -1))) ): return false return true static func _is_bounded_integer( value: Variant, minimum: int, maximum: int, ) -> bool: if typeof(value) == TYPE_INT: return int(value) >= minimum and int(value) <= maximum if typeof(value) != TYPE_FLOAT: return false var number: float = float(value) return ( is_finite(number) and number >= float(minimum) and number <= float(maximum) and is_equal_approx(number, round(number)) ) 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 _persistence_tracking_enabled: _store_persistent_state() if force_emit or changed: weather_changed.emit(_weather, _seconds_remaining) func _store_persistent_state() -> void: _persistent_weather = _weather _persistent_seconds_remaining = _seconds_remaining _has_persistent_state = true 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 func _update_scheduled_weather(force_emit: bool) -> void: if _daily_schedule.is_empty() or _world_time == null: return var elapsed_hours: float = fposmod( _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, WorldTimeService.HOURS_PER_DAY, ) var segment_hours: float = DAILY_PLAN_SEGMENT_HOURS var segment_index: int = clampi( floori(elapsed_hours / segment_hours), 0, _daily_schedule.size() - 1, ) var entry: Dictionary = _daily_schedule[segment_index] var weather: Weather = int(entry.get("weather", DEFAULT_WEATHER)) as Weather var next_boundary: float = float(segment_index + 1) * segment_hours var hours_remaining: float = maxf(next_boundary - elapsed_hours, 0.0) var seconds_remaining: float = ( hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND ) _set_weather(weather, seconds_remaining, force_emit)