from . import mathhelper from .hitobject import HitObject cdef class Beatmap(object): """ Beatmap object for beatmap parsing and handling """ cdef public str file_name cdef public int version cdef public int header cdef public dict difficulty cdef public dict timing_points cdef public float slider_point_distance cdef public list hitobjects cdef public int max_combo def __init__(self, file_name): """ file_name -- Directory for beatmap file (.osu) """ self.file_name = file_name self.version = -1 #Unknown by default self.header = -1 self.difficulty = {} self.timing_points = { "raw_bpm": {}, #Raw bpm modifier code "raw_spm": {}, #Raw speed modifier code "bpm": {}, #Beats pr minute "spm": {} #Speed modifier } self.slider_point_distance = 1 #Changes after [Difficulty] is fully parsed self.hitobjects = [] self.max_combo = 0 self.parse_beatmap() if "ApproachRate" not in self.difficulty.keys(): #Fix old osu version self.difficulty["ApproachRate"] = self.difficulty["OverallDifficulty"] cpdef parse_beatmap(self): """ Parses beatmap file line by line by passing each line into parse_line. """ cdef str line with open(self.file_name, encoding="utf8") as file_stream: ver_line = "" while len(ver_line) < 2: #Find the line where beatmap version is spesified (normaly first line) ver_line = file_stream.readline() self.version = int(''.join(list(filter(str.isdigit, ver_line)))) #Set version for line in file_stream: self.parse_line(line.replace("\n", "")) cpdef parse_line(self, str line): """ Parse a beatmapfile line. Handles lines that are required for our use case (Difficulty, TimingPoints & hitobjects), everything else is skipped. """ if len(line) < 1: return if line.startswith("["): if line == "[Difficulty]": self.header = 0 elif line == "[TimingPoints]": self.header = 1 elif line == "[HitObjects]": self.header = 2 self.slider_point_distance = (100 * self.difficulty["SliderMultiplier"]) / self.difficulty["SliderTickRate"] else: self.header = -1 return if self.header == -1: #We return if we are reading under a header we dont care about return if self.header == 0: self.handle_difficulty_propperty(line) elif self.header == 1: self.handle_timing_point(line) elif self.header == 2: self.handle_hitobject(line) cpdef handle_difficulty_propperty(self, str propperty): """ Puts the [Difficulty] propperty into the difficulty dict. """ prop = propperty.split(":") self.difficulty[prop[0]] = float(prop[1]) cpdef handle_timing_point(self, str timing_point): """ Formats timing points used for slider velocity changes, and store them into self.timing_points dict. """ timing_point_split = timing_point.split(",") timing_point_time = int(float(timing_point_split[0])) #Fixes some special mappers special needs timing_point_focus = timing_point_split[1] timing_point_type = 1 if len(timing_point_split) >= 7: #Fix for old beatmaps that only stores bpm change and timestamp (only BPM change) [v3?] timing_point_type = int(timing_point_split[6]) if timing_point_type == 0 and not timing_point_focus.startswith("-"): timing_point_focus = "-100" if timing_point_focus.startswith("-"): #If not then its not a slider velocity modifier self.timing_points["spm"][timing_point_time] = -100 / float(timing_point_focus) #Convert to normalized value and store self.timing_points["raw_spm"][timing_point_time] = float(timing_point_focus) else: if len(self.timing_points["bpm"]) == 0: #Fixes if hitobjects shows up before bpm is set timing_point_time = 0 self.timing_points["bpm"][timing_point_time] = 60000 / float(timing_point_focus)#^ self.timing_points["raw_bpm"][timing_point_time] = float(timing_point_focus) #This trash of a game resets the spm when bpm change >.> self.timing_points["spm"][timing_point_time] = 1 self.timing_points["raw_spm"][timing_point_time] = -100 cpdef handle_hitobject(self, str line): """ Puts every hitobject into the hitobjects array. Creates hitobjects, hitobject_sliders or skip depending on the given data. We skip everything that is not important for us for our use case (Spinners) """ split_object = line.split(",") time = int(split_object[2]) object_type = int(split_object[3]) if not (1 & object_type > 0 or 2 & object_type > 0): #We only want sliders and circles as spinners are random bannanas etc. return if 2 & object_type: #Slider repeat = int(split_object[6]) pixel_length = float(split_object[7]) time_point = self.get_timing_point_all(time) tick_distance = (100 * self.difficulty["SliderMultiplier"]) / self.difficulty["SliderTickRate"] if self.version >= 8: tick_distance /= (mathhelper.clamp(-time_point["raw_spm"], 10, 1000) / 100) curve_split = split_object[5].split("|") curve_points = [] for i in range(1, len(curve_split)): vector_split = curve_split[i].split(":") vector = mathhelper.Vec2(int(vector_split[0]), int(vector_split[1])) curve_points.append(vector) slider_type = curve_split[0] if self.version <= 6 and len(curve_points) >= 2: if slider_type == "L": slider_type = "B" if len(curve_points) == 2: if (int(split_object[0]) == curve_points[0].x and int(split_object[1]) == curve_points[0].y) or (curve_points[0].x == curve_points[1].x and curve_points[0].y == curve_points[1].y): del curve_points[0] slider_type = "L" if len(curve_points) == 0: #Incase of ExGon meme (Sliders that acts like hitcircles) hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, 1) else: hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, object_type, slider_type, curve_points, repeat, pixel_length, time_point, self.difficulty, tick_distance) else: hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, object_type) self.hitobjects.append(hitobject) self.max_combo += hitobject.get_combo() def get_timing_point_all(self, time): """ Returns a object of all current timing types time -- timestamp return -- {"raw_bpm": Float, "raw_spm": Float, "bpm": Float, "spm": Float} """ types = { "raw_bpm": 600, "raw_spm": -100, "bpm": 100, "spm": 1 } #Will return the default value if timing point were not found for t in types.keys(): r = self.get_timing_point(time, t) if r is not None: types[t] = r #else: #print("{} were not found for timestamp {}, using {} instead.".format(t, time, types[t])) return types def get_timing_point(self, time, timing_type): """ Returns latest timing point by timestamp (Current) time -- timestamp timing_type -- mpb, bmp or spm return -- self.timing_points object """ r = None try: for key in sorted(self.timing_points[timing_type].keys(), key=lambda k: k): if key <= time: r = self.timing_points[timing_type][key] else: break except Exception as e: print(e) return r def get_object_count(self): """ Get the total hitobject count for the parsed beatmap (Normal hitobjects, sliders & sliderticks) return -- total hitobjects for parsed beatmap """ cdef int count = 0 for hitobject in self.hitobjects: count += hitobject.get_points() return count