Spent the day building the base for a dynamic weather system! What do you think?

I just spent the day in Godot creating a system that would allow me to mix various weather effects, like wind, rain and storm, to create a dynamic weather system!

After exploring different possibilities, I ended up making a custom Resource class named “weather effect” that contains the basic parameters of each effect.

extends Resource
class_name WeatherEffect

@export var effect_name: String
enum Intensity {
NONE,
LOW,
MEDIUM,
HIGH }
@export var intensity_db_volumes: Dictionary[Intensity, float] = {
	Intensity.NONE: -80.0,
	Intensity.LOW: -10.0,
	Intensity.MEDIUM: -5.0,
	Intensity.HIGH: 0.0 }
@export var transition_time: float = 5.0
@export var minimum_time: float = 40.0
@export var maximum_time: float = 120.0

Then I created a script extending Node called “weather_cycle’” that allows me to control the mixing of the different weather effects to create dynamic weather. This is the node that will be added to my scene to add weather functionalities.

extends Node

@export var world_environment: WorldEnvironment
@export var grass_material: ShaderMaterial

@export_group("Weather Effects")

@export_subgroup("Wind", "wind_")
@export var wind_resource: WeatherEffect
@export var wind_audio_stream_player: AudioStreamPlayer
@export var wind_grass_wind_strength: Dictionary[WeatherEffect.Intensity, float]
@export var wind_grass_wind_speed: Dictionary[WeatherEffect.Intensity, float]

@export_subgroup("Rain", "rain_")
@export var rain_resource: WeatherEffect
@export var rain_audio_stream_player: AudioStreamPlayer


var wind_intensity: WeatherEffect.Intensity = WeatherEffect.Intensity.NONE: set = set_wind_intensity

var rain_intensity: WeatherEffect.Intensity = WeatherEffect.Intensity.NONE: set = set_rain_intensity


@onready var weather_effects_timers : Dictionary[WeatherEffect, Timer] = {
	wind_resource: null,
	rain_resource: null
}

@onready var intensity_setters : Dictionary[WeatherEffect, Callable] = {
	wind_resource: set_wind_intensity,
	rain_resource: set_rain_intensity
}


func _ready() -> void:
	for weather_effect_resource in weather_effects_timers.keys():
		intensity_setters[weather_effect_resource].call(WeatherEffect.Intensity.NONE)
		var new_timer := Timer.new()
		new_timer.name = str(weather_effect_resource.effect_name + "_timer")
		add_child(new_timer)
		weather_effects_timers[weather_effect_resource] = new_timer
		new_timer.timeout.connect(on_weather_timer_timeout.bind(weather_effect_resource))
		new_timer.start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func on_weather_timer_timeout(weather_effect_resource: WeatherEffect) -> void:
	var random_intensity: WeatherEffect.Intensity = randi() % WeatherEffect.Intensity.size()
	intensity_setters[weather_effect_resource].call(random_intensity)
	weather_effects_timers[weather_effect_resource].start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func set_wind_intensity(value: WeatherEffect.Intensity):
	wind_intensity = value
	
	grass_material.set_shader_parameter("wind_speed", wind_grass_wind_speed[value])
	grass_material.set_shader_parameter("wind_strength", wind_grass_wind_strength[value])
	
	if value != WeatherEffect.Intensity.NONE:
		wind_audio_stream_player.play()
		
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		wind_audio_stream_player,
		"volume_db",
		wind_resource.intensity_db_volumes[wind_intensity], 
		wind_resource.transition_time
			)
	if value == WeatherEffect.Intensity.NONE:
		wind_audio_stream_player.stop()

func set_rain_intensity(value: WeatherEffect.Intensity):
	rain_intensity = value
	
	if value != WeatherEffect.Intensity.NONE:
		rain_audio_stream_player.play()
		
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		rain_audio_stream_player,
		"volume_db",
		rain_resource.intensity_db_volumes[rain_intensity], 
		rain_resource.transition_time
			)
	if value == WeatherEffect.Intensity.NONE:
		rain_audio_stream_player.stop()

