@tool class_name VFXController extends Node3D signal shooting_star_spawned(position: Vector3, direction: Vector3) signal meteor_shower_started() signal meteor_shower_ended() @export_group("Shooting Stars") @export var shooting_stars_enabled: bool = true @export var shooting_star_frequency: float = 0.05 @export var min_spawn_interval: float = 5.0 @export var max_spawn_interval: float = 30.0 @export_group("Shooting Star Appearance") @export var trail_length: float = 2.0 @export var trail_width: float = 0.05 @export var star_brightness: float = 1.5 @export var star_color: Color = Color(1.0, 1.0, 0.9) @export var trail_fade_time: float = 0.5 @export_group("Meteor Shower") @export var meteor_shower_active: bool = false @export var meteor_shower_intensity: float = 1.0 @export var meteors_per_second: float = 3.0 @export var meteor_shower_duration: float = 60.0 @export_group("Meteor Appearance") @export var meteor_trail_length: float = 4.0 @export var meteor_brightness: float = 2.0 @export var meteor_color: Color = Color(1.0, 0.8, 0.5) @export_group("Spawn Area") @export var spawn_radius: float = 500.0 @export var spawn_height_min: float = 100.0 @export var spawn_height_max: float = 200.0 @export var spawn_direction_variance: float = 30.0 @export_group("Audio") @export var meteor_shower_audio_event: String = "" var _spawn_timer: float = 0.0 var _next_spawn_time: float = 0.0 var _meteor_spawn_accumulator: float = 0.0 var _meteor_shower_elapsed: float = 0.0 var _active_stars: Array[Dictionary] = [] var _star_material: ShaderMaterial var _is_night: bool = false func _ready() -> void: _setup_star_mesh() _schedule_next_shooting_star() func _process(delta: float) -> void: if not _is_night: return if shooting_stars_enabled: _process_shooting_stars(delta) if meteor_shower_active: _process_meteor_shower(delta) _update_active_stars(delta) func _setup_star_mesh() -> void: _star_material = ShaderMaterial.new() _star_material.shader = _get_star_shader() _star_material.set_shader_parameter("color", star_color) _star_material.set_shader_parameter("brightness", star_brightness) func _get_star_shader() -> Shader: var shader := Shader.new() shader.code = """ shader_type spatial; render_mode unshaded, depth_draw_never, cull_disabled, blend_add; uniform vec4 color : source_color = vec4(1.0, 1.0, 0.9, 1.0); uniform float brightness = 1.5; uniform float fade = 1.0; uniform float trail_progress = 0.0; void vertex() { VERTEX.y *= mix(0.1, 1.0, trail_progress); } void fragment() { float gradient = UV.y; float alpha = gradient * fade; ALBEDO = color.rgb * brightness; ALPHA = alpha * color.a; } """ return shader func _schedule_next_shooting_star() -> void: _next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval) _next_spawn_time /= maxf(0.01, shooting_star_frequency) _spawn_timer = 0.0 func _process_shooting_stars(delta: float) -> void: _spawn_timer += delta if _spawn_timer >= _next_spawn_time: _spawn_shooting_star() _schedule_next_shooting_star() func _process_meteor_shower(delta: float) -> void: _meteor_shower_elapsed += delta if _meteor_shower_elapsed >= meteor_shower_duration: stop_meteor_shower() return _meteor_spawn_accumulator += meteors_per_second * meteor_shower_intensity * delta while _meteor_spawn_accumulator >= 1.0: _meteor_spawn_accumulator -= 1.0 _spawn_meteor() func _spawn_shooting_star() -> void: var spawn_pos := _get_random_sky_position() var direction := _get_random_fall_direction() var star_data := { "position": spawn_pos, "direction": direction, "speed": randf_range(50.0, 100.0), "lifetime": 0.0, "max_lifetime": trail_fade_time + randf_range(0.2, 0.5), "trail_length": trail_length * randf_range(0.8, 1.2), "brightness": star_brightness * randf_range(0.8, 1.2), "color": star_color, "mesh": _create_star_trail(spawn_pos, direction), "is_meteor": false } _active_stars.append(star_data) shooting_star_spawned.emit(spawn_pos, direction) func _spawn_meteor() -> void: var spawn_pos := _get_random_sky_position() var direction := _get_random_fall_direction() var star_data := { "position": spawn_pos, "direction": direction, "speed": randf_range(80.0, 150.0), "lifetime": 0.0, "max_lifetime": trail_fade_time + randf_range(0.3, 0.7), "trail_length": meteor_trail_length * randf_range(0.8, 1.2), "brightness": meteor_brightness * randf_range(0.8, 1.2), "color": meteor_color, "mesh": _create_star_trail(spawn_pos, direction, true), "is_meteor": true } _active_stars.append(star_data) func _get_random_sky_position() -> Vector3: var angle := randf() * TAU var radius := randf_range(spawn_radius * 0.3, spawn_radius) var height := randf_range(spawn_height_min, spawn_height_max) return Vector3( cos(angle) * radius, height, sin(angle) * radius ) func _get_random_fall_direction() -> Vector3: var base_dir := Vector3(-0.5, -0.8, -0.3).normalized() var variance := deg_to_rad(spawn_direction_variance) var curr_rotation := Basis(Vector3.UP, randf() * TAU) curr_rotation = curr_rotation.rotated(Vector3.RIGHT, randf_range(-variance, variance)) return (curr_rotation * base_dir).normalized() func _create_star_trail(position: Vector3, direction: Vector3, is_meteor: bool = false) -> MeshInstance3D: var mesh_instance := MeshInstance3D.new() var length := meteor_trail_length if is_meteor else trail_length var width := trail_width * (1.5 if is_meteor else 1.0) var quad := QuadMesh.new() quad.size = Vector2(width, length) mesh_instance.mesh = quad var material := _star_material.duplicate() as ShaderMaterial material.set_shader_parameter("color", meteor_color if is_meteor else star_color) material.set_shader_parameter("brightness", meteor_brightness if is_meteor else star_brightness) mesh_instance.material_override = material mesh_instance.position = position add_child(mesh_instance) mesh_instance.look_at(position + direction, Vector3.UP) mesh_instance.rotate_object_local(Vector3.RIGHT, PI / 2.0) return mesh_instance func _update_active_stars(delta: float) -> void: var stars_to_remove: Array[int] = [] for i in _active_stars.size(): var star: Dictionary = _active_stars[i] star["lifetime"] += delta var progress: float = star["lifetime"] / star["max_lifetime"] if progress >= 1.0: stars_to_remove.append(i) continue var mesh: MeshInstance3D = star["mesh"] if is_instance_valid(mesh): star["position"] += star["direction"] * star["speed"] * delta mesh.position = star["position"] var fade := 1.0 - smoothstep(0.5, 1.0, progress) var material := mesh.material_override as ShaderMaterial if material: material.set_shader_parameter("fade", fade) material.set_shader_parameter("trail_progress", minf(progress * 3.0, 1.0)) for i in range(stars_to_remove.size() - 1, -1, -1): var idx: int = stars_to_remove[i] var star: Dictionary = _active_stars[idx] var mesh: MeshInstance3D = star["mesh"] if is_instance_valid(mesh): mesh.queue_free() _active_stars.remove_at(idx) func set_night_mode(is_night: bool) -> void: _is_night = is_night if not is_night: _clear_all_stars() func _clear_all_stars() -> void: for star in _active_stars: var mesh: MeshInstance3D = star["mesh"] if is_instance_valid(mesh): mesh.queue_free() _active_stars.clear() func start_meteor_shower(duration: float = -1.0) -> void: if duration > 0: meteor_shower_duration = duration meteor_shower_active = true _meteor_shower_elapsed = 0.0 _meteor_spawn_accumulator = 0.0 meteor_shower_started.emit() if meteor_shower_audio_event != "": pass func stop_meteor_shower() -> void: meteor_shower_active = false _meteor_shower_elapsed = 0.0 meteor_shower_ended.emit() func trigger_shooting_star() -> void: if _is_night: _spawn_shooting_star() func set_shooting_star_appearance(color: Color, brightness: float, length: float) -> void: star_color = color star_brightness = brightness trail_length = length func set_meteor_appearance(color: Color, brightness: float, length: float) -> void: meteor_color = color meteor_brightness = brightness meteor_trail_length = length