324 lines
12 KiB
Python
324 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- encoding: utf-8 -*-
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple, Any, Union, Optional
|
|
import zlib
|
|
import json
|
|
from enum import IntEnum
|
|
|
|
RAGNAROCK_ENVIRONMENTS: Tuple[str, ...] = ("Midgard", "Alfheim", "Nidavellir",
|
|
"Asgard", "Muspelheim", "Helheim",
|
|
# Hellfest
|
|
# DarkEmpty
|
|
)
|
|
|
|
|
|
def text_to_environment(text: str) -> str:
|
|
return RAGNAROCK_ENVIRONMENTS[
|
|
zlib.crc32(str(text).encode('utf-8')) % len(RAGNAROCK_ENVIRONMENTS)
|
|
]
|
|
|
|
|
|
class RagnaRockInfoButDifficulties:
|
|
def __init__(self,
|
|
songName: str = '',
|
|
songSubName: str = '',
|
|
songAuthorName: str = '',
|
|
levelAuthorName: str = '',
|
|
beatsPerMinute: float = 150.00,
|
|
songApproximativeDuration: float = 0,
|
|
previewStartTime: float = 20.25,
|
|
previewDuration: float = 20.00,
|
|
customData: Optional[Dict[str, Any]] = None
|
|
) -> None:
|
|
self.version = '1'
|
|
self.songName = songName
|
|
self.songSubName = songSubName
|
|
self.songAuthorName = songAuthorName
|
|
self.levelAuthorName = levelAuthorName
|
|
self.beatsPerMinute = beatsPerMinute
|
|
# 4/4 timing was HARDCODED on BeatSaber side; no RagnaRock documentation says otherwise
|
|
self._msBetweenTimePoints: float = 60000/(4*self.beatsPerMinute)
|
|
self.shuffle = 0
|
|
self.shufflePeriod = 0.5
|
|
self.songApproximativeDuration = songApproximativeDuration
|
|
self.previewStartTime = previewStartTime
|
|
self.previewDuration = previewDuration
|
|
self.songFilename = 'song.ogg'
|
|
self.coverImageFilename = 'cover.jpg'
|
|
self.environmentName = text_to_environment(
|
|
f'{songName}{songSubName}{songAuthorName}{levelAuthorName}')
|
|
self.songTimeOffset = 0
|
|
self.customData: Dict[str, Any] = customData or {}
|
|
self.difficultyBeatmapSets: list = []
|
|
|
|
def round_to_beat(self, event_ms: Union[int, float]) -> int:
|
|
return round(round(event_ms/self._msBetweenTimePoints)*self._msBetweenTimePoints)
|
|
|
|
def convert_to_beat(self, event_ms: Union[int, float]) -> float:
|
|
return round(round(event_ms/self._msBetweenTimePoints)/4, 2)
|
|
|
|
def with_song_sub_name(self, songSubName: str) -> 'RagnaRockInfoButDifficulties':
|
|
return type(self)(
|
|
self.songName,
|
|
songSubName,
|
|
self.songAuthorName,
|
|
self.levelAuthorName,
|
|
self.beatsPerMinute,
|
|
self.songApproximativeDuration,
|
|
self.previewStartTime,
|
|
self.previewDuration,
|
|
self.customData,
|
|
)
|
|
|
|
def with_difficulties(self, difficultyBeatmapSets: List['RagnaRockDifficultyV1']) -> 'RagnaRockInfo':
|
|
return RagnaRockInfo(
|
|
self.songName,
|
|
self.songSubName,
|
|
self.songAuthorName,
|
|
self.levelAuthorName,
|
|
self.beatsPerMinute,
|
|
self.songApproximativeDuration,
|
|
self.previewStartTime,
|
|
self.previewDuration,
|
|
self.customData,
|
|
difficultyBeatmapSets,
|
|
)
|
|
|
|
|
|
class RagnaRockInfo(RagnaRockInfoButDifficulties):
|
|
def __init__(self,
|
|
songName: str = '',
|
|
songSubName: str = '',
|
|
songAuthorName: str = '',
|
|
levelAuthorName: str = '',
|
|
beatsPerMinute: float = 150.00,
|
|
songApproximativeDuration: float = 0,
|
|
previewStartTime: float = 20.25,
|
|
previewDuration: float = 10.00,
|
|
customData: Optional[Dict[str, Any]] = None,
|
|
difficultyBeatmapSets: Optional[List['RagnaRockDifficultyV1']] = None,
|
|
) -> None:
|
|
super().__init__(
|
|
songName,
|
|
songSubName,
|
|
songAuthorName,
|
|
levelAuthorName,
|
|
beatsPerMinute,
|
|
songApproximativeDuration,
|
|
previewStartTime,
|
|
previewDuration,
|
|
customData,
|
|
)
|
|
self.difficultyBeatmapSets: List['RagnaRockDifficultyV1'] = (
|
|
difficultyBeatmapSets or [])
|
|
self._difficulty_internals = RagnarockDifficultyEnum.pick(
|
|
len(self.difficultyBeatmapSets))
|
|
self.path: Optional[Path] = None
|
|
|
|
def to_jsonable(self) -> dict:
|
|
return {
|
|
"_version": self.version,
|
|
"_songName": self.songName,
|
|
"_songSubName": self.songSubName,
|
|
"_songAuthorName": self.songAuthorName,
|
|
"_levelAuthorName": self.levelAuthorName,
|
|
"_beatsPerMinute": self.beatsPerMinute,
|
|
"_shuffle": self.shuffle,
|
|
"_shufflePeriod": self.shufflePeriod,
|
|
"_previewStartTime": self.previewStartTime,
|
|
"_previewDuration": self.previewDuration,
|
|
"_songApproximativeDuration": round(self.songApproximativeDuration),
|
|
"_songFilename": self.songFilename,
|
|
"_coverImageFilename": self.coverImageFilename,
|
|
"_environmentName": self.environmentName,
|
|
"_songTimeOffset": self.songTimeOffset,
|
|
"_customData": {
|
|
"_warnings": [],
|
|
"_information": [],
|
|
"_suggestions": [],
|
|
"_requirements": [],
|
|
**self.customData
|
|
},
|
|
"_difficultyBeatmapSets": [
|
|
{
|
|
"_beatmapCharacteristicName": "Standard",
|
|
"_difficultyBeatmaps": [
|
|
{
|
|
"_difficulty": di.name,
|
|
"_difficultyRank": df.difficulty_rank,
|
|
"_beatmapFilename": f"{di.name}.dat",
|
|
"_noteJumpMovementSpeed": 10,
|
|
"_noteJumpStartBeatOffset": 0,
|
|
"_customData": {
|
|
"_difficultyLabel": df.difficulty_label,
|
|
"_editorOffset": 0,
|
|
"_editorOldOffset": 0,
|
|
"_difficulty": df.difficulty_rankf,
|
|
"_warnings": [],
|
|
"_information": [],
|
|
"_suggestions": [],
|
|
"_requirements": [],
|
|
** df.customData
|
|
}
|
|
}
|
|
for di, df in zip(self._difficulty_internals, self.difficultyBeatmapSets)
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
def write_to(self, path: Path):
|
|
path.joinpath('info.dat').write_text(json.dumps(
|
|
self.to_jsonable(), ensure_ascii=False, indent=2), encoding='utf-8')
|
|
for di, df in zip(self._difficulty_internals, self.difficultyBeatmapSets):
|
|
path.joinpath(f"{di.name}.dat").write_text(json.dumps(
|
|
df.to_jsonable(), ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
|
|
|
|
@classmethod
|
|
def read_from(cls, path: Path) -> 'RagnaRockInfo':
|
|
clsjson = json.loads(path.read_text(encoding='utf-8', errors='ignore'))
|
|
c = cls(
|
|
songName=clsjson['_songName'],
|
|
songSubName=clsjson['_songSubName'],
|
|
songAuthorName=clsjson['_songAuthorName'],
|
|
levelAuthorName=clsjson['_levelAuthorName'],
|
|
beatsPerMinute=clsjson['_beatsPerMinute'],
|
|
previewStartTime=clsjson['_previewStartTime'],
|
|
previewDuration=clsjson['_previewDuration'],
|
|
customData=clsjson.get('_customData', {}),
|
|
difficultyBeatmapSets=[RagnaRockDifficultyV1.from_json(path.parent/y['_beatmapFilename'],
|
|
y.get('_customData', {}).get(
|
|
'_difficultyLabel') or y['_difficulty'],
|
|
y.get('_customData', {}).get(
|
|
'_difficulty', y['_difficultyRank']),
|
|
clsjson['_beatsPerMinute'],
|
|
)
|
|
for x in clsjson['_difficultyBeatmapSets']
|
|
for y in x['_difficultyBeatmaps']],
|
|
)
|
|
for d in c.difficultyBeatmapSets:
|
|
d.parent = c
|
|
c.path = path
|
|
return c
|
|
|
|
|
|
class RagnarockDifficultyEnum(IntEnum):
|
|
Easy = 3
|
|
Normal = 5
|
|
Hard = 8
|
|
|
|
@classmethod
|
|
def pick(cls, qtty: int) -> List['RagnarockDifficultyEnum']:
|
|
if qtty < 1 or qtty > 3:
|
|
raise ValueError(f'{qtty=} must be in the range [1, 3]')
|
|
return [
|
|
[cls.Normal],
|
|
[cls.Normal, cls.Hard],
|
|
[cls.Easy, cls.Normal, cls.Hard],
|
|
][qtty-1]
|
|
|
|
|
|
class RagnaRockDifficultyV1:
|
|
def __init__(self,
|
|
) -> None:
|
|
self.version = '1'
|
|
self.notes: List[RagnaRockDifficultyNote] = []
|
|
self.difficulty_label: str = 'Normal'
|
|
self.difficulty_rankf: float = 5
|
|
self.bpm: float = 150
|
|
self.customData: Dict[str, Any] = {}
|
|
self.parent: Optional[RagnaRockInfo] = None
|
|
|
|
@property
|
|
def difficulty_rank(self) -> int:
|
|
return round(max(1, self.difficulty_rankf))
|
|
|
|
@difficulty_rank.setter
|
|
def difficulty_rank(self, value: float):
|
|
self.difficulty_rankf = float(value)
|
|
|
|
def to_jsonable(self) -> dict:
|
|
return {
|
|
"_version": self.version,
|
|
"_customData": {
|
|
"_time": 0,
|
|
"_BPMChanges": [],
|
|
"_bookmarks": [],
|
|
"_difficulty": self.difficulty_rankf,
|
|
**self.customData,
|
|
},
|
|
"_events": [],
|
|
"_notes": list(map(RagnaRockDifficultyNote.to_jsonable, self.notes)),
|
|
"_obstacles": [],
|
|
}
|
|
|
|
@classmethod
|
|
def from_json(cls, path: Path, difficulty_name: str, difficulty_rank: int, bpm: float) -> 'RagnaRockDifficultyV1':
|
|
clsjson = json.loads(path.read_text(encoding='utf-8', errors='ignore'))
|
|
self = cls()
|
|
self.difficulty_label = difficulty_name
|
|
self.difficulty_rank = difficulty_rank
|
|
self.customData = clsjson['_customData']
|
|
self.version = clsjson['_version']
|
|
self.bpm = bpm
|
|
self.notes = list(
|
|
map(RagnaRockDifficultyNote.from_jsonable, clsjson['_notes']))
|
|
return self
|
|
|
|
@property
|
|
def beatsPerMinute(self) -> float:
|
|
return self.bpm
|
|
|
|
@property
|
|
def _msBetweenTimePoints(self) -> float:
|
|
return 60000/(4*self.beatsPerMinute)
|
|
|
|
def round_to_beat(self, event_ms: Union[int, float]) -> int:
|
|
return round(round(event_ms/self._msBetweenTimePoints)*self._msBetweenTimePoints)
|
|
|
|
def convert_to_beat(self, event_ms: Union[int, float]) -> float:
|
|
return round(round(event_ms/self._msBetweenTimePoints)/4, 2)
|
|
|
|
|
|
class RagnaRockDifficultyNote:
|
|
def __init__(self,
|
|
time,
|
|
lineIndex,
|
|
) -> None:
|
|
self.time: float = time
|
|
self.lineIndex: int = lineIndex
|
|
|
|
def __repr__(self) -> str:
|
|
return f'{type(self).__name__}({self.time}, {self.lineIndex})'
|
|
|
|
def to_jsonable(self) -> dict:
|
|
return {
|
|
"_time": self.time,
|
|
"_lineIndex": self.lineIndex,
|
|
"_lineLayer": 1,
|
|
"_type": 0,
|
|
"_cutDirection": 1,
|
|
}
|
|
|
|
@classmethod
|
|
def from_jsonable(cls, clsjson: dict) -> 'RagnaRockDifficultyNote':
|
|
return cls(
|
|
time=clsjson['_time'],
|
|
lineIndex=clsjson['_lineIndex'],
|
|
)
|
|
|
|
|
|
class NoteLineIndexEnum(IntEnum):
|
|
'''Bottom-left origin'''
|
|
FarLeft = 0
|
|
LightLeft = 1
|
|
LightRight = 2
|
|
FarRight = 3
|
|
|
|
|
|
class HandEnum(IntEnum):
|
|
left = 0
|
|
right = 1
|