From 1479281ae7adaecc8f66807f9f4463bf6ba13ad3 Mon Sep 17 00:00:00 2001 From: 544146 Date: Sun, 8 Oct 2023 18:36:35 +0100 Subject: [PATCH 1/4] Add feet saber wip changes --- README.md | 8 ++- drs2dd.py | 8 +-- fs2dd.py | 143 +++++++++++++++++++++++++++------------------ model/dancedash.py | 4 +- model/feetsaber.py | 92 +++++++++++++++-------------- 5 files changed, 145 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 7a979640..a25a74e4 100644 --- a/README.md +++ b/README.md @@ -59,5 +59,11 @@ Generate full DD Beat Map from json files in repository (no --song-id does all) Generate full DD Beat Map from Feet Saber directory (WIP) ```bash -.venv/Scripts/python fs2dd.py ... # WIP when I have time. +# WIP when I have time. https://beatsaver.com/maps/229ed is what i've been using to test. +.venv/Scripts/python fs2dd.py --fs-map-dir "229ed (Yell! (DJ Shimamura Remix) [feat. Moimoi] [Feet saber] - KikaeAeon)" ``` + +### Feet Saber TODO: + +- Map 1-9 x position of notes (model/feetsaber.py:213) +- Map timings and logic for line notes (fs2dd.py:74) \ No newline at end of file diff --git a/drs2dd.py b/drs2dd.py index 14b01d0e..7e7b1081 100644 --- a/drs2dd.py +++ b/drs2dd.py @@ -22,8 +22,8 @@ from model.dancedash import DDLineNode from model.dancedash import DDRoadBlockNode from model.dancedash import DDSphereNode from model.dancedash import DRS2DD_MAP_PREFIX -from model.dancedash import DRS_TO_DDS_LINE_NOTE_TYPE -from model.dancedash import DRS_TO_DDS_NOTE_TYPE +from model.dancedash import DRS_TO_DD_LINE_NOTE_TYPE +from model.dancedash import DRS_TO_DD_NOTE_TYPE from model.dancedash import ORDER_COUNT_PER_BEAT from model.dancedash import X_Y from model.dancerush import ALBUM_NAME @@ -63,7 +63,7 @@ def map_sphere_nodes( noteOrder=round(bps * seconds * ORDER_COUNT_PER_BEAT), time=seconds / total_time_seconds, position=X_Y(x=track_step.position_info.to_dance_dash_x, y=0), - noteType=DRS_TO_DDS_NOTE_TYPE[track_step.kind], + noteType=DRS_TO_DD_NOTE_TYPE[track_step.kind], ), ) return spheres @@ -108,7 +108,7 @@ def map_line_nodes( noteOrder=round(bps * seconds * ORDER_COUNT_PER_BEAT), time=seconds / total_time_seconds, position=X_Y(x=drs_track_point.to_dance_dash_x, y=0), - noteType=DRS_TO_DDS_LINE_NOTE_TYPE[track_step.kind], + noteType=DRS_TO_DD_LINE_NOTE_TYPE[track_step.kind], ), ) diff --git a/fs2dd.py b/fs2dd.py index 490f1245..a2af73c0 100644 --- a/fs2dd.py +++ b/fs2dd.py @@ -5,15 +5,23 @@ import os import shutil from datetime import datetime -from model.dancedash import DD_LEFT +from model.dancedash import DD_LINE_LEFT +from model.dancedash import DD_LINE_RIGHT from model.dancedash import DDBeatMap from model.dancedash import DDBeatMapData from model.dancedash import DDBeatMapInfoFile +from model.dancedash import DDDownPos +from model.dancedash import DDDownPos2D +from model.dancedash import DDJumpPos +from model.dancedash import DDJumpPos2D from model.dancedash import DDLineNode from model.dancedash import DDRoadBlockNode from model.dancedash import DDSphereNode from model.dancedash import ORDER_COUNT_PER_BEAT from model.dancedash import X_Y +from model.feetsaber import FS_LEFT_NOTE +from model.feetsaber import FS_RIGHT_NOTE +from model.feetsaber import FS_TO_DD_NOTE_TYPE from model.feetsaber import FSBeatMapFile from model.feetsaber import FSInfoDat from util import convert_egg_to_ogg_and_get_length @@ -22,65 +30,83 @@ from util import random_9_digit_int from util import yyyymmdd_to_ticks -def map_sphere_nodes(fs_beat_map: FSBeatMapFile, total_time_seconds: float) -> list[DDSphereNode]: - spheres = [] - ... - return spheres +def map_sphere_nodes( + fs_beat_map: FSBeatMapFile, + fs_info_dat: FSInfoDat, + total_time_seconds: float, +) -> list[DDSphereNode]: + + def process_notes(note_type: FS_LEFT_NOTE | FS_RIGHT_NOTE, line_type: DD_LINE_LEFT | DD_LINE_RIGHT): + all_lines_of_type = [ + o for o in fs_beat_map.obstacles if o.customData.dd_note_type == line_type + ] + all_notes = [ + n for n in fs_beat_map.notes if n.type == note_type + ] + non_overlapping_notes = [ + note for note in all_notes + if not any(obstacle.time <= note.time < obstacle.end_time for obstacle in all_lines_of_type) + ] + return [ + DDSphereNode( + noteOrder=round(note.time * ORDER_COUNT_PER_BEAT), + time=(fs_info_dat.bps * note.time) / total_time_seconds, + position=X_Y(x=note.to_dd_x, y=0), + noteType=FS_TO_DD_NOTE_TYPE[note.type], + ) + for note in non_overlapping_notes + ] + + spheres = process_notes(FS_LEFT_NOTE, DD_LINE_LEFT) + \ + process_notes(FS_RIGHT_NOTE, DD_LINE_RIGHT) + return sorted(spheres, key=lambda s: s.noteOrder) -def map_line_nodes(fs_beat_map: FSBeatMapFile, total_time_seconds: float) -> list[DDLineNode]: +def map_line_nodes( + fs_beat_map: FSBeatMapFile, + fs_info_dat: FSInfoDat, + total_time_seconds: float, +) -> list[DDLineNode]: lines = [] - line_obstacles = [ - o for o in fs_beat_map.obstacles if o.customData.is_fs_slider - ] - left_obstacles = [ - o for o in line_obstacles if o.customData.dd_note_type == DD_LEFT - ] - right_obstacles = [ # noqa - o for o in line_obstacles if o.customData.dd_note_type != DD_LEFT - ] # noqa - line_group_id = 1 + # line_obstacles = [ + # o for o in fs_beat_map.obstacles if o.customData.is_fs_slider + # ] + # left_obstacles = [ + # o for o in line_obstacles if o.customData.dd_note_type == DD_LEFT + # ] + # right_obstacles = [ + # o for o in line_obstacles if o.customData.dd_note_type == DD_RIGHT + # ] - index_in_line = 0 - for idx, obstacle in enumerate(left_obstacles): - - for multiplier in (0, obstacle.duration): # line start and line end - index_in_line += 1 - lines.append( - DDLineNode( - lineGroupId=line_group_id, - indexInLine=index_in_line, - noteOrder=round( - ( - (obstacle.time + multiplier) * - ORDER_COUNT_PER_BEAT - ) - ORDER_COUNT_PER_BEAT, - ), - time=fs_beat_map.customData.time / total_time_seconds, - position=X_Y(x=obstacle.customData.dd_x, y=0), - noteType=obstacle.customData.dd_note_type, - ), - ) - - # todo(aggg figure this out i give up for today) - - is_last_obstacle = idx == len(left_obstacles) - 1 - next_obstacle = left_obstacles[ - idx + - 1 - ] if not is_last_obstacle else None - if next_obstacle and not next_obstacle.is_part_of_last_obstacle(obstacle): - line_group_id += 1 - index_in_line = 0 + # todo: implement lines return lines -def map_down_and_jump_notes(fs_beat_map: FSBeatMapFile, total_time_seconds: float) -> list[DDRoadBlockNode]: - spheres = [] - ... - return spheres +def map_down_and_jump_notes( + fs_beat_map: FSBeatMapFile, + fs_info_dat: FSInfoDat, + total_time_seconds: float, +) -> list[DDRoadBlockNode]: + def create_block(obstacle, position, position2D): + return DDRoadBlockNode( + noteOrder=round(obstacle.time * ORDER_COUNT_PER_BEAT) + + (ORDER_COUNT_PER_BEAT / 16), + time=(fs_info_dat.bps * obstacle.time) / total_time_seconds, + position=position, + position2D=position2D, + ) + + downs = [ + create_block(o, DDDownPos, DDDownPos2D) + for o in fs_beat_map.obstacles if o.is_down + ] + ups = [ + create_block(o, DDJumpPos, DDJumpPos2D) + for o in fs_beat_map.obstacles if o.is_up + ] + return sorted(downs + ups, key=lambda r: r.noteOrder) def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: @@ -112,19 +138,19 @@ def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: song_paths = [] for difficulty_set in fs_info.difficultyBeatmapSets: - for difficulty_set in difficulty_set.difficultyBeatmaps: - beat_map: FSBeatMapFile = difficulty_set.get_beatmap(fs_map_dir) + for difficulty in difficulty_set.difficultyBeatmaps: + beat_map: FSBeatMapFile = difficulty.get_beatmap(fs_map_dir) - sphere_notes = map_sphere_nodes(beat_map, song_length) - line_notes = map_line_nodes(beat_map, song_length) + sphere_notes = map_sphere_nodes(beat_map, fs_info, song_length) + line_notes = map_line_nodes(beat_map, fs_info, song_length) road_block_notes = map_down_and_jump_notes( - beat_map, song_length, + beat_map, fs_info, song_length, ) total_note_count = len(sphere_notes + line_notes + road_block_notes) # noqa dd_beat_map = DDBeatMap( data=DDBeatMapData( - name=f'{fs_info.songName}', + name=fs_info.songName, sphereNodes=sphere_notes, lineNodes=line_notes, roadBlockNodes=road_block_notes, @@ -133,9 +159,10 @@ def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: NPS=str(round(total_note_count / song_length, 2)), ) + difficulty_name = f'{difficulty_set.beatmapCharacteristicName}_{difficulty.difficulty}' song_paths.append( dd_beat_map.save_to_file( - target_dir, 'a.json', + target_dir, f'{difficulty_name}.json', ), ) diff --git a/model/dancedash.py b/model/dancedash.py index 2d0f8691..e849334c 100644 --- a/model/dancedash.py +++ b/model/dancedash.py @@ -23,12 +23,12 @@ ORDER_COUNT_PER_BEAT = 24 DRS2DD_MAP_PREFIX = 44_52_53_000 # 44 = D, 52 = R, 53 = S -DRS_TO_DDS_NOTE_TYPE = { +DRS_TO_DD_NOTE_TYPE = { DRS_LEFT: DD_LEFT, DRS_RIGHT: DD_RIGHT, } -DRS_TO_DDS_LINE_NOTE_TYPE = { +DRS_TO_DD_LINE_NOTE_TYPE = { DRS_LEFT: DD_LINE_LEFT, DRS_RIGHT: DD_LINE_RIGHT, } diff --git a/model/feetsaber.py b/model/feetsaber.py index 03ac69c5..bca794fc 100644 --- a/model/feetsaber.py +++ b/model/feetsaber.py @@ -5,8 +5,10 @@ import os from dataclasses import dataclass from dataclasses import field +from model.dancedash import DD_LEFT from model.dancedash import DD_LINE_LEFT from model.dancedash import DD_LINE_RIGHT +from model.dancedash import DD_RIGHT @dataclass @@ -193,6 +195,10 @@ class FSBeatMapFileNoteCustomData: position: tuple[float, float] +FS_LEFT_NOTE = 0 +FS_RIGHT_NOTE = 1 + + @dataclass class FSBeatMapFileNote: time: float @@ -202,6 +208,10 @@ class FSBeatMapFileNote: cutDirection: int customData: FSBeatMapFileNoteCustomData + @property + def to_dd_x(self): + return 0 # TODO + FS_RIGHT_COLOUR = (0.0, 1.0, 3.0, 1.0) FS_LEFT_COLOUR = (2.0, 1.5, 0.0, 1.0) @@ -211,9 +221,15 @@ FS_LONG_NOTE_TO_DD_NOTE_TYPE = { FS_LEFT_COLOUR: DD_LINE_LEFT, } +FS_TO_DD_NOTE_TYPE = { + FS_LEFT_NOTE: DD_LEFT, + FS_RIGHT_NOTE: DD_RIGHT, +} + @dataclass class FSBeatMapFileObstacleCustomData: + track: str interactable: bool fake: bool position: tuple[float, float] @@ -222,52 +238,21 @@ class FSBeatMapFileObstacleCustomData: localRotation: tuple[float, float, float] | None = None @property - def height(self): + def height(self) -> float: return self.scale[1] @property - def y(self): + def is_fs(self) -> bool: _, y = self.position - return y - - @property - def x(self): - x, _ = self.position - return x - - @property - def is_fs(self): - return all( - [ - self.height == 0.1, - self.y == -0.25, - self.fake, - ], - ) + return self.height == 0.1 and y == -0.25 and self.fake @property def is_fs_slider(self) -> bool: return self.scale[0] == 1.0 and self.is_fs @property - def is_tail(self) -> bool: - return self.scale[0] == 1.5 and self.is_fs - - @property - def dd_x(self): - if not self.is_fs: - raise ValueError('This is not a FS note') - - if self.x < -2.5 or self.x > 1.5: - raise ValueError('Input should be between -1.5 and 1.5') - - normalized = (self.x + 2.5) / 4.0 - mapped_value = round(normalized * 8 + 1) - return int(mapped_value) - - @property - def dd_note_type(self): - return FS_LONG_NOTE_TO_DD_NOTE_TYPE[self.color] + def dd_note_type(self) -> DD_LINE_RIGHT | DD_LINE_LEFT | None: + return FS_LONG_NOTE_TO_DD_NOTE_TYPE.get(self.color) @dataclass @@ -279,14 +264,24 @@ class FSBeatMapFileObstacle: width: int customData: FSBeatMapFileObstacleCustomData + @property + def end_time(self): + return self.time + self.duration + + @property + def is_down(self) -> bool: + if not self.customData.track: + return False + return self.customData.track.casefold() == 'DownArch'.casefold() + + @property + def is_up(self) -> bool: + if not self.customData.track: + return False + return self.customData.track.casefold() == 'JumpBar'.casefold() + def is_part_of_last_obstacle(self, last_obstacle: FSBeatMapFileObstacle) -> bool: - if self.time < last_obstacle.time: - return False - - if self.time > last_obstacle.time + last_obstacle.duration: - return False - - return True + return last_obstacle.time <= self.time <= last_obstacle.end_time @dataclass @@ -322,8 +317,14 @@ class FSBeatMapFile: notes = [ FSBeatMapFileNote( time=note['_time'], - lineIndex=note['_lineIndex'], - lineLayer=note['_lineLayer'], + lineIndex=note['_lineIndex'] * + 1000 if len( + str(note['_lineIndex']), + ) == 1 else note['_lineIndex'], + lineLayer=note['_lineLayer'] * + 1000 if len( + str(note['_lineLayer']), + ) == 1 else note['_lineLayer'], type=note['_type'], cutDirection=note['_cutDirection'], customData=FSBeatMapFileNoteCustomData( @@ -340,6 +341,7 @@ class FSBeatMapFile: duration=obstacle['_duration'], width=obstacle['_width'], customData=FSBeatMapFileObstacleCustomData( + track=obstacle['_customData'].get('_track'), interactable=obstacle['_customData']['_interactable'], fake=obstacle['_customData']['_fake'], position=tuple(obstacle['_customData']['_position']), -- 2.54.0 From 9c3ed8a043779f57a6c164ef91aa3346414ac081 Mon Sep 17 00:00:00 2001 From: 544146 Date: Sun, 8 Oct 2023 18:41:18 +0100 Subject: [PATCH 2/4] Add comment for reminder --- model/feetsaber.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/model/feetsaber.py b/model/feetsaber.py index bca794fc..417fd8a0 100644 --- a/model/feetsaber.py +++ b/model/feetsaber.py @@ -318,9 +318,9 @@ class FSBeatMapFile: FSBeatMapFileNote( time=note['_time'], lineIndex=note['_lineIndex'] * - 1000 if len( - str(note['_lineIndex']), - ) == 1 else note['_lineIndex'], + 1000 if len( # line index is meant to be between 0 and 3, but sometimes it can be # noqa + str(note['_lineIndex']), # a 4 digit between like 1000 - 3500 (????) # noqa + ) == 1 else note['_lineIndex'], # https://bsmg.wiki/mapping/difficulty-format-v2.html#lineindex # noqa lineLayer=note['_lineLayer'] * 1000 if len( str(note['_lineLayer']), -- 2.54.0 From 1771253acc2fcb69e712082f57a720f0c5742447 Mon Sep 17 00:00:00 2001 From: 544146 Date: Sun, 8 Oct 2023 22:06:44 +0100 Subject: [PATCH 3/4] More feet saber progress... kind of --- fs2dd.py | 95 ++++++++++++++++++++++++++++++++++++++++------ model/feetsaber.py | 49 ++++++++++++++++++------ 2 files changed, 122 insertions(+), 22 deletions(-) diff --git a/fs2dd.py b/fs2dd.py index a2af73c0..3b0cc66a 100644 --- a/fs2dd.py +++ b/fs2dd.py @@ -69,17 +69,90 @@ def map_line_nodes( ) -> list[DDLineNode]: lines = [] - # line_obstacles = [ - # o for o in fs_beat_map.obstacles if o.customData.is_fs_slider - # ] - # left_obstacles = [ - # o for o in line_obstacles if o.customData.dd_note_type == DD_LEFT - # ] - # right_obstacles = [ - # o for o in line_obstacles if o.customData.dd_note_type == DD_RIGHT - # ] + line_obstacles = [ + o for o in fs_beat_map.obstacles if o.customData.is_fs + ] - # todo: implement lines + obstacles_by_type = { + DD_LINE_LEFT: [ + o for o in line_obstacles if o.customData.dd_note_type == DD_LINE_LEFT + ], + DD_LINE_RIGHT: [ + o for o in line_obstacles if o.customData.dd_note_type == DD_LINE_RIGHT + ], + } + + index_in_line = 0 + line_group_id = 1 + last_obstacle = None + for note_type, obstacles in obstacles_by_type.items(): + for obstacle in obstacles: + if obstacle.is_part_of_last_obstacle(last_obstacle) and not obstacle.customData.is_fs_slider: + index_in_line += 1 + is_left = obstacle.customData.position[0] < last_obstacle.customData.position[0] + last_obstacle_x = lines[-1].position.x + lines.append( + DDLineNode( + lineGroupId=line_group_id, + indexInLine=index_in_line, + noteOrder=round(obstacle.time * ORDER_COUNT_PER_BEAT), + time=(fs_info_dat.bps * obstacle.time) / + total_time_seconds, + position=X_Y( + last_obstacle_x + + (-1 if is_left else 1), y=0, + ), + noteType=note_type, + ), + ) + index_in_line += 1 + lines.append( + DDLineNode( + lineGroupId=line_group_id, + indexInLine=index_in_line, + noteOrder=round( + obstacle.time * ORDER_COUNT_PER_BEAT, + ) + ORDER_COUNT_PER_BEAT / 4, + time=(fs_info_dat.bps * obstacle.time) / + total_time_seconds, + position=X_Y( + last_obstacle_x + + (-1 if is_left else 1), y=0, + ), + noteType=note_type, + ), + ) + last_obstacle = obstacle + continue + + if not obstacle.is_part_of_last_obstacle(last_obstacle): + index_in_line = 0 + line_group_id += 1 + + lines.append( + DDLineNode( + lineGroupId=line_group_id, + indexInLine=index_in_line, + noteOrder=round(obstacle.time * ORDER_COUNT_PER_BEAT), + time=(fs_info_dat.bps * obstacle.time) / + total_time_seconds, + position=X_Y(x=obstacle.to_dd_x(), y=0), + noteType=note_type, + ), + ) + index_in_line += 1 + lines.append( + DDLineNode( + lineGroupId=line_group_id, + indexInLine=index_in_line, + noteOrder=round(obstacle.end_time * ORDER_COUNT_PER_BEAT), + time=(fs_info_dat.bps * obstacle.time) / + total_time_seconds, + position=X_Y(x=obstacle.end_to_dd_x, y=0), + noteType=note_type, + ), + ) + last_obstacle = obstacle return lines @@ -179,7 +252,7 @@ def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: create_ticks = yyyymmdd_to_ticks(datetime.now().strftime('%Y%m%d')) dd_beat_map_info = DDBeatMapInfoFile( - OstId=random_9_digit_int(), + OstId=0, # default CreateTicks=create_ticks, CreateTime=str(create_ticks), BeatMapId=random_9_digit_int(), diff --git a/model/feetsaber.py b/model/feetsaber.py index 417fd8a0..a2d38800 100644 --- a/model/feetsaber.py +++ b/model/feetsaber.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import os from dataclasses import dataclass from dataclasses import field @@ -209,8 +210,12 @@ class FSBeatMapFileNote: customData: FSBeatMapFileNoteCustomData @property - def to_dd_x(self): - return 0 # TODO + def to_dd_x(self) -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9: + x = self.customData.position[0] + x_min, x_max = -2, 1 + y_min, y_max = 1, 9 + y = (x - x_min) * (y_max - y_min) / (x_max - x_min) + y_min + return round(y) FS_RIGHT_COLOUR = (0.0, 1.0, 3.0, 1.0) @@ -264,6 +269,32 @@ class FSBeatMapFileObstacle: width: int customData: FSBeatMapFileObstacleCustomData + def to_dd_x(self, x: float = None) -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9: + x_min, x_max = -2, 1 + y_min, y_max = 1, 9 + if not x: + x = self.customData.position[0] + if x < x_min: + x = -2 + elif x > x_max: + x = 1 + y = (x - x_min) * (y_max - y_min) / (x_max - x_min) + y_min + return round(y) + + @property + def end_to_dd_x(self) -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9: + opposite_angle = self.customData.localRotation[1] + if opposite_angle == 0.0: + return self.to_dd_x() + # this angle calculation came out of nowhere, fix this with proper math or something idk + opposite_length = self.duration * \ + math.sin(math.radians(opposite_angle)) + return self.to_dd_x(x=self.customData.position[0] + (opposite_length * 6.4)) + + @property + def has_rotation(self) -> bool: + return self.customData.localRotation[1] != 0.0 + @property def end_time(self): return self.time + self.duration @@ -280,7 +311,9 @@ class FSBeatMapFileObstacle: return False return self.customData.track.casefold() == 'JumpBar'.casefold() - def is_part_of_last_obstacle(self, last_obstacle: FSBeatMapFileObstacle) -> bool: + def is_part_of_last_obstacle(self, last_obstacle: FSBeatMapFileObstacle | None) -> bool: + if not last_obstacle: + return False return last_obstacle.time <= self.time <= last_obstacle.end_time @@ -317,14 +350,8 @@ class FSBeatMapFile: notes = [ FSBeatMapFileNote( time=note['_time'], - lineIndex=note['_lineIndex'] * - 1000 if len( # line index is meant to be between 0 and 3, but sometimes it can be # noqa - str(note['_lineIndex']), # a 4 digit between like 1000 - 3500 (????) # noqa - ) == 1 else note['_lineIndex'], # https://bsmg.wiki/mapping/difficulty-format-v2.html#lineindex # noqa - lineLayer=note['_lineLayer'] * - 1000 if len( - str(note['_lineLayer']), - ) == 1 else note['_lineLayer'], + lineIndex=note['_lineIndex'], + lineLayer=note['_lineLayer'], type=note['_type'], cutDirection=note['_cutDirection'], customData=FSBeatMapFileNoteCustomData( -- 2.54.0 From 85f9b55f331a325eb6adff8ec25c0702ff578443 Mon Sep 17 00:00:00 2001 From: 544146 Date: Sun, 8 Oct 2023 23:35:23 +0100 Subject: [PATCH 4/4] Feet saber album process! --- README.md | 10 ++--- fs2dd.py | 92 +++++++++++++++++++++++++++++++++++++++++----- model/feetsaber.py | 29 +++++++++++---- util.py | 71 ++++++++++++++++++++++++++++++++++- 4 files changed, 178 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a25a74e4..c01222a4 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,7 @@ Generate full DD Beat Map from json files in repository (no --song-id does all) Generate full DD Beat Map from Feet Saber directory (WIP) ```bash -# WIP when I have time. https://beatsaver.com/maps/229ed is what i've been using to test. -.venv/Scripts/python fs2dd.py --fs-map-dir "229ed (Yell! (DJ Shimamura Remix) [feat. Moimoi] [Feet saber] - KikaeAeon)" +.venv/Scripts/python fs2dd.py --fs-map-dir "path/to/map/folder" +.venv/Scripts/python fs2dd.py --fs-map-id 229ed +.venv/Scripts/python fs2dd.py --fs-playlist-id 3474 ``` - -### Feet Saber TODO: - -- Map 1-9 x position of notes (model/feetsaber.py:213) -- Map timings and logic for line notes (fs2dd.py:74) \ No newline at end of file diff --git a/fs2dd.py b/fs2dd.py index 3b0cc66a..20bba605 100644 --- a/fs2dd.py +++ b/fs2dd.py @@ -1,12 +1,16 @@ from __future__ import annotations import argparse +import concurrent import os import shutil +import zipfile +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from model.dancedash import DD_LINE_LEFT from model.dancedash import DD_LINE_RIGHT +from model.dancedash import DDAlbumInfo from model.dancedash import DDBeatMap from model.dancedash import DDBeatMapData from model.dancedash import DDBeatMapInfoFile @@ -25,9 +29,14 @@ from model.feetsaber import FS_TO_DD_NOTE_TYPE from model.feetsaber import FSBeatMapFile from model.feetsaber import FSInfoDat from util import convert_egg_to_ogg_and_get_length +from util import download_and_extract_zip +from util import download_image_from_url +from util import get_feet_saber_map_from_id +from util import get_feet_saber_maps_from_playlist from util import get_first_image_file_in_folder from util import random_9_digit_int from util import yyyymmdd_to_ticks +from util import zipdir def map_sphere_nodes( @@ -38,7 +47,7 @@ def map_sphere_nodes( def process_notes(note_type: FS_LEFT_NOTE | FS_RIGHT_NOTE, line_type: DD_LINE_LEFT | DD_LINE_RIGHT): all_lines_of_type = [ - o for o in fs_beat_map.obstacles if o.customData.dd_note_type == line_type + o for o in fs_beat_map.obstacles if o.customData and o.customData.dd_note_type == line_type ] all_notes = [ n for n in fs_beat_map.notes if n.type == note_type @@ -70,7 +79,7 @@ def map_line_nodes( lines = [] line_obstacles = [ - o for o in fs_beat_map.obstacles if o.customData.is_fs + o for o in fs_beat_map.obstacles if o.customData and o.customData.is_fs ] obstacles_by_type = { @@ -182,7 +191,7 @@ def map_down_and_jump_notes( return sorted(downs + ups, key=lambda r: r.noteOrder) -def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: +def create_dd_tracks_from_fs(fs_map_dir: str, prefix_dir: str = '', ost_id: int | None = None) -> DDBeatMapInfoFile: info_file_path = os.path.join(fs_map_dir, 'Info.dat') fs_info = FSInfoDat.from_json_file(info_file_path) @@ -191,7 +200,7 @@ def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: fs_map_dir, ) if f.endswith('.egg') ][0] - target_dir = f'{fs_info.songName} - {fs_info.levelAuthorName}' + target_dir = f'{prefix_dir}/{fs_info.songName} - {fs_info.levelAuthorName}' if not os.path.exists(target_dir): os.makedirs(target_dir) @@ -252,7 +261,7 @@ def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: create_ticks = yyyymmdd_to_ticks(datetime.now().strftime('%Y%m%d')) dd_beat_map_info = DDBeatMapInfoFile( - OstId=0, # default + OstId=ost_id, CreateTicks=create_ticks, CreateTime=str(create_ticks), BeatMapId=random_9_digit_int(), @@ -280,13 +289,78 @@ if __name__ == '__main__': description='Create Dance Dash tracks from Feet Saber maps', ) parser.add_argument( - '--fs-map-dir', - type=str, - help='The directory containing the Feet Saber map files', - required=True, + '--fs-map-dir', type=str, help='The directory containing the Feet Saber map files', required=False, + ) + parser.add_argument( + '--fs-map-id', type=str, + help='The ID to fetch the Feet Saber map zip', required=False, + ) + parser.add_argument( + '--fs-playlist-id', type=str, + help='The ID to fetch the Feet Saber playlist', required=False, ) args = parser.parse_args() + + if args.fs_playlist_id: + ost_id = int(args.fs_playlist_id) if args.fs_playlist_id.isdigit( + ) else random_9_digit_int() + target_dir = f'bin/{ost_id}' + + if not os.path.exists(target_dir): + os.makedirs(target_dir) + + fs_map_urls, album_cover_url, author = get_feet_saber_maps_from_playlist( + args.fs_playlist_id, + ) + album_cover_path = None + if album_cover_url: + album_cover_path = download_image_from_url( + album_cover_url, f'{target_dir}/{ost_id}', + ) + + def download_and_process_track(url, target_dir, ost_id): + fs_map_dir = download_and_extract_zip(url) + track = create_dd_tracks_from_fs(fs_map_dir, target_dir, ost_id) + return track + + tracks = [] + with ThreadPoolExecutor() as executor: + future_to_url = { + executor.submit(download_and_process_track, url, target_dir, ost_id): url for url in + fs_map_urls + } + for future in concurrent.futures.as_completed(future_to_url): + track = future.result() + tracks.append(track) + + album_info = DDAlbumInfo( + OstName=f'Feet Saber - {args.fs_playlist_id} by {author}', + BeatMapIdList=sorted([track.BeatMapId for track in tracks]), + OstId=ost_id, + CoverPath=os.path.basename(album_cover_path), + CreateTime=yyyymmdd_to_ticks(datetime.now().strftime('%Y%m%d')), + ).save_to_file(target_dir) + print(f'Created album info file: {album_info}') + + print('Zipping tracks...') + with zipfile.ZipFile(f'bin/{ost_id}.zip', 'w', zipfile.ZIP_DEFLATED) as zipf: + zipdir( + target_dir, zipf, + f'Dance Dash_Data/StreamingAssets/NewDLC/{ost_id}', + ) + + print(f'Created bin/{ost_id}.zip') + raise SystemExit(0) + + if not args.fs_map_dir and not args.fs_map_id: + print('Either --fs-map-dir or --fs-map-id must be provided.') + raise SystemExit(1) + + if args.fs_map_id: + url = get_feet_saber_map_from_id(args.fs_map_id) + args.fs_map_dir = download_and_extract_zip(url) + if not os.path.exists(args.fs_map_dir): print(f'Invalid Feet Saber map directory: {args.fs_map_dir}') raise SystemExit(1) diff --git a/model/feetsaber.py b/model/feetsaber.py index a2d38800..70f6ab96 100644 --- a/model/feetsaber.py +++ b/model/feetsaber.py @@ -127,10 +127,12 @@ class FSInfoDat: noteJumpMovementSpeed=beatmap['_noteJumpMovementSpeed'], noteJumpStartBeatOffset=beatmap['_noteJumpStartBeatOffset'], customData=FSBeatMapCustomData( - difficultyLabel=beatmap['_customData']['_difficultyLabel'], + difficultyLabel=beatmap['_customData'].get( + '_difficultyLabel', + ), editorOffset=beatmap['_customData']['_editorOffset'], editorOldOffset=beatmap['_customData']['_editorOldOffset'], - suggestions=beatmap['_customData']['_suggestions'], + suggestions=beatmap['_customData'].get('_suggestions'), requirements=beatmap['_customData']['_requirements'], ), ) for beatmap in dbset['_difficultyBeatmaps'] @@ -211,9 +213,18 @@ class FSBeatMapFileNote: @property def to_dd_x(self) -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9: + if not self.customData: + if self.lineIndex == 0: + return 2 + elif self.lineIndex == 1: + return 4 + elif self.lineIndex == 2: + return 6 + elif self.lineIndex == 3: + return 8 x = self.customData.position[0] x_min, x_max = -2, 1 - y_min, y_max = 1, 9 + y_min, y_max = 2, 8 y = (x - x_min) * (y_max - y_min) / (x_max - x_min) + y_min return round(y) @@ -271,7 +282,7 @@ class FSBeatMapFileObstacle: def to_dd_x(self, x: float = None) -> 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9: x_min, x_max = -2, 1 - y_min, y_max = 1, 9 + y_min, y_max = 2, 8 if not x: x = self.customData.position[0] if x < x_min: @@ -301,12 +312,16 @@ class FSBeatMapFileObstacle: @property def is_down(self) -> bool: + if not self.customData: + return False if not self.customData.track: return False return self.customData.track.casefold() == 'DownArch'.casefold() @property def is_up(self) -> bool: + if not self.customData: + return False if not self.customData.track: return False return self.customData.track.casefold() == 'JumpBar'.casefold() @@ -335,7 +350,7 @@ class FSBeatMapFile: time=bm['_time'], name=bm['_name'], color=bm['_color'], - ) for bm in json_dict['_customData']['_bookmarks'] + ) for bm in json_dict['_customData'].get('_bookmarks') or [] ], ) @@ -356,7 +371,7 @@ class FSBeatMapFile: cutDirection=note['_cutDirection'], customData=FSBeatMapFileNoteCustomData( position=tuple(note['_customData']['_position']), - ), + ) if '_customData' in note else None, ) for note in json_dict['_notes'] ] @@ -381,7 +396,7 @@ class FSBeatMapFile: float(c) for c in obstacle['_customData']['_color'] ), - ), + ) if '_customData' in obstacle else None, ) for obstacle in json_dict['_obstacles'] ] diff --git a/util.py b/util.py index 767226d1..f480cb51 100644 --- a/util.py +++ b/util.py @@ -3,9 +3,14 @@ from __future__ import annotations import contextlib import os import random +import shutil import subprocess +import tempfile +import zipfile from datetime import datetime +import requests + def get_drs_ogg_and_duration(folder_path) -> tuple[str, str] | tuple[None, None]: ogg_files = [f for f in os.listdir(folder_path) if f.endswith('.ogg')] @@ -81,7 +86,7 @@ def zipdir(path, ziph, archiveroot): def random_9_digit_int(): - return random.randint(10**8, 10**9 - 1) + return random.randint(10 ** 8, 10 ** 9 - 1) def convert_egg_to_ogg_and_get_length(egg_path: str) -> tuple[str, float]: @@ -101,3 +106,67 @@ def convert_egg_to_ogg_and_get_length(egg_path: str) -> tuple[str, float]: ffprobe_cmd, text=True, ).strip() return os.path.basename(ogg_path), float(duration_str) + + +def get_feet_saber_map_from_id(beat_saber_map_id: str) -> str: + response = requests.get( + f'https://beatsaver.com/api/maps/id/{beat_saber_map_id}', + ) + response.raise_for_status() + response_dict = response.json() + return response_dict['versions'][0]['downloadURL'] + + +def get_feet_saber_maps_from_playlist(playlist_id: str) -> tuple[list[str], str, str]: + response = requests.get( + f'https://beatsaver.com/api/playlists/id/{playlist_id}/0', + ) + response.raise_for_status() + response_dict = response.json() + download_urls = [ + song['map']['versions'][0]['downloadURL'] + for song in response_dict['maps'] + ] + image_url = response_dict['playlist']['playlistImage'] + author = response_dict['playlist']['owner']['name'] + return download_urls, image_url, author + + +def download_image_from_url(url, target_path): + response = requests.get(url, stream=True) + response.raise_for_status() # Raise exception for bad responses + + content_type = response.headers['content-type'] + if 'jpeg' in content_type or 'jpg' in content_type: + ext = '.jpg' + elif 'png' in content_type: + ext = '.png' + else: + ext = '.jpg' + + with open(f'{target_path}{ext}', 'wb') as file: + for chunk in response.iter_content(chunk_size=8192): + file.write(chunk) + + print(f'Downloaded image to {target_path}{ext}') + + return f'{target_path}{ext}' + + +def download_and_extract_zip(url): + tmpdirname = tempfile.mkdtemp() # Manually creating a temporary directory + + try: + response = requests.get(url, stream=True) + zip_path = os.path.join(tmpdirname, 'downloaded.zip') + with open(zip_path, 'wb') as out_file: + for chunk in response.iter_content(chunk_size=8192): + out_file.write(chunk) + + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(tmpdirname) + + return tmpdirname + except Exception as e: + shutil.rmtree(tmpdirname) + raise e -- 2.54.0