diff --git a/game/assets/shaders/rhythm/bar_test.gdshader b/game/assets/shaders/rhythm/bar_test.gdshader new file mode 100644 index 0000000..4c2d86b --- /dev/null +++ b/game/assets/shaders/rhythm/bar_test.gdshader @@ -0,0 +1,18 @@ +shader_type spatial; +#include "uid://cy7b01or0ckgk" // Rhythm Helper + + +void vertex() { + // Called for every vertex the material is visible on. +} + +void fragment() { + float phase = get_bar_phase(); + ALPHA = (1.0 - phase) * (UV.y > phase ? 1.0 : 0.0) * (UV.x < 1.0 - get_song_time_ratio() ? 1.0 : 0.0); + ALBEDO.gb = vec2(distance(phase + 0.75, 0.75)); +} + +//void light() { +// // Called for every pixel for every light affecting the material. +// // Uncomment to replace the default light processing function with this one. +//} diff --git a/game/assets/shaders/rhythm/bar_test.gdshader.uid b/game/assets/shaders/rhythm/bar_test.gdshader.uid new file mode 100644 index 0000000..e40fe4e --- /dev/null +++ b/game/assets/shaders/rhythm/bar_test.gdshader.uid @@ -0,0 +1 @@ +uid://dlqjg6dby6pdy diff --git a/game/assets/shaders/rhythm/beat_test.gdshader b/game/assets/shaders/rhythm/beat_test.gdshader new file mode 100644 index 0000000..9289170 --- /dev/null +++ b/game/assets/shaders/rhythm/beat_test.gdshader @@ -0,0 +1,18 @@ +shader_type spatial; +#include "uid://cy7b01or0ckgk" // Rhythm Helper + + +void vertex() { + // Called for every vertex the material is visible on. +} + +void fragment() { + float phase = get_beat_phase(); + ALPHA = (1.0 - phase) * (UV.y > phase ? 1.0 : 0.0) * (-UV.x < -get_song_time_ratio() ? 1.0 : 0.0); + ALBEDO.gb = vec2(distance(phase + 0.5, 0.5)); +} + +//void light() { +// // Called for every pixel for every light affecting the material. +// // Uncomment to replace the default light processing function with this one. +//} diff --git a/game/assets/shaders/rhythm/beat_test.gdshader.uid b/game/assets/shaders/rhythm/beat_test.gdshader.uid new file mode 100644 index 0000000..259960a --- /dev/null +++ b/game/assets/shaders/rhythm/beat_test.gdshader.uid @@ -0,0 +1 @@ +uid://c7n5s8eodlca6 diff --git a/game/assets/shaders/rhythm/beating_lightbeam.gdshader b/game/assets/shaders/rhythm/beating_lightbeam.gdshader new file mode 100644 index 0000000..7079328 --- /dev/null +++ b/game/assets/shaders/rhythm/beating_lightbeam.gdshader @@ -0,0 +1,24 @@ +shader_type spatial; +render_mode unshaded, shadows_disabled, cull_disabled; +#include "uid://cy7b01or0ckgk" // Rhythm Helper + +uniform vec3 color: source_color = vec3(1.0, 1.0, 1.0); +uniform float alpha_multiplier: hint_range(0.0, 1.0, 0.001) = 0.25; +uniform sampler2D albedo; +uniform float flash_exponent = 3.0; + +void vertex() { + // Called for every vertex the material is visible on. +} + +void fragment() { + float phase = get_beat_phase(); + vec4 tex = texture(albedo, UV); + ALBEDO = mix(tex.rgb, color.rgb, 0.5); + ALPHA = tex.a * ease(1.0 - phase, flash_exponent) * alpha_multiplier;//mix(tex.a, color.a * (1.0 - phase), 0.5); +} + +//void light() { +// // Called for every pixel for every light affecting the material. +// // Uncomment to replace the default light processing function with this one. +//} diff --git a/game/assets/shaders/rhythm/beating_lightbeam.gdshader.uid b/game/assets/shaders/rhythm/beating_lightbeam.gdshader.uid new file mode 100644 index 0000000..b8a0f43 --- /dev/null +++ b/game/assets/shaders/rhythm/beating_lightbeam.gdshader.uid @@ -0,0 +1 @@ +uid://c32arbbdbo7m8 diff --git a/game/assets/shaders/rhythm/beating_surface.gdshader b/game/assets/shaders/rhythm/beating_surface.gdshader new file mode 100644 index 0000000..d809fd8 --- /dev/null +++ b/game/assets/shaders/rhythm/beating_surface.gdshader @@ -0,0 +1,23 @@ +shader_type spatial; +render_mode unshaded, shadows_disabled; +#include "uid://cy7b01or0ckgk" // Rhythm Helper + +uniform sampler2D albedo; +uniform float flash_exponent = 3.0; +uniform float intensity_multiplier = 1.0; + +void vertex() { + // Called for every vertex the material is visible on. +} + +void fragment() { + float phase = get_beat_phase(); + vec4 tex = texture(albedo, UV); + float intensity = ease((1.0 - phase), flash_exponent) * intensity_multiplier; + ALBEDO = tex.rgb * intensity; +} + +//void light() { +// // Called for every pixel for every light affecting the material. +// // Uncomment to replace the default light processing function with this one. +//} diff --git a/game/assets/shaders/rhythm/beating_surface.gdshader.uid b/game/assets/shaders/rhythm/beating_surface.gdshader.uid new file mode 100644 index 0000000..21f7f13 --- /dev/null +++ b/game/assets/shaders/rhythm/beating_surface.gdshader.uid @@ -0,0 +1 @@ +uid://b46o5a45g58xb diff --git a/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc b/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc new file mode 100644 index 0000000..6ed33a0 --- /dev/null +++ b/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc @@ -0,0 +1,106 @@ +global uniform float beat; +global uniform float bar; +global uniform float song_time; +global uniform float total_song_time; +global uniform float user_offset_ms; + +uniform float phase_offset = 0.0; +uniform float phase_multiplier = 1.0; + +instance uniform float instance_phase_offset = 0.0; +instance uniform float instance_phase_multiplier = 1.0; + + +float get_beat_with_offset() { + return (beat * phase_multiplier * instance_phase_multiplier) + phase_offset + instance_phase_offset;// + (user_offset_ms / 1000.0); +} + + +float get_bar_with_offset() { + return (bar * phase_multiplier * instance_phase_multiplier) + phase_offset + instance_phase_offset;// + (user_offset_ms / 1000.0); +} + + +float get_beat_phase() { + float beat_offset = get_beat_with_offset(); + return beat_offset - floor(beat_offset); +} + + +float get_bar_phase() { + float bar_offset = get_bar_with_offset(); + return bar_offset - floor(bar_offset); +} + +float get_song_time_ratio() { + return song_time / max(total_song_time, 0.001); +} + +float ease(float x, float c) { + // Godot's source code converted via ChatGPT to GLSL-style + //branchless: + //x = clamp(x, 0.0, 1.0); + //float pos = mix( + //1.0 - pow(1.0 - x, 1.0 / c), + //pow(x, c), + //step(1.0, c) + //); + // + //float k = -c; + //float x2 = x * 2.0; + //float inout_v = mix( + //pow(x2, k) * 0.5, + //(1.0 - pow(2.0 - x2, k)) * 0.5 + 0.5, + //step(0.5, x) + //); + // + //float isPos = step(0.0, c); + //float isNeg = step(c, 0.0); + // + //return pos * isPos + inout_v * isNeg; + + // a few branches: + x = clamp(x, 0.0, 1.0); + + float pos = mix( + 1.0 - pow(1.0 - x, 1.0 / c), // 0 < c < 1 + pow(x, c), // c >= 1 + step(1.0, c) + ); + + float k = -c; + float x2 = x * 2.0; + float inout_v = mix( + pow(x2, k) * 0.5, + (1.0 - pow(2.0 - x2, k)) * 0.5 + 0.5, + step(0.5, x) + ); + + return (c > 0.0) ? pos : + (c < 0.0) ? inout_v : + 0.0; + + // From godot's source code: + //if (x < 0.0) { + //x = 0.0; + //} else if (x > 1.0) { + //x = 1.0; + //} + //if (c > 0.0) { + //if (c < 1.0) { + //return 1.0 - pow(1.0 - x, 1.0 / c); + //} else { + //return pow(x, c); + //} + //} else if (c < 0.0) { + ////inout ease +// + //if (x < 0.5) { + //return pow(x * 2.0, -c) * 0.5; + //} else { + //return (1.0 - pow(1.0 - (x - 0.5) * 2.0, -c)) * 0.5 + 0.5; + //} + //} else { + //return 0.0; // no ease (raw) + //} +} \ No newline at end of file diff --git a/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc.uid b/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc.uid new file mode 100644 index 0000000..397977a --- /dev/null +++ b/game/assets/shaders/rhythm/rhythm_helper.gdshaderinc.uid @@ -0,0 +1 @@ +uid://cy7b01or0ckgk diff --git a/game/project.godot b/game/project.godot index d5cd3bc..f07873e 100644 --- a/game/project.godot +++ b/game/project.godot @@ -22,6 +22,7 @@ SPrint="*uid://cpfunq8fyixco" VersionDisplay="*uid://bqxtpo2c64h22" InputManager="*uid://cocp0vmvgd4ln" ControllerIcons="*uid://bdosbfkp568je" +ShaderGlobals="*uid://d2lr860r1ysrm" LimboConsole="*uid://dyxornv8vwibg" [debug] @@ -204,3 +205,26 @@ locale/translations=PackedStringArray("res://localization/localization.en.transl rendering_device/driver.windows="d3d12" anti_aliasing/quality/msaa_3d=3 occlusion_culling/use_occlusion_culling=true + +[shader_globals] + +song_time={ +"type": "float", +"value": 0.0 +} +total_song_time={ +"type": "float", +"value": 0.0 +} +beat={ +"type": "float", +"value": 0.0 +} +bar={ +"type": "float", +"value": 0.0 +} +user_offset_ms={ +"type": "float", +"value": 0.0 +} diff --git a/game/src/core/autoloads/shader_globals.gd b/game/src/core/autoloads/shader_globals.gd new file mode 100644 index 0000000..afba837 --- /dev/null +++ b/game/src/core/autoloads/shader_globals.gd @@ -0,0 +1,33 @@ +extends Node + + +var beat: float = 0.0: set = set_beat +var bar: float = 0.0: set = set_bar +var song_time: float = 0.0: set = set_song_time +var total_song_time: float = 0.0: set = set_total_song_time +var user_offset_ms: float = 0.0 + + +func set_user_offset_ms(offset: float) -> void: + user_offset_ms = offset + RenderingServer.global_shader_parameter_set(&"user_offset_ms", beat) + + +func set_beat(_beat: float) -> void: + beat = _beat + RenderingServer.global_shader_parameter_set(&"beat", beat) + + +func set_bar(_bar: float) -> void: + bar = _bar + RenderingServer.global_shader_parameter_set(&"bar", bar) + + +func set_song_time(time: float) -> void: + song_time = time + RenderingServer.global_shader_parameter_set(&"song_time", time) + + +func set_total_song_time(time: float) -> void: + total_song_time = time + RenderingServer.global_shader_parameter_set(&"total_song_time", time) diff --git a/game/src/core/autoloads/shader_globals.gd.uid b/game/src/core/autoloads/shader_globals.gd.uid new file mode 100644 index 0000000..3d7c89a --- /dev/null +++ b/game/src/core/autoloads/shader_globals.gd.uid @@ -0,0 +1 @@ +uid://d2lr860r1ysrm diff --git a/game/src/core/rhythm/rhythm_listener.gd b/game/src/core/rhythm/rhythm_listener.gd new file mode 100644 index 0000000..c1c4187 --- /dev/null +++ b/game/src/core/rhythm/rhythm_listener.gd @@ -0,0 +1,66 @@ +class_name RhythmListener +extends Node + + +signal beat_tick(beat_index: int) +signal bar_tick(bar_index: int) + +@export_range(0.0, 1.0, 0.001, "or_greater", "or_less") var phase_shift: float = 0.0 + +var beat: float = 0.0 +var bar: float = 0.0 +var _last_beat_time: float = 0.0 +var _last_beat_index: int = -1 +var _last_bar_index: int = -1 +var _last_bar_time: float = 0.0 + + +func _process(_delta: float) -> void: + if RhythmPlayer.paused: + _last_beat_index = 0 + _last_bar_index = 0 + return + + beat = RhythmPlayer.beat + phase_shift + bar = RhythmPlayer.bar + phase_shift + + var beat_index: int = floori(beat) + var bar_index: int = floori(bar) + + if _last_beat_index != beat_index: + on_beat_tick(beat_index) + _last_beat_index = beat_index + _last_beat_time = RhythmPlayer.song_time + phase_shift + + if _last_bar_index != bar_index: + on_bar_tick(bar_index) + _last_bar_index = bar_index + _last_bar_time = RhythmPlayer.song_time + phase_shift + + #SPrint.print_msgf("Time To Next Bar: %s" % get_time_to_next_bar()) + #SPrint.print_msgf("Time To Next Beat: %s" % get_time_to_next_beat()) + + +func get_time_to_next_beat() -> float: + var beat_duration: float = 60.0 / RhythmPlayer.bpm + return (_last_beat_time - (RhythmPlayer.song_time + phase_shift)) + beat_duration + + +func get_time_to_next_bar() -> float: + var bar_duration: float = 60.0 / (RhythmPlayer.bpm / RhythmPlayer.beats_per_bar) + return (_last_bar_time - (RhythmPlayer.song_time + phase_shift)) + bar_duration + + +#func get_time_to_beat(target_beat: float) -> float: + #var beat_duration: float = 60.0 / RhythmPlayer.bpm + # + + +func on_beat_tick(beat_index: int) -> void: + #SPrint.print_msg("Beat received %s" % beat_index, 0.75) + beat_tick.emit(beat_index) + + +func on_bar_tick(bar_index: int) -> void: + #SPrint.print_msg("Bar received %s" % bar_index, 1.0) + bar_tick.emit(bar_index) diff --git a/game/src/core/rhythm/rhythm_listener.gd.uid b/game/src/core/rhythm/rhythm_listener.gd.uid new file mode 100644 index 0000000..cac0f29 --- /dev/null +++ b/game/src/core/rhythm/rhythm_listener.gd.uid @@ -0,0 +1 @@ +uid://co7j2qtqpud6b diff --git a/game/src/core/rhythm/rhythm_player.gd b/game/src/core/rhythm/rhythm_player.gd new file mode 100644 index 0000000..add72b4 --- /dev/null +++ b/game/src/core/rhythm/rhythm_player.gd @@ -0,0 +1,168 @@ +class_name RhythmPlayer +extends Node + +signal beat_tick(beat_index: int) +signal bar_tick(bar_index: int) + +static var user_offset_ms: float = 0.0: set = set_user_offset_ms + +# Since only one rhythm player should play at any time, these are all static. +static var current_song_info: SongInfo +static var paused: bool = true +static var bpm: float = 120.0 +static var beats_per_bar: int = 4 + +static var song_time: float = 0.0 +static var beat: float = 0.0 +static var beat_phase: float = 0.0 +static var bar: float = 0.0 +static var bar_phase: float = 0.0 + +@export var song_info: SongInfo +@export var autoplay: bool = false +@export var playback_position: float = 0.0 + +@export_node_path("AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D") +var audio_player: NodePath: set = set_audio_player + +var _audio_player: Node +var _last_beat: int = -1 +var _last_bar: int = -1 + + +static func set_user_offset_ms(offset: float) -> void: + user_offset_ms = offset + ShaderGlobals.set_user_offset_ms(offset) + + +func _ready() -> void: + set_audio_player(audio_player) + + if autoplay: + play(playback_position) + + +func _process(_delta: float) -> void: + if not is_instance_valid(_audio_player): + return + + paused = not _audio_player.playing + + if not _audio_player.playing: + _last_beat = -1 + _last_bar = -1 + return + + _update_playback() + + +func get_song_time() -> float: + if not is_instance_valid(_audio_player): + return 0.0 + + var time: float = _audio_player.get_playback_position() + time += AudioServer.get_time_since_last_mix() + time -= AudioServer.get_output_latency() + return time + + +func get_song_time_with_user_offset() -> float: + return get_song_time() + user_offset_ms / 1000.0 + + +func get_beat() -> float: + return song_info.tempo_map.get_beat_at_time(get_song_time_with_user_offset() + song_info.phase_shift) + + +func get_bar_phase() -> float: + return bar - floorf(bar) + + +func get_bar() -> float: + return beat / beats_per_bar + + +## 0.0 -> beat start +## 0.5 -> halfway +## 0.99 -> almost next beat +func get_beat_phase() -> float: + return beat - floorf(beat) + + +func beat_to_signature() -> int: + return posmod(floori(beat), beats_per_bar) + 1 + + +static func bar_to_signature(bar_index: int, _bar_signature: int) -> int: + return bar_index + #return posmod(bar_index, bar_signature) + 1 + + +func set_audio_player(audio_player_path: NodePath) -> void: + audio_player = audio_player_path + _audio_player = get_node_or_null(audio_player) + + if not is_instance_valid(_audio_player): + set_process(false) + return + + if ( + not _audio_player.has_method(&"play") + or not _audio_player.has_method(&"get_playback_position") + or not &"playing" in _audio_player + ): + _audio_player = null + push_error("audio_player is not a valid player.") + return + + #set_process(_audio_player.playing) + set_process(true) + + +func play(position: float = 0.0) -> void: + current_song_info = song_info + _audio_player.stream = song_info.audio_stream + ShaderGlobals.set_total_song_time(song_info.audio_stream.get_length()) + + bpm = song_info.bpm + beats_per_bar = song_info.beats_per_bar + + song_info.create_tempo_map() + _audio_player.play(position) + + +func stop() -> void: + _audio_player.stop() + + +func _update_playback() -> void: + beat = get_beat() + beat_phase = get_beat_phase() + + bar = get_bar() + bar_phase = get_bar_phase() + + song_time = get_song_time_with_user_offset() + song_info.phase_shift + + ShaderGlobals.set_song_time(song_time) + + var tempo_section: TempoMap.TempoSection = song_info.tempo_map.get_tempo_section_at_time(song_time) + bpm = tempo_section.bpm + beats_per_bar = tempo_section.beats_per_bar + + ShaderGlobals.set_beat(beat) + ShaderGlobals.set_bar(bar) + + var beat_index: int = floori(beat) + var bar_index: int = floori(bar) + SPrint.print_msgf("Beat: %s (%s) on beat: %s" % [beat_index, beat_to_signature(), beat_phase <= (1.0 / beats_per_bar)]) + SPrint.print_msgf("Bar: %s (%s)" % [bar_to_signature(bar_index, beats_per_bar), str(bar_phase).pad_decimals(4)]) + #SPrint.print_msgf("Bar: %s (%s) bar on beat: %s" % [bar_to_signature(bar_index, _bars), bar_index, bar_index % _bars == 0]) + + if _last_beat != beat_index: + beat_tick.emit(beat_index) + _last_beat = beat_index + + if _last_bar != bar_index: + bar_tick.emit(bar_index) + _last_bar = bar_index diff --git a/game/src/core/rhythm/rhythm_player.gd.uid b/game/src/core/rhythm/rhythm_player.gd.uid new file mode 100644 index 0000000..3e73be8 --- /dev/null +++ b/game/src/core/rhythm/rhythm_player.gd.uid @@ -0,0 +1 @@ +uid://bdi06itcm6wfp diff --git a/game/src/core/rhythm/rhythm_property_setter.gd b/game/src/core/rhythm/rhythm_property_setter.gd new file mode 100644 index 0000000..66bd9a5 --- /dev/null +++ b/game/src/core/rhythm/rhythm_property_setter.gd @@ -0,0 +1,59 @@ +class_name RhythmPropertySetter +extends Node + + +@export var nodes: Array[Node] = [] +@export var beat_nodes: Array[Node] = [] +@export var bar_nodes: Array[Node] = [] +@export var beat_property: StringName = &"" +@export var bar_property: StringName = &"" + +@export var phase_shift: float = 0.0 +@export var phase_multiplier: float = 1.0 + +@export_group("Beat Value", "beat_") +@export var beat_range_min: float = 0.0 +@export var beat_range_max: float = 1.0 +@export_exp_easing("attenuation") var beat_exponent: float = 1.0 + +@export_group("Bar Value", "bar_") +@export var bar_range_min: float = 0.0 +@export var bar_range_max: float = 1.0 +@export_exp_easing("attenuation") var bar_exponent: float = 1.0 + + +func _process(_delta: float) -> void: + if RhythmPlayer.paused: + return + + if not beat_property.is_empty(): + _update_beat_property() + + if not bar_property.is_empty(): + _update_bar_property() + + +func _update_beat_property() -> void: + var beat: float = RhythmPlayer.beat * phase_multiplier + phase_shift + var beat_phase: float = beat - floorf(beat) + var beat_value: float = remap(beat_phase, 0.0, 1.0, beat_range_min, beat_range_max) + beat_value *= ease(1.0 - beat_phase, beat_exponent) + + var _nodes: Array[Node] = beat_nodes.duplicate() + _nodes.append_array(nodes) + + for node: Node in _nodes: + node.set_indexed(NodePath(beat_property), beat_value) + + +func _update_bar_property() -> void: + var bar: float = RhythmPlayer.bar * phase_multiplier + phase_shift + var bar_phase: float = bar - floorf(bar) + var bar_value: float = remap(bar_phase, 0.0, 1.0, bar_range_min, bar_range_max) + bar_value *= ease(1.0 - bar_phase, bar_exponent) + + var _nodes: Array[Node] = bar_nodes.duplicate() + _nodes.append_array(nodes) + + for node: Node in _nodes: + node.set_indexed(NodePath(bar_property), bar_value) diff --git a/game/src/core/rhythm/rhythm_property_setter.gd.uid b/game/src/core/rhythm/rhythm_property_setter.gd.uid new file mode 100644 index 0000000..1479ba1 --- /dev/null +++ b/game/src/core/rhythm/rhythm_property_setter.gd.uid @@ -0,0 +1 @@ +uid://bvmfdeypbeqsn diff --git a/game/src/core/rhythm/song_info.gd b/game/src/core/rhythm/song_info.gd new file mode 100644 index 0000000..968a72d --- /dev/null +++ b/game/src/core/rhythm/song_info.gd @@ -0,0 +1,41 @@ +class_name SongInfo +extends Resource + + +#@export_file("*.tres", "*.res", "*.ogg", "*.wav", "*.mp3") +#var audio_path: String +@export var audio_stream: AudioStream +@export var bpm: float = 120 +@export_range(0, 8, 1, "or_greater") var beats_per_bar: int = 4 +@export_range(0.0, 1.0, 0.001, "or_greater", "or_less") var phase_shift: float = 0.0 +#@export_enum("1/2", "1/4", "1/8", "1/16", "1/32", "1/64") var time_signature: int = 1 + +@export_group("Tempo Sections") +@export var tempo_infos: Array[TempoSectionInfo] = [] +@export var tempo_sections_in_seconds: Dictionary[float, float] +@export_subgroup("Tempo Sections Simple") +@export var tempo_section_round_to_bar: bool = true +@export var tempo_section_round_to_beat: bool = false + +var tempo_map: TempoMap + + +func _init() -> void: + create_tempo_map() + + +func create_tempo_map() -> void: + if tempo_infos.is_empty(): + tempo_map = TempoMap.create_from_time_dictionary( + tempo_sections_in_seconds, + bpm, + tempo_section_round_to_bar, + tempo_section_round_to_beat, + beats_per_bar + ) + else: + tempo_map = TempoMap.create_from_section_infos(tempo_infos, bpm, beats_per_bar) + + +#func get_time_signature() -> int: + #return int(pow(2, time_signature + 1)) diff --git a/game/src/core/rhythm/song_info.gd.uid b/game/src/core/rhythm/song_info.gd.uid new file mode 100644 index 0000000..a44e467 --- /dev/null +++ b/game/src/core/rhythm/song_info.gd.uid @@ -0,0 +1 @@ +uid://c5mqtmsvgt4e8 diff --git a/game/src/core/rhythm/tempo_map.gd b/game/src/core/rhythm/tempo_map.gd new file mode 100644 index 0000000..6473262 --- /dev/null +++ b/game/src/core/rhythm/tempo_map.gd @@ -0,0 +1,194 @@ +class_name TempoMap +extends Resource + +@export var time_sections: Dictionary[float, float] = {} +@export var section_infos: Array[TempoSectionInfo] = [] + +var sections: Array[TempoSection] = [] + + +static func create_from_beat_dictionary( + dictionary: Dictionary[float, float], + fallback_bpm: float +) -> TempoMap: + var map := TempoMap.new() + + if dictionary.is_empty(): + map.add_section(0.0, fallback_bpm) + return map + + var keys: Array[float] = dictionary.keys() + keys.sort() + + for key: float in keys: + map.add_section(key, dictionary.get(key)) + + map.recalculate_times() + return map + + +static func create_from_time_dictionary( + dictionary: Dictionary[float, float], + fallback_bpm: float, + round_to_bars: bool = true, + round_to_whole_beats: bool = false, + beats_per_bar: int = 4 +) -> TempoMap: + var map := TempoMap.new() + + if dictionary.is_empty(): + map.add_section(0.0, fallback_bpm, beats_per_bar) + return map + + var times: Array[float] = dictionary.keys() + times.sort() + + var current_beat: float = 0.0 + var previous_time: float = 0.0 + var previous_bpm: float = fallback_bpm + + map.add_section(0.0, fallback_bpm) + + for time: float in times: + var delta: float = time - previous_time + current_beat += delta * previous_bpm / 60.0 + + var bpm: float = dictionary[time] + + if round_to_bars: + current_beat = roundf(current_beat / beats_per_bar) * beats_per_bar + if round_to_whole_beats: + current_beat = roundf(current_beat) + + map.add_section(current_beat, bpm) + + previous_time = time + previous_bpm = bpm + + map.recalculate_times() + return map + + +static func create_from_section_infos(infos: Array[TempoSectionInfo], fallback_bpm: float, beats_per_bar_fallback: int) -> TempoMap: + var map := TempoMap.new() + + if infos.is_empty(): + map.add_section(0.0, fallback_bpm, beats_per_bar_fallback) + return map + + var times: Dictionary[float, TempoSectionInfo] = {} + + for info: TempoSectionInfo in infos: + times.set(info.time, info) + + times.sort() + + var current_beat: float = 0.0 + var previous_time: float = 0.0 + var previous_bpm: float = fallback_bpm + var previous_beats_per_bar: int = beats_per_bar_fallback + + map.add_section(0.0, fallback_bpm) + + for time: float in times.keys(): + var delta: float = time - previous_time + current_beat += delta * previous_bpm / 60.0 + + var info: TempoSectionInfo = times[time] + var bpm: float = info.bpm + var beats_per_bar: int = info.beats_per_bar + + if info.snap_to_previous_beat: + current_beat = roundf(current_beat) + if info.snap_to_previous_bar: + current_beat = roundf(current_beat / previous_beats_per_bar) * previous_beats_per_bar + + map.add_section(current_beat, bpm, beats_per_bar) + + previous_time = time + previous_bpm = bpm + previous_beats_per_bar = beats_per_bar + + map.recalculate_times() + return map + + +func add_section(beat: float, bpm: float, beats_per_bar: int = 4) -> void: + if not sections.is_empty() and is_equal_approx(sections.back().beat, beat): + sections.back().bpm = bpm + sections.back().beats_per_bar = beats_per_bar + else: + sections.append(TempoSection.new(bpm, beat, beats_per_bar)) + + +func recalculate_times() -> void: + if sections.is_empty(): + return + + sections.front().time = 0.0 + print("[TempoMap] Calculating tempo changes:") + + for index: int in range(1, sections.size()): + var previous: TempoSection = sections[index - 1] + var current: TempoSection = sections[index] + + var beat_delta: float = current.beat - previous.beat + var seconds: float = beat_delta * 60.0 / previous.bpm + + current.time = previous.time + seconds + print_rich("- [b]%s[/b] [i]bpm[/i] ([b]1/%s[/b]) at [b]%s[/b] [i]seconds[/i] (beat delta: [b]%s[/b], seconds delta: [b]%s[/b])." % [current.bpm, current.beats_per_bar, current.time, beat_delta, seconds]) + + +func get_beat_at_time(time: float) -> float: + if sections.is_empty(): + return 0.0 + + # O(log n) (Binary search) + var low: int = 0 + var high: int = sections.size() - 1 + + while low <= high: + @warning_ignore("integer_division") + var mid: int = (low + high) / 2 + + if sections[mid].time <= time: + low = mid + 1 + else: + high = mid - 1 + + var section: TempoSection = sections[maxi(high, 0)] + SPrint.print_msgf("Section: %s" % section.bpm) + return section.beat + (time - section.time) * section.bpm / 60.0 + + +func get_tempo_section_at_time(time: float) -> TempoSection: + if sections.is_empty(): + return null + + # O(log n) (Binary search) + var low: int = 0 + var high: int = sections.size() - 1 + + while low <= high: + @warning_ignore("integer_division") + var mid: int = (low + high) / 2 + + if sections[mid].time <= time: + low = mid + 1 + else: + high = mid - 1 + + var section: TempoSection = sections[maxi(high, 0)] + return section + + +class TempoSection extends Resource: + var beat: float + var beats_per_bar: int + var bpm: float + var time: float + + func _init(_bpm: float, _beat: float, _beats_per_bar: int = 4) -> void: + bpm = _bpm + beat = _beat + beats_per_bar = _beats_per_bar diff --git a/game/src/core/rhythm/tempo_map.gd.uid b/game/src/core/rhythm/tempo_map.gd.uid new file mode 100644 index 0000000..9c930d2 --- /dev/null +++ b/game/src/core/rhythm/tempo_map.gd.uid @@ -0,0 +1 @@ +uid://b7vjbaiu1nst diff --git a/game/src/core/rhythm/tempo_section_info.gd b/game/src/core/rhythm/tempo_section_info.gd new file mode 100644 index 0000000..19f3498 --- /dev/null +++ b/game/src/core/rhythm/tempo_section_info.gd @@ -0,0 +1,9 @@ +class_name TempoSectionInfo +extends Resource + +@export var time: float = 0.0 +@export var bpm: float = 120.0 +@export var beats_per_bar: int = 4 + +@export var snap_to_previous_beat: bool = true +@export var snap_to_previous_bar: bool = false diff --git a/game/src/core/rhythm/tempo_section_info.gd.uid b/game/src/core/rhythm/tempo_section_info.gd.uid new file mode 100644 index 0000000..2608a6e --- /dev/null +++ b/game/src/core/rhythm/tempo_section_info.gd.uid @@ -0,0 +1 @@ +uid://cx1nws4t8hs2u diff --git a/game/src/gameplay/objects/stagelight/stagelight.gd b/game/src/gameplay/objects/stagelight/stagelight.gd new file mode 100644 index 0000000..ad706b7 --- /dev/null +++ b/game/src/gameplay/objects/stagelight/stagelight.gd @@ -0,0 +1,65 @@ +@tool +class_name Stagelight +extends Node3D + +@export var light_color := Color.WHITE: set = set_light_color +@export var lightbeam_material: ShaderMaterial: set = set_lightbeam_material +@export var light_surface_material: ShaderMaterial: set = set_light_surface_material +@export var phase_multiplier: float = 0.5: set = set_phase_multiplier +@export var phase_offset: float = 0.0: set = set_phase_offset + +@onready var spot_light: SpotLight3D = %SpotLight +@onready var rhythm_property_setter: RhythmPropertySetter = %RhythmPropertySetter +@onready var lightbeam: MeshInstance3D = %Lightbeam +@onready var cylinder: MeshInstance3D = $StudioSpotLight/Cylinder + + +func _ready() -> void: + set_light_color(light_color) + set_lightbeam_material(lightbeam_material) + set_light_surface_material(light_surface_material) + + +func set_light_color(color: Color) -> void: + light_color = color + + if not is_node_ready(): + await ready + + spot_light.light_color = light_color + + +func set_lightbeam_material(shader_material: ShaderMaterial) -> void: + lightbeam_material = shader_material + + if not is_node_ready(): + await ready + + lightbeam.material_override = lightbeam_material + + +func set_light_surface_material(shader_material: ShaderMaterial) -> void: + light_surface_material = shader_material + + if not is_node_ready(): + await ready + + cylinder.set_surface_override_material(1, light_surface_material) + + +func set_phase_multiplier(multiplier: float) -> void: + phase_multiplier = multiplier + + if not is_node_ready(): + await ready + + rhythm_property_setter.phase_multiplier = phase_multiplier + + +func set_phase_offset(offset: float) -> void: + phase_offset = offset + + if not is_node_ready(): + await ready + + rhythm_property_setter.phase_shift = phase_offset diff --git a/game/src/gameplay/objects/stagelight/stagelight.gd.uid b/game/src/gameplay/objects/stagelight/stagelight.gd.uid new file mode 100644 index 0000000..5f7f5e9 --- /dev/null +++ b/game/src/gameplay/objects/stagelight/stagelight.gd.uid @@ -0,0 +1 @@ +uid://cmh1y78o47f06 diff --git a/game/src/gameplay/objects/stagelight/stagelight.tscn b/game/src/gameplay/objects/stagelight/stagelight.tscn new file mode 100644 index 0000000..21553ca --- /dev/null +++ b/game/src/gameplay/objects/stagelight/stagelight.tscn @@ -0,0 +1,98 @@ +[gd_scene format=3 uid="uid://bc84qk6pfgctg"] + +[ext_resource type="PackedScene" uid="uid://pvll6bliout1" path="res://assets/models/electrics/spot_light/studio_spot_light.glb" id="1_3cilv"] +[ext_resource type="Script" uid="uid://cmh1y78o47f06" path="res://src/gameplay/objects/stagelight/stagelight.gd" id="1_wce40"] +[ext_resource type="Shader" uid="uid://b46o5a45g58xb" path="res://assets/shaders/rhythm/beating_surface.gdshader" id="2_wce40"] +[ext_resource type="PackedScene" uid="uid://bwejp67rw5erj" path="res://assets/models/electrics/spot_light/studio_spot_light_framing_01.glb" id="3_otype"] +[ext_resource type="Shader" uid="uid://c32arbbdbo7m8" path="res://assets/shaders/rhythm/beating_lightbeam.gdshader" id="4_d6ag8"] +[ext_resource type="Script" uid="uid://bvmfdeypbeqsn" path="res://src/core/rhythm/rhythm_property_setter.gd" id="5_uce1x"] + +[sub_resource type="Gradient" id="Gradient_wj31t"] +interpolation_mode = 2 +offsets = PackedFloat32Array(0, 0.38108107, 0.5054054, 0.7162162) +colors = PackedColorArray(1, 1, 1, 1, 1, 1, 1, 0.44602686, 1, 1, 1, 0.08750595, 1, 1, 1, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_du0pf"] +gradient = SubResource("Gradient_wj31t") +width = 1 +fill_to = Vector2(0, 0.48931623) + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_xn83m"] +render_priority = 0 +shader = ExtResource("4_d6ag8") +shader_parameter/phase_offset = 0.0 +shader_parameter/phase_multiplier = 0.5 +shader_parameter/color = Color(1, 1, 1, 1) +shader_parameter/alpha_multiplier = 0.25 +shader_parameter/albedo = SubResource("GradientTexture2D_du0pf") +shader_parameter/flash_exponent = 4.0 + +[sub_resource type="Gradient" id="Gradient_vtuqv"] +interpolation_mode = 1 +offsets = PackedFloat32Array(1) +colors = PackedColorArray(1, 1, 1, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_glp4g"] +gradient = SubResource("Gradient_vtuqv") +width = 1 +height = 1 + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_nqu5b"] +render_priority = 0 +shader = ExtResource("2_wce40") +shader_parameter/phase_offset = 0.0 +shader_parameter/phase_multiplier = 0.5 +shader_parameter/albedo = SubResource("GradientTexture2D_glp4g") +shader_parameter/flash_exponent = 4.0 +shader_parameter/intensity_multiplier = 8.0 + +[sub_resource type="CylinderMesh" id="CylinderMesh_787dp"] +top_radius = 0.275 +bottom_radius = 2.0 +height = 12.0 +cap_top = false +cap_bottom = false + +[node name="Stagelight" type="Node3D" unique_id=729680764] +script = ExtResource("1_wce40") +lightbeam_material = SubResource("ShaderMaterial_xn83m") +light_surface_material = SubResource("ShaderMaterial_nqu5b") + +[node name="StudioSpotLight" parent="." unique_id=1407009402 instance=ExtResource("1_3cilv")] + +[node name="Cylinder" parent="StudioSpotLight" index="0" unique_id=1873656815] +surface_material_override/1 = SubResource("ShaderMaterial_nqu5b") + +[node name="StudioSpotLightFraming01" parent="." unique_id=166030914 instance=ExtResource("3_otype")] + +[node name="LightbeamRoot" type="Node3D" parent="." unique_id=1922157293] +transform = Transform3D(-4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0, 1, 0.2, 8.742278e-09, 0) + +[node name="Lightbeam" type="MeshInstance3D" parent="LightbeamRoot" unique_id=1840014799] +unique_name_in_owner = true +transform = Transform3D(0.5, 0, 0, 0, 0.49999994, 0, 0, 0, 0.5, 0, -2.9999995, 0) +material_override = SubResource("ShaderMaterial_xn83m") +cast_shadow = 0 +instance_shader_parameters/instance_phase_offset = 0.0 +mesh = SubResource("CylinderMesh_787dp") + +[node name="SpotLight" type="SpotLight3D" parent="LightbeamRoot" unique_id=1841045966] +unique_name_in_owner = true +transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 0.8, 0) +spot_range = 50.0 +spot_attenuation = 1.38 +spot_angle = 8.75 +spot_angle_attenuation = 0.23325838 + +[node name="RhythmPropertySetter" type="Node" parent="LightbeamRoot" unique_id=1355223878 node_paths=PackedStringArray("nodes")] +unique_name_in_owner = true +script = ExtResource("5_uce1x") +nodes = [NodePath("../SpotLight")] +beat_property = &"light_energy" +phase_multiplier = 0.5 +beat_range_min = 10.0 +beat_range_max = 0.0 +beat_exponent = 4.0 +metadata/_custom_type_script = "uid://bvmfdeypbeqsn" + +[editable path="StudioSpotLight"]