The script exports one WeatherEffect type variables for each effect so I only have to define them in one place. Each of these resources is then assigned a timer which is connected to a function in the same script.

When the timer ends, I get a new random intensity from 0 to 3 (defined by the enum WeatherEffect.Intensity) and set the associated unique variable. These unique variables are defined with a set() function that allows custom logic to be executed for each effect. For example, the wind updates some parameters in the grass shader when it changes intensity.

I then restart the timer with a random value between WeatherEffect.minimum_time and WeatherEffect.maximum_time to define the duration of the new intensity.

While this is just the framework for making such a system and there are functionalities that I haven’t implemented yet, I thought it would be interesting to share this, to get feedback or inspire people :slight_smile:

I am not a professional programmer, just a hobbyist, so feel free to communicate any improvements you’d make

I’d change the name WeatherEffect to Weather. It’ll make your code easier to read and conveys the same functionality.

This is odd, because if you use setters above, so why not just use a setter on your wind_resource variable? Then you don’t need to be calling this function all over the place.

My gut says this is overcomplicated. There are a lot of callables in this code - which is fine, but typically not necessary in GDScript.

2 Likes

Hi, thanks a lot for the feedback :slight_smile:

If I understand correctly your point on setters, your proposition is that I add the setter functions for the intensity of the effect inside the resource itself ? So inside weather_effect.gd adding:

var intensity: Intensity = Intensity.NONE: set = set_effect_intensity

But the thing is that I want specific behaviour to trigger for each effect. When the wind intensity changes, it needs to update a value in another node’s shader.

From my understanding then, this means I would have to make custom resources that extend WeatherEffect and override the set_effect_intensity for each weather effect. This has the advantage that I don’t have to repeat the common behaviour in several places.

In weather_effect.gd:


var intensity: Intensity
func set_effect_intensity(value: Intensity) -> void:
	intensity = value
	#common behaviour
	_set_effect_intensity(value)

func _set_effect_intensity(value: Intensity) -> void:
	#Virtual method, to be changed by sub classes
	pass

and then in wind_effect.gd (for example):

extends WeatherEffect

func _set_effect_intensity(value: Intensity) -> void:
	#wind specific behaviour
	pass

However, the problem is that now I need to manage a reference to the node I need to access when the wind intensity changes inside this resource script, which sounds messy… If I keep the setter function in the weather_cycle script, all my node references can remain at the same place with export variables.

Maybe I’m looking at this at the wrong angle, were you thinking of another way to implement this ?

I do tend to make things over complicated because I am trying to create code that can scale easily. I was thinking this way, I could add a bunch of other weather effects, like fog and thunder, relatively easily. But maybe there are better ways to do this.

1 Like

When reading your code, I cannot tell why you made certain decisions. My point on setters is that you seem to use them in odd places. Also, the code smacks of Future-Proofing. Especially when you tell me you’re trying to make it extensible for fog, etc. Because right now, your code relies on WeatherCycle being tightly coupled with WeatherEffect.

So, I spent some time refactoring your code.

Weather

  • Renamed from WeatherEffect.
  • Followed the GDScript Style Guide and re-ordered your code to make it cleaner.
  • Removed the option to set a custom volume for Intensity.NONE.
  • Changed the embedded Dictionary for volume into three separate variables. This makes them much easier to see and edit in the Resource.
  • Made the volume variables ranges, so you cannot select a volume level that would cause an error with the AudioStreamPlayer.
  • Changed effect_name to name to make it more readable.
  • Added a get_volume() function to handle returning volume intensity.
  • Added a sound variable to store the weather sounds as an AudioStream so that we can refactor out the AudioStreamPlayer in WeatherCycle later on.
  • Added documentation comments.
class_name Weather extends Resource

enum Intensity {
NONE,
LOW,
MEDIUM,
HIGH }

const VOLUME_OFF = -80.0

