From 703edb9656609ae4ea8ae9ffd1e496d591040ad0 Mon Sep 17 00:00:00 2001 From: Luke Hubmayer-Werner Date: Sun, 29 Mar 2020 17:43:28 +1030 Subject: [PATCH] Refactored Library scan, Note construction. Preparing for StepMania charts. --- FileLoader.gd | 218 ++++++++++++++++++++++++++++++++++++++++++++++++ InputHandler.gd | 4 + Menu.gd | 39 ++------- Note.gd | 65 ++++++++++++--- NoteHandler.gd | 25 +++--- main.tscn | 15 ++++ 6 files changed, 311 insertions(+), 55 deletions(-) diff --git a/FileLoader.gd b/FileLoader.gd index 5ba0adc..7de9b7b 100644 --- a/FileLoader.gd +++ b/FileLoader.gd @@ -1,6 +1,70 @@ #extends Object extends Node +var userroot := "user://" if OS.get_name() != "Android" else "/storage/emulated/0/RhythmGame/" +# The following would probably work. One huge caveat is that permission needs to be manually granted by the user in app settings as we can't use OS.request_permission("WRITE_EXTERNAL_STORAGE") +# "/storage/emulated/0/Android/data/au.ufeff.rhythmgame/" +# "/sdcard/Android/data/au.ufeff.rhythmgame/" + +func directory_list(directory: String, hidden: bool) -> Dictionary: + # Sadly there's no filelist sugar so we make our own + var output = {folders=[], files=[], err=OK} + var dir = Directory.new() + output.err = dir.open(directory) + if output.err != OK: + print_debug('Failed to open directory: ' + directory + '(Error code '+output.err+')') + return output + output.err = dir.list_dir_begin(true, !hidden) + if output.err != OK: + print_debug('Failed to begin listing directory: ' + directory + '(Error code '+output.err+')') + return output + + var item = dir.get_next() + while (item != ''): + if dir.current_is_dir(): + output['folders'].append(item) + else: + output['files'].append(item) + item = dir.get_next() + dir.list_dir_end() + return output + +func scan_library(): + print("Scanning library") + var rootdir = userroot + "songs" + var dir = Directory.new() + var err = dir.make_dir_recursive(rootdir) + if err != OK: + print_debug("An error occurred while trying to create the songs directory: ", err) + return err + + var songslist = directory_list(rootdir, false) + if songslist.err != OK: + print("An error occurred when trying to access the songs directory: ", songslist.err) + return songslist.err + + var song_defs = {} + var song_images = {} + var genres = {} + dir.open(rootdir) + for key in songslist.folders: + if dir.file_exists(key + "/song.json"): + song_defs[key] = FileLoader.load_folder("%s/%s" % [rootdir, key]) + print("Loaded song directory: %s" % key) + song_images[key] = FileLoader.load_image("%s/%s/%s" % [rootdir, key, song_defs[key]["tile_filename"]]) + if song_defs[key]["genre"] in genres: + genres[song_defs[key]["genre"]].append(key) + else: + genres[song_defs[key]["genre"]] = [key] + else: + print("Found non-song directory: " + key) + for file in songslist.files: + print("Found file: " + file) + + return {song_defs=song_defs, song_images=song_images, genres=genres} + + + class SRT: const TAP_DURATION := 0.062500 const ID_BREAK := 4 @@ -65,6 +129,160 @@ class SRB: pass +class SM: + # Stepmania simfile + + const NOTE_VALUES = { + '0': 'None', + '1': 'Tap', + '2': 'HoldStart', + '3': 'HoldRollEnd', + '4': 'RollStart', + 'M': 'Mine', + # These three are less likely to show up anywhere, no need to implement + 'K': 'Keysound', + 'L': 'Lift', + 'F': 'Fake', + } + + const CHART_DIFFICULTIES = { + 'Beginner': 0, + 'Easy': 1, + 'Medium': 2, + 'Hard': 3, + 'Challenge': 4, + 'Edit': 5, + # Some will just write whatever for special difficulties, but we should at least color-code these standard ones + } + + const TAG_TRANSLATIONS = { + '#TITLE': 'title', + '#SUBTITLE': 'subtitle', + '#ARTIST': 'artist', + '#TITLETRANSLIT': 'title_transliteration', + '#SUBTITLETRANSLIT': 'subtitle_transliteration', + '#ARTISTTRANSLIT': 'artist_transliteration', + '#GENRE': 'genre', + '#CREDIT': 'chart_author', + '#BANNER': 'image_banner', + '#BACKGROUND': 'image_background', + '#LYRICSPATH': '', + '#CDTITLE': 'image_cd_title', + '#MUSIC': 'audio_filelist', + '#OFFSET': 'audio_offsets', + '#SAMPLESTART': 'audio_preview_times', + '#SAMPLELENGTH': 'audio_preview_times', + '#SELECTABLE': '', + '#BPMS': 'bpm_values', + '#STOPS': '', + '#BGCHANGES': '', + '#KEYSOUNDS': '', + } + + static func load_chart(lines): + var metadata = {} + var notes = [] + + assert(lines[0].begins_with('#NOTES:')) + metadata['chart_type'] = lines[1].strip_edges().rstrip(':') + metadata['description'] = lines[2].strip_edges().rstrip(':') + metadata['difficulty_str'] = lines[3].strip_edges().rstrip(':') + metadata['numerical_meter'] = lines[4].strip_edges().rstrip(':') + metadata['groove_radar'] = lines[5].strip_edges().rstrip(':') + + # Measures are separated by lines that start with a comma + # Each line has a state for each of the pads, e.g. '0000' for none pressed + # The lines become even subdivisions of the measure, so if there's 4 lines everything represents a 1/4 beat, if there's 8 lines everything represents a 1/8 beat etc. + # For this reason it's probably best to just have a float for beat-within-measure rather than integer beats. + var measures = [[]] + for i in range(6, len(lines)): + var line = lines[i].strip_edges() + if line.begins_with(','): + measures.append([]) + elif line.begins_with(';'): + break + elif len(line) > 0: + measures[-1].append(line) + + var ongoing_holds = {} + var num_notes := 0 + var num_jumps := 0 + var num_hands := 0 + var num_holds := 0 + var num_rolls := 0 + var num_mines := 0 + + for measure in range(len(measures)): + var m_lines = measures[measure] + var m_length = len(m_lines) # Divide out all lines by this + for beat in m_length: + var line : String = m_lines[beat] + # Jump check at a line-level (check for multiple 1/2/4s) + var hits : int = line.count('1') + line.count('2') + line.count('4') + # Hand/quad check more complex as need to check hold/roll state as well + # TODO: are they exclusive? Does quad override hand override jump? SM5 doesn't have quads and has hands+jumps inclusive + var total_pressed : int = hits + len(ongoing_holds) + var jump : bool = hits >= 2 + var hand : bool = total_pressed >= 3 + # var quad : bool = total_pressed >= 4 + num_notes += hits + num_jumps += int(jump) + num_hands += int(hand) + var time = measure + beat/float(m_length) + for col in len(line): + match line[col]: + '1': + notes.append(Note.make_tap(time, col)) + '2': # Hold + ongoing_holds[col] = len(notes) + notes.append(Note.make_tap(time, col)) + '4': # Roll + ongoing_holds[col] = len(notes) + notes.append(Note.make_tap(time, col)) + '3': # End Hold/Roll + assert(ongoing_holds.has(col)) + notes[ongoing_holds[col]].set_time_release(time) + 'M': # Mine + num_mines += 1 + pass + metadata['num_notes'] = num_notes + metadata['num_jumps'] = num_jumps + metadata['num_hands'] = num_hands + metadata['num_holds'] = num_holds + metadata['num_rolls'] = num_rolls + metadata['num_mines'] = num_mines + return [metadata, notes] + + static func load_file(filename): + # Technically, declarations end with a semicolon instead of a linebreak. + # This is a PITA to do correctly in GDScript and the files in our collection are well-behaved with linebreaks anyway, so we won't bother. + var file := File.new() + var err := file.open(filename, File.READ) + if err != OK: + print(err) + return err + var length = file.get_len() + var lines = [[]] # First list will be header, then every subsequent one is a chart + while (file.get_position() < (length-1)): # Could probably replace this with file.eof_reached() + var line : String = file.read_line() + if line.begins_with('#NOTES'): # Split to a new list for each chart definition + lines.append([]) + lines[-1].append(line) + file.close() + + var metadata = {} + for line in lines[0]: + var tokens = line.rstrip(';').split(':') + if TAG_TRANSLATIONS.has(tokens[0]): + metadata[TAG_TRANSLATIONS[tokens[0]]] = tokens[1] + var charts = [] + + for i in range(1, len(lines)): + charts.append(load_chart(lines[i])) + + return charts + + class Test: static func stress_pattern(): var notes = [] diff --git a/InputHandler.gd b/InputHandler.gd index 4d0967e..fa397aa 100644 --- a/InputHandler.gd +++ b/InputHandler.gd @@ -35,11 +35,15 @@ func _ready(): # connect("button_pressed", self, "print_pressed") $"/root".connect("size_changed", self, "resize") $VsyncButton.connect("toggled", self, "update_vsync") + $FilterSlider.connect("value_changed", self, "update_filter") resize() func update_vsync(setting: bool): OS.vsync_enabled = setting +func update_filter(alpha: float): + GameTheme.screen_filter_min_alpha = alpha + func print_pressed(col: int): print("Pressed %d"%col) diff --git a/Menu.gd b/Menu.gd index b32ccc7..c6a5a64 100644 --- a/Menu.gd +++ b/Menu.gd @@ -33,41 +33,14 @@ var GenreFont := preload("res://assets/MenuGenreFont.tres") var ScoreFont := preload("res://assets/MenuScoreFont.tres") var snd_interact := preload("res://assets/softclap.wav") -var userroot := "user://" if OS.get_name() != "Android" else "/storage/emulated/0/RhythmGame/" -# The following would probably work. One huge caveat is that permission needs to be manually granted by the user in app settings as we can't use OS.request_permission("WRITE_EXTERNAL_STORAGE") -# "/storage/emulated/0/Android/data/au.ufeff.rhythmgame/" -# "/sdcard/Android/data/au.ufeff.rhythmgame/" +var userroot : String = FileLoader.userroot func scan_library(): - print("Scanning library") - var rootdir = userroot + "songs" - var dir = Directory.new() - var err = dir.make_dir_recursive(rootdir) - if err != OK: - print("An error occurred while trying to create the songs directory: ", err) - return err - err = dir.open(rootdir) - if err == OK: - dir.list_dir_begin(true, true) - var key = dir.get_next() - while (key != ""): - if dir.current_is_dir(): - if dir.file_exists(key + "/song.json"): - song_defs[key] = FileLoader.load_folder("%s/%s" % [rootdir, key]) - print("Loaded song directory: %s" % key) - song_images[key] = FileLoader.load_image("%s/%s/%s" % [rootdir, key, song_defs[key]["tile_filename"]]) - if song_defs[key]["genre"] in genres: - genres[song_defs[key]["genre"]].append(key) - else: - genres[song_defs[key]["genre"]] = [key] - else: - print("Found non-song directory: " + key) - else: - print("Found file: " + key) - key = dir.get_next() - dir.list_dir_end() - else: - print("An error occurred when trying to access the songs directory: ", err) + var results = FileLoader.scan_library() + song_defs = results.song_defs + song_images = results.song_images + genres = results.genres + func save_score(): var rootdir = userroot + "scores" diff --git a/Note.gd b/Note.gd index db5f9bc..5e2e32c 100644 --- a/Note.gd +++ b/Note.gd @@ -3,44 +3,70 @@ extends Node #class_name Note -enum {NOTE_TAP, NOTE_HOLD, NOTE_SLIDE, NOTE_ARROW, NOTE_TOUCH, NOTE_TOUCH_HOLD} +enum {NOTE_TAP, NOTE_HOLD, NOTE_SLIDE, NOTE_ARROW, NOTE_TOUCH, NOTE_TOUCH_HOLD, NOTE_ROLL} enum SlideType {CHORD, ARC_CW, ARC_ACW} const DEATH_DELAY := 1.0 # This is touchy with the judgement windows and variable bpm. -const RELEASE_SCORE_TYPES := [NOTE_HOLD, NOTE_SLIDE, NOTE_TOUCH_HOLD] +const RELEASE_SCORE_TYPES := [NOTE_HOLD, NOTE_SLIDE, NOTE_TOUCH_HOLD, NOTE_ROLL] +const NOTE_TAP1 = 0 class NoteBase: - var time_hit: float + var time_hit: float setget set_time_hit var time_death: float var column: int var double_hit := false var time_activated := INF var missed := false + func set_time_hit(value: float): + time_hit = value + time_death = time_hit + DEATH_DELAY + class NoteTap extends NoteBase: var type := NOTE_TAP func _init(time_hit: float, column: int): self.time_hit = time_hit - self.time_death = time_hit + DEATH_DELAY self.column = column -class NoteHold extends NoteBase: - var type := NOTE_HOLD - var time_release: float +class NoteHoldBase extends NoteBase: + var time_release: float setget set_time_release var time_released := INF - var duration: float + var duration: float setget set_duration var is_held: bool func _init(time_hit: float, duration: float, column: int): self.time_hit = time_hit self.duration = duration - self.time_release = time_hit + duration - self.time_death = time_release + DEATH_DELAY self.column = column self.is_held = false + func set_time_hit(value: float): + time_hit = value + time_release = time_hit + duration + time_death = time_release + DEATH_DELAY + + func set_time_release(value: float): + time_release = value + time_death = time_release + DEATH_DELAY + duration = time_release - time_hit + + func set_duration(value: float): + duration = value + time_release = time_hit + duration + time_death = time_release + DEATH_DELAY + +class NoteHold extends NoteHoldBase: + var type := NOTE_HOLD + func _init(time_hit: float, duration: float, column: int).(time_hit, duration, column): + pass + +class NoteRoll extends NoteHoldBase: + var type := NOTE_ROLL + func _init(time_hit: float, duration: float, column: int).(time_hit, duration, column): + pass + class NoteSlide extends NoteBase: var type := NOTE_SLIDE - var time_release: float - var duration: float + var time_release: float setget set_time_release + var duration: float setget set_duration var column_release: int var slide_type: int var slide_id: int @@ -59,6 +85,21 @@ class NoteSlide extends NoteBase: self.values = {} update_slide_variables() + func set_time_hit(value: float): + time_hit = value + time_release = time_hit + duration + time_death = time_release + DEATH_DELAY + + func set_time_release(value: float): + time_release = value + time_death = time_release + DEATH_DELAY + duration = time_release - time_hit + + func set_duration(value: float): + duration = value + time_release = time_hit + duration + time_death = time_release + DEATH_DELAY + func update_slide_variables(): match slide_type: Note.SlideType.CHORD: diff --git a/NoteHandler.gd b/NoteHandler.gd index 2a2588d..757b936 100644 --- a/NoteHandler.gd +++ b/NoteHandler.gd @@ -646,16 +646,21 @@ func _process(delta): for i in range(len(active_notes)-1, -1, -1): var note = active_notes[i] if note.time_death < t: - if note.type == Note.NOTE_SLIDE: - SlideTrailHandler.remove_child(slide_trail_mesh_instances[note.slide_id]) - slide_trail_mesh_instances.erase(note.slide_id) - var idx = active_slide_trails.find(note) - if idx >= 0: - active_slide_trails.remove(idx) - active_judgement_texts.append({col=note.column_release, judgement="MISS", time=t}) - scores[-Note.NOTE_SLIDE]["MISS"] += 1 - note.missed_slide = true - SFXPlayer.play(SFXPlayer.Type.NON_POSITIONAL, self, snd_judgement["MISS"], db_judgement["MISS"]) + match note.type: + Note.NOTE_HOLD: + scores[-Note.NOTE_HOLD][3] += 1 + active_judgement_texts.append({col=note.column, judgement=3, time=t}) + SFXPlayer.play(SFXPlayer.Type.NON_POSITIONAL, self, snd_judgement[3], db_judgement[3]) + Note.NOTE_SLIDE: + SlideTrailHandler.remove_child(slide_trail_mesh_instances[note.slide_id]) + slide_trail_mesh_instances.erase(note.slide_id) + var idx = active_slide_trails.find(note) + if idx >= 0: + active_slide_trails.remove(idx) + active_judgement_texts.append({col=note.column_release, judgement="MISS", time=t}) + scores[-Note.NOTE_SLIDE]["MISS"] += 1 + note.missed_slide = true + SFXPlayer.play(SFXPlayer.Type.NON_POSITIONAL, self, snd_judgement["MISS"], db_judgement["MISS"]) active_notes.remove(i) elif note.time_activated == INF: if ((t-note.time_hit) > miss_time) and not note.missed: diff --git a/main.tscn b/main.tscn index cc9ac2b..c7323b2 100644 --- a/main.tscn +++ b/main.tscn @@ -198,6 +198,21 @@ step = 0.01 value = 1.0 tick_count = 3 ticks_on_borders = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="FilterSlider" type="HSlider" parent="InputHandler"] +margin_left = 16.0 +margin_top = 400.0 +margin_right = 316.0 +margin_bottom = 416.0 +max_value = 1.0 +step = 0.01 +value = 0.2 +__meta__ = { +"_edit_use_anchors_": false +} [node name="VsyncButton" type="CheckButton" parent="InputHandler"] margin_left = 4.0