site_liveq/liveq/bms_ragnarock.py

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