##Name used for timers created for this effect.
@export var name: String
## Sound to play when this weather is in effect.
@export var sound: AudioStream
## Volume to play the weather sound when at [enum Intensity.LOW].
@export_range(-80.0, 24.0, 0.01, "db") var low_volume = -10.0
## Volume to play the weather sound when at [enum Intensity.MEDIUM].
@export_range(-80.0, 24.0, 0.01, "db") var medium_volume = -5.0
## Volume to play the weather sound when at [enum Intensity.HIGH].
@export_range(-80.0, 24.0, 0.01, "db") var high_volume = 0.0
@export var transition_time: float = 5.0
@export var minimum_time: float = 40.0
@export var maximum_time: float = 120.0


## Returns in dB the volume for the given [enum Intensity].
func get_volume(intensity: Intensity) -> float:
	match intensity:
		Intensity.LOW:
			return low_volume
		Intensity.LOW:
			return medium_volume
		Intensity.LOW:
			return high_volume
		_:
			return VOLUME_OFF

So now the Resource went from this:

To this:

I then refactored the weather_effect.gd code to work with this new object, as well as a few other tweaks, like casting the random weather line as an Enum.

WeatherCycle - 1st Refactor
extends Node

@export var world_environment: WorldEnvironment
@export var grass_material: ShaderMaterial

@export_group("Weather Effects")

@export_subgroup("Wind", "wind_")
@export var wind_resource: Weather
@export var wind_audio_stream_player: AudioStreamPlayer
@export var wind_grass_wind_strength: Dictionary[Weather.Intensity, float]
@export var wind_grass_wind_speed: Dictionary[Weather.Intensity, float]

@export_subgroup("Rain", "rain_")
@export var rain_resource: Weather
@export var rain_audio_stream_player: AudioStreamPlayer


var wind_intensity: Weather.Intensity = Weather.Intensity.NONE: set = set_wind_intensity

var rain_intensity: Weather.Intensity = Weather.Intensity.NONE: set = set_rain_intensity


@onready var weather_effects_timers : Dictionary[Weather, Timer] = {
	wind_resource: null,
	rain_resource: null
}

@onready var intensity_setters : Dictionary[Weather, Callable] = {
	wind_resource: set_wind_intensity,
	rain_resource: set_rain_intensity
}


func _ready() -> void:
	for weather_effect_resource in weather_effects_timers.keys():
		intensity_setters[weather_effect_resource].call(Weather.Intensity.NONE)
		var new_timer := Timer.new()
		new_timer.name = str(weather_effect_resource.name + "_timer")
		add_child(new_timer)
		weather_effects_timers[weather_effect_resource] = new_timer
		new_timer.timeout.connect(on_weather_timer_timeout.bind(weather_effect_resource))
		new_timer.start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func on_weather_timer_timeout(weather_effect_resource: Weather) -> void:
	var random_intensity: Weather.Intensity = randi() % Weather.Intensity.size() as Weather.Intensity
	intensity_setters[weather_effect_resource].call(random_intensity)
	weather_effects_timers[weather_effect_resource].start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func set_wind_intensity(value: Weather.Intensity) -> void:
	wind_intensity = value
	
	grass_material.set_shader_parameter("wind_speed", wind_grass_wind_speed[value])
	grass_material.set_shader_parameter("wind_strength", wind_grass_wind_strength[value])
	
	if value != Weather.Intensity.NONE:
		wind_audio_stream_player.play()
		
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		wind_audio_stream_player,
		"volume_db",
		wind_resource.get_volume(wind_intensity),
		wind_resource.transition_time
			)
	if value == Weather.Intensity.NONE:
		wind_audio_stream_player.stop()


func set_rain_intensity(value: Weather.Intensity) -> void:
	rain_intensity = value
	
	if value != Weather.Intensity.NONE:
		rain_audio_stream_player.play()
		
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		rain_audio_stream_player,
		"volume_db",
		rain_resource.get_volume(rain_intensity),
		rain_resource.transition_time
			)
	if value == Weather.Intensity.NONE:
		rain_audio_stream_player.stop()

Wind

