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:
- Add an @export_subgroup.
- Add a new resource.
- Add an AudioStreamPlayer.
- Add a new intensity variable for fog.
- Add that variable to the list of two @onready variable Dictionary objects.
- 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:
- 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.)
- 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.
- 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:
- Take over creating and managing the AudioStreamPlayers.
- Run all the code we put in
_change_intensity().
- 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.

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.
- Select the shader in FileSystem.
- Drag it to the Script Editor.
- Hold down the Ctrl key. (After you’ve dragged it, but before you drop it.)
- Drop it into your code.
- 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.