I noticed that the only difference between Wind and Rain was Grass Wind Strength and Grass Wind Speed.

Since this information could be stored in the Resource, it made sense to create a new Resource object called Wind that inherited from Weather.

class_name Wind extends Weather

@export var grass_wind_strength: Dictionary[Weather.Intensity, float]
@export var grass_wind_speed: Dictionary[Weather.Intensity, float]

Then a few tweaks to weather_cycle.gd:

@export_subgroup("Wind", "wind_")
@export var wind_resource: Wind
@export var wind_audio_stream_player: AudioStreamPlayer

@export_subgroup("Rain", "rain_")
@export var rain_resource: Weather
@export var rain_audio_stream_player: AudioStreamPlayer

Changing the Resource type from Weather to Wind and removing the references to the shader parameters.

grass_material.set_shader_parameter("wind_speed", wind_resource.grass_wind_speed[value])
grass_material.set_shader_parameter("wind_strength", wind_resource.grass_wind_strength[value])

And making sure we can still find those shader parameters again.

set_x_intensity()

You have two functions that do almost exactly the same thing. Except one sets two shader parameters. That’s ripe for some refactoring.

func _change_intensity(value: Weather.Intensity, audio_stream_player: AudioStreamPlayer, weather: Weather) -> void:
	if value != Weather.Intensity.NONE:
		audio_stream_player.play()
	
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		audio_stream_player,
		"volume_db",
		weather.get_volume(value),
		weather.transition_time
			)
	
	await get_tree().create_timer(weather.transition_time).timeout
	if value == Weather.Intensity.NONE:
		audio_stream_player.stop()

This fixes the problem that if intensity was set to None the sound would immediately cut off instead of ramping down. It also passes all the arguments needed to use this in the setters, which then become:

func set_wind_intensity(value: Weather.Intensity) -> void:
	wind_intensity = value
	
	grass_material.set_shader_parameter("wind_speed", wind_resource.grass_wind_speed[value])
	grass_material.set_shader_parameter("wind_strength", wind_resource.grass_wind_strength[value])
	
	_change_intensity(value, wind_audio_stream_player, wind_resource)


func set_rain_intensity(value: Weather.Intensity) -> void:
	rain_intensity = value
	_change_intensity(value, rain_audio_stream_player, rain_resource)

The _change_intensity() function (which we made private) now no longer relies on hardcoded information in weather_cycle.gd. Sadly, it cannot be moved to the Weather code because Resource objects do not have access to get_tree(). Still, there’s some refactoring we can still do with weather_cycle.gd.

Supporting Infinite Weather Objects

Right now, you can support one Wind object and one Weather object (which is really a Rain object). To add, say Fog, we have to:

  1. Add an @export_subgroup.
  2. Add a new resource.
  3. Add an AudioStreamPlayer.
  4. Add a new intensity variable for fog.
  5. Add that variable to the list of two @onready variable Dictionary objects.
  6. Add a new setter for fog.

Let’s see if we can reduce the work needed to add new effects - and to support multiple of the same kind of resource. For example, what if we want light rain, heavy rain and hail?

Architecture

The biggest problem we have is that the WeatherCycle object (which I’ve been assuming you’re using as an Autoload) is required to create a weather. A Weather resource cannot create weather on its own because it’s not a Node. So there’s a few possible solutions:

  1. Use the WeatherCycle (Autoload) as the thing that actually executes weather, and reads Weather resources as recipes. (I believe this is what you’re doing now.)
  2. Create a Component of some type from a Node object that gets attached to the WeatherCycle (Autoload) and can be used to execute weather as recipes - “playing” the weather. This would be kind like making an AudioStreamPlayer that loads AudioStreams, so you might call it WeatherPlayer.
  3. Change the Weather object to a Node that can handle playing its own data. Then attach them to WeatherCycle.

Based on the fact that you started by using Resource objects, I think the best thing to do is Option 2. Then you can load different Weather types into it.

Resource Icons

I took a little detour here. I’m a big fan of using icons with custom classes in Godot. They really help make things clearer in the editor, and they are so easy to add. I get most of my icons from SVG Repo, because they are free.

There is no consistent color for Resource icons. A lot are white, some are purple or yellow. AudioStream icons are rainbow colored. For Weather resources I chose to use the same light blue that Godot uses for Shapes. It’s as easy as finding an icon for Weather, Wind, and Rain, then editing the icon and changing the color. (I just used the dropper and selected the color straight from the Add Resource dialog.)

Then I downloaded the icons, and copied them over to the project.

Then I added them to the files.

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532033/cloud
@icon("uid://jplyu2garh4o")
class_name Weather extends Resource

You’ll note I’m using the UID of each file. This is so if I move the textures around later, the link still works. To get the UID, just right-click the file and select Copy UID.

You may be wondering where the Rain object came from. I made it. It’s literally a placeholder so I can have a different icon. This is the whole file:

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532041/cloud-showers
@icon("uid://uj5534pjfxfl")
class_name Rain extends Weather

And the full code for Wind is now:

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532896/wind
@icon("uid://dsru3asjm27dq")
class_name Wind extends Weather

@export var grass_wind_strength: Dictionary[Weather.Intensity, float]
@export var grass_wind_speed: Dictionary[Weather.Intensity, float]

(Keep in mind your UIDs will be different.)

WeatherPlayer

Now that we have some colorful Resource icons, it’s time to make our WeatherPlayer. First thing I’m going to do is get a WeatherPlayer icon. (When I edited it, I grabbed the color of the white editor icons, because they are actually light grey.)

With a little bit of code, we can add our new Node using the Add Node dialog. This will be important as we create this class. We want you to be able to create it as a normal node instead of having to instantiate it.

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532034/cloud-arrow-down
@icon("uid://hdp03domu0qe")
## Plays [Weather] resources.
class_name WeatherPlayer extends Node

To make the icon show you, you have to reload your project. Project Menu → Reload Project.

Then you can add your new node anywhere:

(Note our documentation comment showing up at the bottom.)

Now that we have our WeatherPlayer node, we want it to do three things:

  1. Take over creating and managing the AudioStreamPlayers.
  2. Run all the code we put in _change_intensity().
  3. Track the current intensity.

With a few variables, and copying over our code from the _change_intensity() function, we have this:

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532034/cloud-arrow-down
@icon("uid://hdp03domu0qe")
## Plays [Weather] resources.
class_name WeatherPlayer extends Node

## The [Weather] resource to play.
@export var weather: Weather

## The current [Weather.Intensity] that is playing (or was last played).
var intensity: Weather.Intensity = Weather.Intensity.NONE: set = play
var audio_stream_player: AudioStreamPlayer


func _ready() -> void:
	audio_stream_player = AudioStreamPlayer.new()
	audio_stream_player.stream = weather.sound
	add_child(audio_stream_player)


func play(value: Weather.Intensity) -> void:
	intensity = value
	if value != Weather.Intensity.NONE:
		audio_stream_player.play()
	
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		audio_stream_player,
		"volume_db",
		weather.get_volume(value),
		weather.transition_time
			)
	
	await get_tree().create_timer(weather.transition_time).timeout
	if value == Weather.Intensity.NONE:
		audio_stream_player.stop()

Before we deal with the Wind and grass effects, let’s make WeatherCycle use our new WeatherPlayer.

WeatherCycle Revision 4

Right now, you are exporting two AudioStreamPlayer nodes, which means they are somewhere in your project, and you’re connecting them by assigning the values in the Inspector. So I’m going to follow the same pattern for my example. However, I strongly recommend that you make these @onready variable references.

First, I created two WeatherPlayers and added them to the WeatherCycle node.

image

Then I added a Wind and Rain resource to each respectively in the Inspector.

At first, you may say, “But I want to be able to see them all in one place.” That’s ok, we will get there. At this point, we are transitioning and we want to make sure everything works.

NOTE: At this point, it is a good time to point out I cannot actually test this code. All I can do is make sure it compiles without errors. I am writing this all based on my experience. You will have to test and tweak all this to fit your needs at the end. Which is one of the reasons I am giving it to you in steps, so if you need to debug it you can go through each of the steps I did.

Our new weather_cycle.gd` is getting much simpler.

extends Node

@export var world_environment: WorldEnvironment
@export var grass_material: ShaderMaterial

@export_group("Weather Effects")

@export_subgroup("Wind", "wind_")
@export var wind_weather_player: WeatherPlayer

@export_subgroup("Rain", "rain_")
@export var rain_weather_player: WeatherPlayer

var wind_intensity: Weather.Intensity = Weather.Intensity.NONE: set = set_wind_intensity
var rain_intensity: Weather.Intensity = Weather.Intensity.NONE: set = set_rain_intensity

@onready var wind_resource = wind_weather_player.weather
@onready var rain_resource = wind_weather_player.weather
@onready var weather_effects_timers : Dictionary[Weather, Timer] = {
	wind_resource: null,
	rain_resource: null
}
@onready var intensity_setters : Dictionary[Weather, Callable] = {
	wind_resource: set_wind_intensity,
	rain_resource: set_rain_intensity
}


func _ready() -> void:
	for weather_effect_resource in weather_effects_timers.keys():
		intensity_setters[weather_effect_resource].call(Weather.Intensity.NONE)
		var new_timer := Timer.new()
		new_timer.name = str(weather_effect_resource.name + "_timer")
		add_child(new_timer)
		weather_effects_timers[weather_effect_resource] = new_timer
		new_timer.timeout.connect(on_weather_timer_timeout.bind(weather_effect_resource))
		new_timer.start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func on_weather_timer_timeout(weather_effect_resource: Weather) -> void:
	var random_intensity: Weather.Intensity = randi() % Weather.Intensity.size() as Weather.Intensity
	intensity_setters[weather_effect_resource].call(random_intensity)
	weather_effects_timers[weather_effect_resource].start(
			randf_range(
			weather_effect_resource.minimum_time,
			weather_effect_resource.maximum_time
				)
			)


func set_wind_intensity(value: Weather.Intensity) -> void:
	wind_intensity = value
	
	grass_material.set_shader_parameter("wind_speed", wind_resource.grass_wind_speed[value])
	grass_material.set_shader_parameter("wind_strength", wind_resource.grass_wind_strength[value])
	
	wind_weather_player.play(value)


func set_rain_intensity(value: Weather.Intensity) -> void:
	rain_intensity = value
	rain_weather_player.play(value)

In fact, at this point it’s got a lot of redundant code. It’s tracking intensity, and then it is setting intensity, and calling the WeatherPlayers. Really, all it needs to do is call the WeatherPlayers. But, for that to be fully functional, we need to deal with the grass_material object and the code attached to it.

Grass Material

So you are leveraging the fact that you can have multiple things reference the same Resource, and changing it one place changes it everywhere. Specifically the grass_material ShaderMaterial. There is no need to store a reference to it in the WeatherCycle object at all. In fact, the only thing that cares about it is the Wind resource, and (in a minute) the WeatherPlayer. So let’s refactor that out.

First, we add it to the Wind resource:

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532896/wind
@icon("uid://dsru3asjm27dq")
class_name Wind extends Weather

@export var grass_material: ShaderMaterial
@export var grass_wind_strength: Dictionary[Weather.Intensity, float]
@export var grass_wind_speed: Dictionary[Weather.Intensity, float]

Good, we’ve moved it. But here’s the thing: You don’t want to set this value EVERY time you make a Wind resource. So what can we do? Well, the easiest thing to do is use a constant. As long as the shader is saved somewhere on disk, when you load it - it will refer to the same object. That’s the magic of RefCounted objects.

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532896/wind
@icon("uid://dsru3asjm27dq")
class_name Wind extends Weather

const GRASS_SHADER = preload("uid://bellu0v7uivg7")

@export var grass_material: ShaderMaterial = GRASS_SHADER
@export var grass_wind_strength: Dictionary[Weather.Intensity, float]
@export var grass_wind_speed: Dictionary[Weather.Intensity, float]

Now, you don’t have to assign the shader every time, but if you want to point a Wind resource at a new shader, you can in the Inspector or in code at a later time. To create that constant, I saved a ShaderMaterial to disk.

  1. Select the shader in FileSystem.
  2. Drag it to the Script Editor.
  3. Hold down the Ctrl key. (After you’ve dragged it, but before you drop it.)
  4. Drop it into your code.
  5. The preload line will be automatically made for you. (Change the assignment if the const name is different.)

Second, we need to use it in the WeatherPlayer. We can just copy the code over from WeatherCycle and put it in our play() function.

if weather is Wind:
	weather.grass_material.set_shader_parameter("wind_speed", weather.grass_wind_speed[value])
	weather.grass_material.set_shader_parameter("wind_strength", weather.grass_wind_strength[value])
Full weather.player.gd Code
# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532034/cloud-arrow-down
@icon("uid://hdp03domu0qe")
## Plays [Weather] resources.
class_name WeatherPlayer extends Node

## The [Weather] resource to play.
@export var weather: Weather

## The current [Weather.Intensity] that is playing (or was last played).
var intensity: Weather.Intensity = Weather.Intensity.NONE: set = play
var audio_stream_player: AudioStreamPlayer


func _ready() -> void:
	audio_stream_player = AudioStreamPlayer.new()
	audio_stream_player.stream = weather.sound
	add_child(audio_stream_player)


func play(value: Weather.Intensity) -> void:
	intensity = value

	if weather is Wind:
		weather.grass_material.set_shader_parameter("wind_speed", weather.grass_wind_speed[value])
		weather.grass_material.set_shader_parameter("wind_strength", weather.grass_wind_strength[value])

	if value != Weather.Intensity.NONE:
		audio_stream_player.play()
	
	var tween := create_tween()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_ease(Tween.EASE_IN_OUT)
	tween.tween_property(
		audio_stream_player,
		"volume_db",
		weather.get_volume(value),
		weather.transition_time
			)
	
	await get_tree().create_timer(weather.transition_time).timeout
	if value == Weather.Intensity.NONE:
		audio_stream_player.stop()

Then we delete it out of weather_cycle.gd and our set_wind_intensity() function looks just like our set_rain_intensity() function:

func set_wind_intensity(value: Weather.Intensity) -> void:
	wind_intensity = value	
	wind_weather_player.play(value)

Like refactoring a math problem, we’ve now got a lot of duplicate code that just calls the same functions, but have specific names. It’s time for our last refactor.

Weather Cycle Revision 5

So first of all, I’ve noticed that the world_environment variable is never used. So I’ve deleted it. Maybe you had plans to use it in the future. For now though, it’s gone. With that, your WeatherCycle node now look like this in the Inspector.

Then I am deleting all the other non-export variables. (I actually commented them out first.) This gives us 7 errors in the rest of our code. So let’s take a look at what we are trying to do.

_ready()
This function is iterating through all the Dictionary items in an @onready variable, and creates a Timer to assign to each entry. Then it sets off each Timer and waits for it to expire.

on_weather_timer_timeout()
On timeout, the Timers select a new random intensity and set the intensity, which calls play.

So all we need to do is make sure we have a Timer for each WeatherPlayer.

Final weather_player.gd

extends Node


func _ready() -> void:
	for weather_player in get_children():
		if weather_player is WeatherPlayer:
			var timer := Timer.new()
			add_child(timer)
			timer.timeout.connect(_on_weather_timer_timeout.bind(timer, weather_player))
			# Start the weather immediately so it doesn't always start as "None".
			_on_weather_timer_timeout(timer, weather_player)


# Takes the [Timer] that expired and the attached [WeatherPlayer] as arguments.
# In this way you can change the [Weather] [Resource] at runtime and it will
# play the new [Weather].
func _on_weather_timer_timeout(timer: Timer, weather_player: WeatherPlayer) -> void:
	var intensity := Weather.Intensity.values().pick_random() as Weather.Intensity
	weather_player.play(intensity)
	timer.start(randf_range(weather_player.weather.minimum_time, weather_player.weather.maximum_time))

None of the variables at all remain. Everything is now stored in Nodes. Add a new WeatherPlayer node, and it’ll automatically get picked up and played at runtime. To add fog, you can add a new Fog resource, and then tell the WeatherPlayer how to play it. You’ll notice all the callables have disappeared. They aren’t necessary.

Say you wanted to have multiple random Wind effects. You could create an @exported Array and call pick_random() inside WeatherCycle. Or, you could create another Weather type called WeatherRandomizer like AudioStreamRandomizer than holds that Array and every time WeatherPlayer.play() is called, it picks a new one.

Conclusion

Hope this helps. I ended up taking a deep dive that took me half the day. It was a fun challenge. No pressure to use it. This is just how I would refactor your code.

6 Likes

I think it rocks that you shared so much code!

And that you were rewarded by @dragonforge-dev with this gem.

1 Like

Thank you so much for this, this sort of feedback priceless :slightly_smiling_face:
I took the time to integrate this and understand the changes. This feels a lot smoother and I really learned a lot so thank you again.

I wanted to avoid

	if weather is Wind:
		weather.grass_material.set_shader_parameter("wind_speed", weather.grass_wind_speed[value])
		weather.grass_material.set_shader_parameter("wind_strength", weather.grass_wind_strength[value])

in weather_player.gd to avoid hardcoding the effects of the various weather in the players themselves. So in weather.gd I added :


## Function called when the weather changes intensity
func play(intensity: Intensity) -> void:
	_play(intensity)


## Virtual method called when the weather changes intensity
func _play(intensity: Intensity) -> void:
	pass

and then in wind.gd :


func _play(intensity: Intensity) -> void:
	grass_material.set_shader_parameter("wind_strength", grass_wind_strength[intensity])
	grass_material.set_shader_parameter("wind_speed", grass_wind_speed[intensity])

I also added fog in the same manner, by extending the weather resource, declaring a default environment resource and the associated export variable, and creating a new _play() function:

# Icon by DazzleUI (CCA License)
# https://www.svgrepo.com/svg/532033/cloud_fog
@icon("uid://dlu4s4xmmsmqy")
class_name Fog extends Weather

const OUTWORLD_ENVIRONMENT = preload("res://outworld/outworld_environment.tres")

@export var environment: Environment = OUTWORLD_ENVIRONMENT
@export var fog_density: Dictionary[Weather.Intensity, float] = {
	Intensity.NONE: 0.0,
	Intensity.LOW: 0.6,
	Intensity.MEDIUM: 0.7,
	Intensity.HIGH: 0.9
}
@export var fog_aerial_perspective: Dictionary[Weather.Intensity, float] = {
	Intensity.NONE: 1.0,
	Intensity.LOW: 0.9,
	Intensity.MEDIUM: 0.5,
	Intensity.HIGH: 0.2
}


func _play(intensity: Intensity) -> void:
	environment.fog_density = fog_density[intensity]
	environment.fog_aerial_perspective = fog_aerial_perspective[intensity]

But actually, I realized that I needed to tween the properties in both wind.gd and fog.gd, to make a smooth change instead.

But since Resources don’t have access to the scene tree, they can’t create or use Tweens. So my options are

  1. Keep if statements inside the weather player, so I must code the logic of each weather inside the weather player class. If I add a bunch of other weather effects, this could lead to a long series of if checks
  2. extract the sound logic of play() and move it to play_sound(). Then add play_visual() function and add the if statements inside
  3. make new classes that extends weather_player.gd for wind and fog and add _play function inside, called from main class play()

Or maybe some other way I haven’t thought of :slight_smile:

Anyway, thanks a lot for the quality of your feedback, it really helped me a lot

1 Like

Make a WindWeatherPlayer and a FogWeatherPlayer if you don’t want to put it in the base WeatherPlayer.

1 Like