pahami semua fungsi pada script ini, yang akan digunakan nanti nya untuk meng improve script selanjut nya : import webbrowser
import pygame
import pygame_gui as pgui
import webbrowser
import pyperclip
import random
import moderngl
import mido
import subprocess
import sys
from os import getcwd, listdir
from os.path import join, isfile
from io import StringIO, BytesIO
from enum import Enum
from math import sin, pi
from pygame import Color
from array import array
from random import randint
from zipfile import ZipFile
from time import time as get_current_time
from json import load, dump, loads
from platform import system
from platform import system as get_os
from typing import Union, Optional, Any
from time import time as get_current_time
from sys import platform, setrecursionlimit
setrecursionlimit(10000)  # increase if more notes

pygame.init()

# noinspection PyBroadException
def print_debug_info():
    debug_info = ""
    debug_info += f"Debug information:\n"
    debug_info += f"os = {system()}\n"
    try:
        debug_info += f"version: {'Binary File' if getattr(sys, 'frozen', False) else 'Source Code'}\n"
    except Exception:
        pass
    debug_info += f"sys.version = {sys.version}\n"
    debug_info += f"framerate = {FRAMERATE}\n"

    for key in Config.__dict__:
        key: str
        v = Config.__dict__[key]
        if key.startswith("__"):
            continue
        if key == "color_themes":
            continue
        if key == "save_attrs":
            v = ", ".join(v)
        debug_info += f"{key} = {v}\n"
    debug_info = debug_info.rstrip("\n")
    print(debug_info)
    pyperclip.copy(debug_info)


def debug_rect(rect: pygame.Rect):
    """Print a copy-pastable equation for debugging in desmos graphing calculator"""
    print(f"\\operatorname\x7Bpolygon\x7D({rect.topleft}, {rect.topright}, {rect.bottomright}, {rect.bottomleft})")


def debug_rectangles(rects: list[pygame.Rect]):
    """Debug rects but multiple"""
    for rect in rects:
        debug_rect(rect)


class Config:
    # constants
    rSCREEN_WIDTH = pygame.display.Info().current_w if pygame.display.Info().current_w else 1920
    rSCREEN_HEIGHT = pygame.display.Info().current_h if pygame.display.Info().current_h else 1080
    # SCREEN_WIDTH = pygame.display.Info().current_w if pygame.display.Info().current_w else 1920
    # SCREEN_HEIGHT = pygame.display.Info().current_h if pygame.display.Info().current_h else 1080
    CAMERA_SPEED = 500
    SQUARE_SIZE = 50
    PARTICLE_SPEED = 10

    # colors
    #
    # each color theme requires a hallway color, a background color, and at least one square color
    # optionally, the color theme provides an hp_bar_border color (default 10, 9, 8),
    # an hp_bar_background color (default 34, 51, 59), and a list
    # of hp_bar_fill colors (default (156, 198, 155), (189, 228, 168), (215, 242, 186))
    #
    color_themes = {
        "dark_modern": {
            "hallway": pygame.Color(40, 44, 52),
            "background": pygame.Color(24, 26, 30),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },

        "dark": {
            "hallway": pygame.Color(214, 209, 205),
            "background": pygame.Color(60, 63, 65),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        # credits to TheCodingCrafter for these themes
        "light": {
            "hallway": pygame.Color(60, 63, 65),
            "background": pygame.Color(214, 209, 205),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "rainbow": {
            "hallway": pygame.Color((214, 209, 205)),
            "background": pygame.Color((60, 63, 65)),
            "square": [
                pygame.Color(0, 0, 0)
            ]
        },
        "autumn": {
            "hallway": pygame.Color((252, 191, 73)),
            "background": pygame.Color((247, 127, 0)),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "winter": {
            "hallway": pygame.Color((202, 240, 255)),
            "background": pygame.Color((0, 180, 216)),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "spring": {
            "hallway": pygame.Color((158, 240, 26)),
            "background": pygame.Color((112, 224, 0)),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "magenta": {
            "hallway": pygame.Color((239, 118, 116)),
            "background": pygame.Color((218, 52, 77)),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "monochromatic": {
            "hallway": pygame.Color((255, 255, 255)),
            "background": pygame.Color((0, 0, 0)),
            "square": [
                pygame.Color(80, 80, 80),
                pygame.Color(150, 150, 150),
                pygame.Color(100, 100, 100),
                pygame.Color(200, 200, 200)
            ]
        },
        "green-screen-hallway": {
            "hallway": pygame.Color(0, 255, 0),
            "background": pygame.Color(60, 63, 65),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        },
        "green-screen-background": {
            "hallway": pygame.Color(60, 63, 65),
            "background": pygame.Color(0, 255, 0),
            "square": [
                pygame.Color(224, 26, 79),
                pygame.Color(173, 247, 182),
                pygame.Color(249, 194, 46),
                pygame.Color(83, 179, 203)
            ]
        }
    }

    # intended configurable settings
    SCREEN_WIDTH = pygame.display.Info().current_w if pygame.display.Info().current_w else 1920
    SCREEN_HEIGHT = pygame.display.Info().current_h if pygame.display.Info().current_h else 1080
    theme: Optional[str] = "dark"
    seed: Optional[int] = None
    camera_mode: Optional[int] = 2
    start_playing_delay = 3000
    max_notes: Optional[int] = None
    bounce_min_spacing: Optional[float] = 30
    square_speed: Optional[int] = 600
    volume: Optional[int] = 70
    music_offset: Optional[int] = 0
    direction_change_chance: Optional[int] = 30
    hp_drain_rate = 10
    theatre_mode = True
    particle_trail = True
    shader_file_name = "none.glsl"
    do_color_bounce_pegs = False
    do_particles_on_bounce = True

    # settings that are not configurable (yet)
    backtrack_chance: Optional[float] = 0.02
    backtrack_amount: Optional[int] = 40
    rainbow_speed: Optional[int] = 30
    square_swipe_anim_speed: Optional[int] = 4
    particle_amount = 10
    language = "english"

    # other random stuff
    current_song = None
    ctx: moderngl.Context = None
    glsl_program: moderngl.Program = None
    render_object: moderngl.VertexArray = None
    screen: pygame.Surface = None
    dt = 0.01

    # ascii shader
    ascii_tex: moderngl.Texture = None

    # keys to save and load
    save_attrs = ["theme", "seed", "camera_mode", "start_playing_delay", "max_notes", "bounce_min_spacing",
                  "square_speed", "volume", "music_offset", "direction_change_chance", "hp_drain_rate", "theatre_mode",
                  "particle_trail", "shader_file_name", "do_color_bounce_pegs", 
                  "do_particles_on_bounce", "language",
                  "SCREEN_WIDTH", "SCREEN_HEIGHT"]

    # glow effect, for dark_modern only for now
    square_glow = True
    square_glow_duration = 0.8
    glow_intensity = 15  # 1-40
    square_min_glow = 7
    border_color = pygame.Color(255, 255, 255)
    glow_color = pygame.Color(255, 255, 255)


def get_colors():
    return Config.color_themes.get(Config.theme, "dark")


def save_to_file(dat: Optional[dict[str, Any]] = None):
    if dat is None:
        dat = {k: getattr(Config, k) for k in Config.save_attrs}
    with open("./assets/settings.json", "w") as f:
        dump(dat, f, indent=4)


def load_from_file():
    try:
        if isfile("./assets/settings.json"):
            with open("./assets/settings.json", "r") as f:
                data = load(f)
                for setting in data:
                    setattr(Config, setting, data[setting])
        else:
            with open("./assets/settings.json", "w") as f:
                f.write('{}')
    except Exception as e:
        print(f"Error: {e}")


if __name__ == "config":
    load_from_file()



class ConfigPage:
    @property
    def made_with_pgui_rect(self):
        return self.made_with_pgui_surf.get_rect(bottomleft=(30, Config.SCREEN_HEIGHT - 30))

    def __init__(self):
        self.active = False
        self.made_with_pgui_surf = get_font(12).render(
            "Config page made with pygame_gui library", True, (255, 255, 255))

        pgui_theme = "{}"
        if lang_key("do-custom-pgui-font"):
            pgui_theme = f"""
            {{
                "label":
                {{
                    "font":
                    {{
                        "name": "hanyisongjian",
                        "regular_path": "./assets/fonts/{lang_key('font')}"
                    }}
                }}
            }}
            """

        # pygame_gui stuff
        self.ui_manager = pgui.UIManager((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT), StringIO(pgui_theme))
        self.back_button = pgui.elements.UIButton(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH - 120, 30, 90, 30)),
            text="Back",
            manager=self.ui_manager
        )

        # all attributes matching /s_.+/ are "s"ettings

        self.s_camera_mode = pgui.elements.UIDropDownMenu(
            ["Center", "Lazy", "Smoothed (Default)", "Predictive"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT // 10, 300, 30)),
            starting_option=["Center", "Lazy", "Smoothed (Default)", "Predictive"][Config.camera_mode],
            manager=self.ui_manager
        )
        self.s_camera_mode_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT // 10 - 30, 240, 30)),
            text=lang_key('camera-mode') + ":",
            manager=self.ui_manager
        )

        self.s_seed = pgui.elements.UITextEntryLine(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 2 // 10, 300, 30)),
            placeholder_text="Default is random number",
            manager=self.ui_manager
        )
        self.s_seed.set_allowed_characters('numbers')
        self.s_seed.set_text(str(Config.seed if Config.seed is not None else ''))
        self.s_seed_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 2 // 10 - 30, 240, 30)),
            text=lang_key('rng-seed') + ":",
            manager=self.ui_manager
        )

        self.s_start_playing_delay = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 3 // 10, 300, 30)),
            start_value=Config.start_playing_delay,
            value_range=(1000, 5000),
            manager=self.ui_manager
        )
        self.s_start_playing_delay_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 3 // 10 - 30, 240, 30)),
            text=lang_key('starting-time-delay') + f" ({self.s_start_playing_delay.get_current_value()}ms):",
            manager=self.ui_manager
        )

        self.s_max_notes = pgui.elements.UITextEntryLine(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 4 // 10, 300, 30)),
            placeholder_text="Default is infinity",
            manager=self.ui_manager
        )
        self.s_max_notes.set_allowed_characters('numbers')
        self.s_max_notes.set_text(str(Config.max_notes if Config.max_notes is not None else ''))
        self.s_max_notes_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 4 // 10 - 30, 240, 30)),
            text=lang_key('max-notes-to-generate') + ":",
            manager=self.ui_manager
        )

        self.s_bounce_min_spacing = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 5 // 10, 300, 30)),
            start_value=Config.bounce_min_spacing,
            value_range=(5, 50),
            manager=self.ui_manager
        )
        self.s_bounce_min_spacing_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 5 // 10 - 30, 240, 30)),
            text=f"{lang_key('bounce-min-spacing')} ({Config.bounce_min_spacing}ms):",
            manager=self.ui_manager
        )

        self.s_square_speed = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 6 // 10, 300, 30)),
            start_value=Config.square_speed,
            value_range=(100, 2000),
            manager=self.ui_manager
        )
        self.s_square_speed_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 6 // 10 - 30, 240, 30)),
            text=f"{lang_key('square-speed')} ({Config.square_speed} pixels/s):",
            manager=self.ui_manager
        )

        self.s_music_offset = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 7 // 10, 300, 30)),
            start_value=Config.music_offset,
            value_range=(-500, 500),
            manager=self.ui_manager
        )
        self.s_music_offset_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 7 // 10 - 30, 240, 30)),
            text=f"{lang_key('music-offset')} ({Config.music_offset}ms):",
            manager=self.ui_manager
        )

        self.s_direction_change_chance = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 8 // 10, 300, 30)),
            start_value=Config.direction_change_chance,
            value_range=(0, 100),
            manager=self.ui_manager
        )
        self.s_direction_change_chance_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 8 // 10 - 30, 240, 30)),
            text=f"{lang_key('change-dir-chance')} ({Config.direction_change_chance}%):",
            manager=self.ui_manager
        )

        self.s_hp_drain_rate = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 9 // 10, 300, 30)),
            start_value=Config.hp_drain_rate,
            value_range=(3, 20),
            manager=self.ui_manager
        )
        self.s_hp_drain_rate_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH // 10, Config.SCREEN_HEIGHT * 9 // 10 - 30, 240, 30)),
            text=f"{lang_key('hp-drain-rate')} ({Config.hp_drain_rate}/s):",
            manager=self.ui_manager
        )

        # audio and general settings

        self.s_game_volume = pgui.elements.UIHorizontalSlider(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT // 10, 300, 30)),
            start_value=Config.volume,
            value_range=(0, 100),
            manager=self.ui_manager
        )
        self.s_game_volume_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT // 10 - 30, 240, 30)),
            text=f"{lang_key('music-volume')} ({Config.volume}%):",
            manager=self.ui_manager
        )

        self.s_color_theme = pgui.elements.UIDropDownMenu(
            [theme for theme in Config.color_themes],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 2 // 10, 300, 30)),
            starting_option=Config.theme,
            manager=self.ui_manager
        )
        self.s_color_theme_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 2 // 10 - 30, 240, 30)),
            text=f"{lang_key('color-theme')}:",
            manager=self.ui_manager
        )

        self.s_theatre_mode = pgui.elements.UIDropDownMenu(
            ["Off", "On"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 3 // 10, 300, 30)),
            starting_option=["Off", "On"][int(Config.theatre_mode)],
            manager=self.ui_manager
        )
        self.s_theatre_mode_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 3 // 10 - 30, 240, 30)),
            text=f"{lang_key('theatre-mode')}:",
            manager=self.ui_manager
        )

        self.s_particle_trail = pgui.elements.UIDropDownMenu(
            ["Off", "On"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 4 // 10, 300, 30)),
            starting_option=["Off", "On"][int(Config.particle_trail)],
            manager=self.ui_manager
        )
        self.s_particle_trail_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 4 // 10 - 30, 240, 30)),
            text=f"{lang_key('particle-trail')}:",
            manager=self.ui_manager
        )

        self.s_shader = pgui.elements.UIDropDownMenu(
            [_ for _ in listdir("./assets/shaders/") if _.endswith(".glsl")],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 5 // 10, 300, 30)),
            starting_option=Config.shader_file_name,
            manager=self.ui_manager
        )
        self.s_shader_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect(Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 5 // 10 - 30, 240, 30),
            text=f"{lang_key('shader')} ({lang_key('restart-required')}):",
            manager=self.ui_manager
        )

        self.s_do_bounce_color_pegs = pgui.elements.UIDropDownMenu(
            ["Off", "On"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 6 // 10, 300, 30)),
            starting_option=["Off", "On"][int(Config.do_color_bounce_pegs)],
            manager=self.ui_manager
        )
        self.s_do_bounce_color_pegs_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 6 // 10 - 30, 240, 30)),
            text=f"{lang_key('color-pegs-on-bounce')}:",
            manager=self.ui_manager
        )

        self.s_do_particles_on_bounce = pgui.elements.UIDropDownMenu(
            ["Off", "On"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 7 // 10, 300, 30)),
            starting_option=["Off", "On"][int(Config.do_color_bounce_pegs)],
            manager=self.ui_manager
        )
        self.s_do_particles_on_bounce_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 7 // 10 - 30, 240, 30)),
            text=f"{lang_key('particles-on-bounce')}:",
            manager=self.ui_manager
        )
        self.s_resolution = pgui.elements.UIDropDownMenu(
            [str(Config.rSCREEN_WIDTH) + "x" + str(Config.rSCREEN_HEIGHT), "800x600", "1024x768", "1280x720",
             "1920x1080"],
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 8 // 10, 300, 30)),
            starting_option=str(Config.SCREEN_WIDTH) + "x" + str(Config.SCREEN_HEIGHT),
            manager=self.ui_manager
        )
        self.s_resolution_label = pgui.elements.UILabel(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH * 5 / 10, Config.SCREEN_HEIGHT * 8 // 10 - 30, 240, 30)),
            text=f"{lang_key('resolution')} ({lang_key('restart-required')}):",
            manager=self.ui_manager
        )

        # reset button

        self.s_reset_button = pgui.elements.UIButton(
            relative_rect=pygame.Rect((Config.SCREEN_WIDTH - 330, Config.SCREEN_HEIGHT - 60, 300, 30)),
            text="Reset to default",
            manager=self.ui_manager
        )

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return

        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                if self.made_with_pgui_rect.collidepoint(pygame.mouse.get_pos()):
                    webbrowser.open("https://github.com/MyreMylar/pygame_gui")

        if event.type == pgui.UI_BUTTON_PRESSED:
            if event.ui_element == self.back_button:
                play_sound("wood.wav")
                return True
            if event.ui_element == self.s_reset_button:
                # todo: rework this
                # todo: this does not work with translation stuffs

                Config.theme = "dark"
                self.s_color_theme.selected_option = "dark"
                self.s_color_theme.current_state.finish()
                self.s_color_theme.current_state.selected_option = "dark"
                self.s_color_theme.current_state.start()

                Config.shader_file_name = "none.glsl"
                self.s_shader.selected_option = "none.glsl"
                self.s_shader.current_state.finish()
                self.s_shader.current_state.selected_option = "none.glsl"
                self.s_shader.current_state.start()

                Config.seed = None
                self.s_seed.set_text("")

                Config.camera_mode = 2
                self.s_camera_mode.selected_option = "Smoothed (Default)"
                self.s_camera_mode.current_state.finish()
                self.s_camera_mode.current_state.selected_option = "Smoothed (Default)"
                self.s_camera_mode.current_state.start()

                Config.start_playing_delay = 3000
                self.s_start_playing_delay.set_current_value(3000)
                self.s_start_playing_delay_label.set_text(
                    f"Starting time delay ({self.s_start_playing_delay.get_current_value()}ms):")

                Config.max_notes = None
                self.s_max_notes.set_text("")

                Config.bounce_min_spacing = 30
                self.s_bounce_min_spacing.set_current_value(30)
                self.s_bounce_min_spacing_label.set_text(f"Bounce min spacing ({Config.bounce_min_spacing}ms):")

                Config.square_speed = 600
                self.s_square_speed.set_current_value(600)
                self.s_square_speed_label.set_text(f"Square speed ({Config.square_speed} pixels/s):")

                Config.volume = 70
                pygame.mixer.music.set_volume(70 / 100)
                self.s_game_volume.set_current_value(70)
                self.s_game_volume_label.set_text(f"Music volume ({Config.volume}%):")

                Config.music_offset = 0
                self.s_music_offset.set_current_value(-300)
                self.s_music_offset_label.set_text(f"Music offset ({Config.music_offset}ms):")

                Config.direction_change_chance = 30
                self.s_direction_change_chance.set_current_value(30)
                self.s_direction_change_chance_label.set_text(f"Change dir chance ({Config.direction_change_chance}%):")

                Config.hp_drain_rate = 10
                self.s_hp_drain_rate.set_current_value(10)
                self.s_hp_drain_rate_label.set_text(f"HP drain rate ({Config.hp_drain_rate}/s):")

                Config.theatre_mode = False
                self.s_theatre_mode.selected_option = "Off"
                self.s_theatre_mode.current_state.finish()
                self.s_theatre_mode.current_state.selected_option = "Off"
                self.s_theatre_mode.current_state.start()

                Config.particle_trail = True
                self.s_particle_trail.selected_option = "On"
                self.s_particle_trail.current_state.finish()
                self.s_particle_trail.current_state.selected_option = "On"
                self.s_particle_trail.current_state.start()

                Config.do_color_bounce_pegs = False
                self.s_do_bounce_color_pegs.selected_option = "Off",
                self.s_do_bounce_color_pegs.current_state.finish()
                self.s_do_bounce_color_pegs.current_state.selected_option = "Off"
                self.s_do_bounce_color_pegs.current_state.start()

                Config.SCREEN_WIDTH = Config.rSCREEN_WIDTH
                Config.SCREEN_HEIGHT = Config.rSCREEN_HEIGHT

                play_sound("wood.wav")

        if event.type == pgui.UI_DROP_DOWN_MENU_CHANGED:
            play_sound("wood.wav")
            if event.ui_element == self.s_camera_mode:
                Config.camera_mode = "CLSP".index(event.text[0])  # awful way of doing this lol
            if event.ui_element == self.s_color_theme:
                Config.theme = event.text
            if event.ui_element == self.s_theatre_mode:
                Config.theatre_mode = bool(["Off", "On"].index(event.text))
            if event.ui_element == self.s_particle_trail:
                Config.particle_trail = bool(["Off", "On"].index(event.text))
            if event.ui_element == self.s_shader:
                Config.shader_file_name = event.text
            if event.ui_element == self.s_do_bounce_color_pegs:
                Config.do_color_bounce_pegs = bool(["Off", "On"].index(event.text))
            if event.ui_element == self.s_do_particles_on_bounce:
                Config.do_particles_on_bounce = bool(["Off", "On"].index(event.text))
            if event.ui_element == self.s_resolution:
                event.text = event.text.split("x")
                Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT = int(event.text[0]), int(event.text[1])

        if event.type == pgui.UI_TEXT_ENTRY_CHANGED:
            if event.ui_element == self.s_seed:
                text: str = event.text
                if text.isnumeric() and len(text):
                    Config.seed = int(text)
                else:
                    Config.seed = None
            if event.ui_element == self.s_max_notes:
                text: str = event.text
                if text.isnumeric() and len(text):
                    Config.max_notes = int(text)
                else:
                    Config.max_notes = None

        if event.type == pgui.UI_HORIZONTAL_SLIDER_MOVED:
            if event.ui_element == self.s_start_playing_delay:
                self.s_start_playing_delay_label.set_text(f"{lang_key('starting-time-delay')} ({event.value}ms):")
                Config.start_playing_delay = event.value
            if event.ui_element == self.s_bounce_min_spacing:
                self.s_bounce_min_spacing_label.set_text(f"{lang_key('bounce-min-spacing')} ({event.value}ms):")
                Config.bounce_min_spacing = event.value
            if event.ui_element == self.s_square_speed:
                rounded = round(event.value, -2)
                self.s_square_speed_label.set_text(f"{lang_key('square-speed')} ({rounded} pixels/s):")
                Config.square_speed = rounded
            if event.ui_element == self.s_game_volume:
                self.s_game_volume_label.set_text(f"{lang_key('music-volume')} ({event.value}%):")
                Config.volume = event.value
                pygame.mixer.music.set_volume(event.value / 100)
            if event.ui_element == self.s_music_offset:
                self.s_music_offset_label.set_text(f"{lang_key('music-offset')} ({event.value}ms):")
                Config.music_offset = event.value
            if event.ui_element == self.s_direction_change_chance:
                self.s_direction_change_chance_label.set_text(f"{lang_key('change-dir-chance')} ({event.value}%):")
                Config.direction_change_chance = event.value
            if event.ui_element == self.s_hp_drain_rate:
                self.s_hp_drain_rate_label.set_text(f"{lang_key('hp-drain-rate')} ({event.value}/s):")
                Config.hp_drain_rate = event.value

        self.ui_manager.process_events(event)

    def draw(self, screen: pygame.Surface):
        if not self.active:
            return
        self.ui_manager.update(1 / FRAMERATE)  # supposed to use dt but whatever
        self.ui_manager.draw_ui(screen)

        screen.blit(self.made_with_pgui_surf, self.made_with_pgui_rect)

class InvalidSongError(Exception):
    """The song is invalid somehow"""
    pass


class UserCancelsLoadingError(Exception):
    """User cancels the loading screen"""
    pass


class MapLoadingFailureError(Exception):
    """The map fails to load (recurs function fails)"""
    pass


class ErrorScreen:
    def __init__(self):
        self.msg = "Unspecified error!!!"
        self.active = False

    def draw(self, screen: pygame.Surface):
        if not self.active:
            return
        error_msg_surface = get_font(18).render(self.msg, True, get_colors()["hallway"])  # todo: cache the error text
        press_esc_to_exit = get_font(24).render("Press escape to go back", True, get_colors()["hallway"])
        screen.blit(error_msg_surface, error_msg_surface.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 3)))
        screen.blit(press_esc_to_exit, press_esc_to_exit.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT * (2 / 3))))


class HitLevel(Enum):
    early = 0
    good = 1
    perfect = 2
    late = 3
    miss = 4


class HitIcon:
    surfaces: dict[HitLevel, pygame.Surface] = {}

    def __init__(self, lvl: HitLevel, pos: Union[tuple[int, int], list[int]]):
        if len(HitIcon.surfaces) == 0:
            for level in HitLevel:
                HitIcon.surfaces[level] = pygame.image.load(f"./assets/{level.name}.png").convert_alpha()

        self.pos = pos
        self.lvl = lvl
        self.age_left = 500
        self.surf = HitIcon.surfaces[lvl].copy()

    def draw(self, screen: pygame.Surface, camera):
        self.age_left -= 500*Config.dt
        self.surf.set_alpha(int(max(min(self.age_left, 255), 0)))
        screen.blit(self.surf, camera.offset(self.surf.get_rect(center=self.pos)))
        return self.age_left <= 0

class Keystrokes:
    def __init__(self):
        self.keys: list[str] = []
        self.key_surfaces: dict[str, pygame.Surface] = {}
        for ascii_keycode in range(97, 123):
            self.key_surfaces[ascii_keycode] = get_font(36).render(
                chr(ascii_keycode), True, (0, 0, 0)
            )
        self.keycodes = list(range(97, 123)) + list(range(1073741903, 1073741907))
        self.key_surfaces[pygame.K_LEFT] = pygame.image.load("./assets/keystrokeicons/left.png").convert_alpha()
        self.key_surfaces[pygame.K_RIGHT] = pygame.image.load("./assets/keystrokeicons/right.png").convert_alpha()
        self.key_surfaces[pygame.K_DOWN] = pygame.image.load("./assets/keystrokeicons/down.png").convert_alpha()
        self.key_surfaces[pygame.K_UP] = pygame.image.load("./assets/keystrokeicons/up.png").convert_alpha()
        self.key_surfaces["click"] = pygame.image.load("./assets/keystrokeicons/click.png").convert_alpha()

    def draw(self, screen: pygame.Surface):
        default_rect = pygame.Rect(10, Config.SCREEN_HEIGHT - 60, 50, 50)
        for keycode in self.keycodes:
            if pygame.key.get_pressed()[keycode]:
                if keycode not in self.keys:
                    self.keys.append(keycode)
            else:
                if keycode in self.keys:
                    self.keys.remove(keycode)
        if pygame.mouse.get_pressed()[0]:
            if "click" not in self.keys:
                self.keys.append("click")
        else:
            if "click" in self.keys:
                self.keys.remove("click")

        for key in self.keys:
            pygame.draw.rect(screen, (0, 0, 0), default_rect)
            pygame.draw.rect(screen, (230, 233, 236), default_rect.inflate(-2, -2))
            screen.blit(self.key_surfaces[key], self.key_surfaces[key].get_rect(center=default_rect.center))
            default_rect.x += 60


def draw_beveled_rectangle(surf: pygame.Surface, color: pygame.Color, rect: pygame.Rect) -> None:
    """i think they're called bevels"""
    pygame.draw.rect(surf, color.lerp((0, 0, 0), 0.4), rect.inflate(8, 8), border_radius=4)
    pygame.draw.rect(surf, color, rect, border_radius=2)


class MenuOption:
    HEIGHT = (Config.SCREEN_HEIGHT//9)-2
    SPACING = 16

    def __init__(self, id_: str, color: pygame.Color):
        self.id = id_
        title = lang_key(id_)  # translate the actual title
        self.color = color
        self.before_hover = False
        self.hover_anim: float = 0

        # this type of graphics programming is hands down the most boring thing i've ever done
        font = get_font(Config.SCREEN_HEIGHT // 15)
        title_surface = font.render(title, True, color.lerp((0, 0, 0), 0.7))
        brighter_title_surface = font.render(title, True, color.lerp((0, 0, 0), 0.2))
        shadow_surface = pygame.Surface((brighter_title_surface.get_rect().width + 100, MenuOption.HEIGHT), pygame.SRCALPHA)
        for _ in range(100):
            chopped = pygame.transform.chop(brighter_title_surface, pygame.Rect(0, MenuOption.HEIGHT-9+lang_key("font-menu-shadow-length-offset") - _, 0, 600))
            shadow_surface.blit(chopped, (_, _))

        self.surface = pygame.Surface((int(Config.SCREEN_WIDTH / 2 + 300), self.HEIGHT + 8), pygame.SRCALPHA)
        draw_beveled_rectangle(self.surface, self.color, pygame.Rect(4, 4, Config.SCREEN_WIDTH, MenuOption.HEIGHT))
        title_rect = title_surface.get_rect(midleft=self.surface.get_rect().midleft).move(50, 0)
        self.surface.blit(shadow_surface, shadow_surface.get_rect(midleft=title_rect.midleft).move(0, lang_key("font-menu-shadow-offset")))
        self.surface.blit(title_surface, title_rect)

    def update_hover(self, y_value: int) -> None:
        wide_rect = pygame.Rect(0, y_value, Config.SCREEN_WIDTH, MenuOption.HEIGHT)
        wide_rect.move_ip(int(Config.SCREEN_WIDTH / 2) - 30 * interpolate_fn(self.hover_anim), 0)
        if wide_rect.collidepoint(pygame.mouse.get_pos()):
            self.hover_anim += 9 / FRAMERATE
        else:
            self.hover_anim -= 3 / FRAMERATE
        self.hover_anim = max(min(self.hover_anim, 1), 0)

    def get_rect(self, y_value: int) -> pygame.Rect:
        wide_rect = pygame.Rect(0, y_value, Config.SCREEN_WIDTH, MenuOption.HEIGHT)
        wide_rect.move_ip(int(Config.SCREEN_WIDTH / 2) - 60 * interpolate_fn(self.hover_anim), 0)
        return wide_rect


class Menu:
    def __init__(self):
        self.menu_options: list[MenuOption] = [
            MenuOption("play", pygame.Color(214, 247, 163)),
            MenuOption("config", pygame.Color(196, 255, 178)),
            MenuOption("contribute", pygame.Color(183, 227, 204)),
            MenuOption("open-songs-folder", pygame.Color(125, 130, 184)),
            MenuOption("quit", pygame.Color(226, 109, 92))
        ]
        self.anim = 1
        # note that there are spaces after each line of code in the marquee text
        self.marquee_text = lang_key("title-marquee")
        self.title_surf = get_font(72).render(lang_key("title"), True, get_colors()["hallway"])
        self.marquee_surf = get_font(24).render(self.marquee_text, True, get_colors()["hallway"])
        self.flags = {}
        for language in TRANSLATIONS:
            self.flags[language] = pygame.image.load(f"./assets/flags/{language}.png").convert_alpha()
        self.prev_active = True
        self.active = True
        self.square = Square(100, 320)
        self.particles: list[Particle] = []

        self.left_lang_rect: Optional[pygame.Rect] = None
        self.right_lang_rect: Optional[pygame.Rect] = None
        self.requires_restart_surf: Optional[pygame.Surface] = None

    @property
    def screensaver_rect(self):
        return pygame.Rect(0, 0, int(Config.SCREEN_WIDTH / 2 - 100), Config.SCREEN_HEIGHT).inflate(-100, -100)

    def draw(self, screen: pygame.Surface, n_frames: int):
        if self.active:
            if self.anim == 0:
                self.anim = 0.3
            self.anim += 1.8 / FRAMERATE
        else:
            self.anim -= 1.8 / FRAMERATE
        self.anim = max(min(self.anim, 1), 0)
        if not self.anim:
            return

        if self.active and not self.prev_active:
            self.title_surf = get_font(72).render(lang_key("title"), True, get_colors()["hallway"])
            self.marquee_surf = get_font(24).render(self.marquee_text, True, get_colors()["hallway"])
        self.prev_active = self.active

        # interesting here
        if self.active:
            # x_offset = interpolate_fn(1-self.anim)*(self.screensaver_rect.width*2)
            x_offset = 0

            sqrect = self.square.rect
            if (get_current_time() - 0.25) < self.square.last_bounce_time:
                lerp = abs((get_current_time() - 0.25) - self.square.last_bounce_time) * 5
                lerp = lerp ** 2  # square it for better-looking interpolation
                if self.square.latest_bounce_direction:
                    sqrect.inflate_ip((lerp * 5, -10 * lerp))
                else:
                    sqrect.inflate_ip((-10 * lerp, lerp * 5))

            pygame.draw.rect(screen, get_colors()["hallway"], self.screensaver_rect.move(-x_offset, 0))

            # particle trail
            if Config.particle_trail:
                # every 2 frames add a particle
                if n_frames % 2 == 0:
                    new = Particle(self.square.pos, [0, 0], True)
                    new.color = get_colors()["background"]
                    new.delta = [randint(-10, 10)/20, randint(-10, 10)/20]
                    self.particles.append(new)

            # particles
            for particle in self.particles:
                pygame.draw.rect(screen, particle.color, particle.rect)
            for remove_particle in [particle for particle in self.particles if particle.age()]:
                self.particles.remove(remove_particle)

            self.square.draw(screen, sqrect.move(-x_offset, 0))
            bounced = self.square.title_screen_physics(self.screensaver_rect.move(-x_offset, 0))
            if bounced:
                latest_dir = self.square.latest_bounce_direction
                sd = self.square.dir.copy()
                sd[latest_dir] *= -1
                sd[1-latest_dir] = 0
                sp = self.square.pos
                for _ in range(Config.particle_amount):
                    new = Particle([sp[0]+randint(-10, 10), sp[1]+randint(-10, 10)], sd)
                    self.particles.append(new)

        # title text
        title_surf_loc_rect = self.title_surf.get_rect(midtop=(Config.SCREEN_WIDTH*3/4, 60))
        self.title_surf.set_alpha(max(int(interpolate_fn(self.anim)*400-145), 0))
        screen.blit(self.title_surf, title_surf_loc_rect)

        # marquee text
        time = pygame.time.get_ticks()
        cropped_marquee = pygame.Surface((self.title_surf.get_width(), self.marquee_surf.get_height()), pygame.SRCALPHA)
        draw_marquee_position = [self.title_surf.get_width()-time/5, 0]
        while draw_marquee_position[0] < -self.marquee_surf.get_width():
            draw_marquee_position[0] += self.marquee_surf.get_width()+self.title_surf.get_width()+10
        cropped_marquee.blit(self.marquee_surf, draw_marquee_position)
        cropped_marquee.set_alpha(max(int(interpolate_fn(self.anim)*400-145), 0))
        screen.blit(cropped_marquee, cropped_marquee.get_rect(topleft=title_surf_loc_rect.bottomleft))

        # options
        for index, option in enumerate(self.menu_options):
            y_value = index * (option.HEIGHT + option.SPACING) + 250
            # update the hover if completely active
            if self.anim == 1:
                option.update_hover(y_value)

            # draw the option
            x_movement = interpolate_fn(1 - self.anim) * 60

            rect = option.get_rect(y_value).move(x_movement, 0)
            option.surface.set_alpha(int(interpolate_fn(self.anim)*255))
            screen.blit(option.surface, rect)

            # for sounds
            new_hover = rect.collidepoint(pygame.mouse.get_pos())
            if new_hover and not option.before_hover:
                play_sound("wood.wav")
            option.before_hover = new_hover

        # flag chooser
        current_flag = self.flags[Config.language]
        cfrect = current_flag.get_rect(bottomright=(Config.SCREEN_WIDTH-100, Config.SCREEN_HEIGHT-30))
        screen.blit(current_flag, cfrect)
        left_arrow = get_font(72, "poppins-regular.ttf").render("<", True, get_colors()["hallway"])
        right_arrow = get_font(72, "poppins-regular.ttf").render(">", True, get_colors()["hallway"])
        self.left_lang_rect = left_arrow.get_rect(midright=cfrect.midleft).move(-5, 5)
        screen.blit(left_arrow, self.left_lang_rect)
        self.right_lang_rect = right_arrow.get_rect(midleft=cfrect.midright).move(5, 5)
        screen.blit(right_arrow, self.right_lang_rect)
        if self.requires_restart_surf is not None:
            screen.blit(self.requires_restart_surf, self.requires_restart_surf.get_rect(midbottom=cfrect.midtop).move(0, -10))

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return
        for index, option in enumerate(self.menu_options):
            rect = option.get_rect(index * (option.HEIGHT + option.SPACING) + 250)
            if (event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and rect.collidepoint(
                    pygame.mouse.get_pos())) \
                    or (event.type == pygame.KEYDOWN and event.key == 49 + index):
                play_sound("select.mp3")
                return option.id
        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button in (1, 2, 3):
                # inflate for more enjoyment (aim trainer minigame !?!? crazy)
                if self.square.rect.inflate(20, 20).collidepoint(pygame.mouse.get_pos()):
                    play_sound("wood.wav", 1)
                    self.square.dir[randint(0, 1)] *= -1
                if self.right_lang_rect.inflate(10, 10).collidepoint(pygame.mouse.get_pos()):
                    play_sound("wood.wav", 1)
                    languages = list(TRANSLATIONS.keys())
                    Config.language = languages[(languages.index(Config.language)+1) % len(languages)]
                    self.requires_restart_surf = get_font(18).render(lang_key('restart-required'), True, get_colors()["hallway"])
                if self.left_lang_rect.inflate(10, 10).collidepoint(pygame.mouse.get_pos()):
                    play_sound("wood.wav", 1)
                    languages = list(TRANSLATIONS.keys())
                    Config.language = languages[(languages.index(Config.language)-1) % len(languages)]
                    self.requires_restart_surf = get_font(18).render(lang_key('restart-required'), True, get_colors()["hallway"])

class Scorekeeper:
    def __init__(self, world):
        self.world = world
        self.unhit_notes: list[float] = []
        self.hit_icons: list[HitIcon] = []
        self.hp = 100
        self.shown_hp = 0

    @property
    def life_bar_rect(self):
        return pygame.Rect((10, 10, int(Config.SCREEN_WIDTH / 2 - 30), 30))

    def draw(self, screen: pygame.Surface, current_time: float, misses: int):
        self.hp = max(0, min(self.hp, 100))
        bg_color = get_colors().get("hp_bar_background", pygame.Color(34, 51, 59))
        border_color = get_colors().get("hp_bar_border", pygame.Color(10, 9, 8))
        fill_colors = get_colors().get("hp_bar_fill", (
            pygame.Color(156, 198, 155), pygame.Color(189, 228, 168), pygame.Color(215, 242, 186)
        ))
        screen.fill(border_color, self.life_bar_rect)
        inner = self.life_bar_rect.inflate(-8, -8)
        screen.fill(bg_color, inner)
        bar_width = inner.width / len(fill_colors)
        bar_hp_repr = 100 / len(fill_colors)
        leftover = self.shown_hp
        for _ in range(len(fill_colors)):
            chunk_rect = inner.copy()
            chunk_rect.x = inner.x + bar_width * _
            width = min(max(leftover, 0), int(bar_hp_repr)+1)/bar_hp_repr*bar_width
            leftover -= bar_hp_repr
            chunk_rect.width = width
            screen.fill(fill_colors[_], chunk_rect)
        if current_time > 0:
            self.hp -= Config.hp_drain_rate*Config.dt
        hp_show_damping = 10
        self.shown_hp = self.shown_hp*(1-hp_show_damping*Config.dt)+self.hp*(hp_show_damping*Config.dt)

        # remove unhit notes
        to_remove = []
        for timestamp in self.unhit_notes:
            if timestamp+0.12 < current_time and not self.world.square.died:  # 120ms late is missed note
                self.hp -= 6
                self.hit_icons.append(HitIcon(HitLevel.miss, self.world.square.pos))
                to_remove.append(timestamp)
                misses = misses if misses is not None else 0
                misses += 1

        for t_remove in to_remove:
            self.unhit_notes.remove(t_remove)

        return misses

    def do_keypress(self, current_time: float, misses: int):
        # negative closest means hit before, positive means hit after
        for timestamp in self.unhit_notes:
            offset = current_time-timestamp
            if not self.world.square.died:
                if offset > 0.06:  # 60ms late - 120ms late
                    self.hit_icons.append(HitIcon(HitLevel.late, self.world.square.pos.copy()))
                    self.hp -= 1
                    misses += 1

                elif offset > -0.06:  # 60ms early - 60ms late
                    self.hit_icons.append(HitIcon(HitLevel.perfect, self.world.square.pos.copy()))
                    self.hp += 3

                elif offset > -0.09:  # 60ms early - 90ms early
                    self.hit_icons.append(HitIcon(HitLevel.good, self.world.square.pos.copy()))
                    self.hp -= 1

                elif offset > -0.12:  # 90ms early - 120ms early
                    self.hit_icons.append(HitIcon(HitLevel.early, self.world.square.pos.copy()))
                    self.hp -= 2
                    misses += 1

                else:
                    return misses
            self.unhit_notes.remove(timestamp)
            return misses
    
    def should_hit(self, current_time: float, blacklist: list):
        # negative closest means hit before, positive means hit after
        for timestamp in self.unhit_notes:
            offset = current_time-timestamp
            if timestamp in blacklist: continue
            if offset > 0.06:  # 60ms late - 120ms late
                pass

            elif offset > -0.06:  # 60ms early - 60ms late
                blacklist.append(timestamp)
                return True, blacklist

            elif offset > -0.09:  # 60ms early - 90ms early
                pass

            elif offset > -0.12:  # 90ms early - 120ms early
                pass

            else:
                return False, blacklist
            
            return False, blacklist

class Song:
    def __init__(self, name: str, song_artist: str, mapper: str,
                 song_file: str, audio_file: str, version: int = -1, filepath: Optional[str] = None,
                 is_from_osu_file: bool = False):
        self.is_from_osu_file = is_from_osu_file
        self.song_file_name: str = song_file
        self.audio_file_name: str = audio_file if audio_file is not None else self.song_file_name
        self.mapper = mapper
        self.fp = filepath
        self.song_artist = song_artist
        self.name = name
        self.version = version  # metadata format version, not the map version
        self.music_offset = 0  # put integer that represents milliseconds, negative is music is before

        self.anim = 0
        col = pygame.Color(229, 97, 196)
        selected_col = pygame.Color(50, 203, 255)

        reset_rect = pygame.Rect((0, 0, int(Config.SCREEN_WIDTH / 2), SongSelector.ITEM_HEIGHT))

        self.surface: pygame.Surface = pygame.Surface((int(Config.SCREEN_WIDTH / 2), SongSelector.ITEM_HEIGHT), pygame.SRCALPHA)
        self.selected_surface: pygame.Surface = pygame.Surface((int(Config.SCREEN_WIDTH / 2), SongSelector.ITEM_HEIGHT), pygame.SRCALPHA)

        overlay_surf = pygame.Surface((int(Config.SCREEN_WIDTH / 2), SongSelector.ITEM_HEIGHT), pygame.SRCALPHA)

        pygame.draw.rect(self.surface, col.lerp((0, 0, 0), 0.4), reset_rect, border_radius=6)
        pygame.draw.rect(self.surface, col, reset_rect.inflate(-8, -8), border_radius=2)
        pygame.draw.rect(self.selected_surface, selected_col.lerp((0, 0, 0), 0.4), reset_rect, border_radius=6)
        pygame.draw.rect(self.selected_surface, selected_col, reset_rect.inflate(-8, -8), border_radius=2)

        title_surface: pygame.Surface = get_font(36).render(self.name, True, (0, 0, 6))
        overlay_surf.blit(title_surface, title_surface.get_rect(topright=overlay_surf.get_rect().topright).move(-20, 20))

        if not is_from_osu_file:
            details_surface: pygame.Surface = get_font(
                24).render(f"Song by {self.song_artist} | Mapped by {self.mapper}", True, (0, 0, 0))
        else:
            details_surface: pygame.Surface = get_font(24).render(f"WARNING: EXPERIMENTAL!!!", True, (0, 0, 0))
        overlay_surf.blit(details_surface, details_surface.get_rect(bottomright=overlay_surf.get_rect().bottomright).move(-20, -20))

        self.surface.blit(overlay_surf, (0, 0))
        self.selected_surface.blit(overlay_surf, (0, 0))

        self.before_hover = False

    def __repr__(self):
        return f"<Song({self.song_file_name}, audio={self.audio_file_name})>"


def song_from_osu_file(contents: str, songfilepath: str, zipfilepath: str) -> Song:
    audio_name = ""
    name = ""
    artist = ""
    mapper = ""

    for line in contents.splitlines(False):
        if line.startswith("AudioFilename: "):
            audio_name = line.removeprefix("AudioFilename: ")
        if line.startswith("Title:"):
            name = line.removeprefix("Title:").lstrip()
        if line.startswith("Artist:"):
            artist = line.removeprefix("Artist:").lstrip()
        if line.startswith("Creator:"):
            mapper = line.removeprefix("Creator:").lstrip()
        if line.startswith("Version:"):
            # janky asf but whatever
            mapper += f' | {line.removeprefix("Version:").lstrip()}'

    return Song(
        name=name, song_artist=artist, mapper=mapper,
        song_file=songfilepath, audio_file=audio_name,
        version=-2, filepath=zipfilepath, is_from_osu_file=True
    )


def make_songs_from_osz(fpath: str) -> list[Song]:
    songs = []
    with ZipFile(fpath) as zf:
        files = zf.filelist
        for fileinfo in files:
            if fileinfo.filename.endswith(".osu"):
                songs.append(song_from_osu_file(zf.read(fileinfo.filename).decode('utf-8'), fileinfo.filename, fpath))
    return songs


def make_song_from_zip(fpath: str) -> Song:
    with ZipFile(fpath) as zf:
        metadata_info = zf.getinfo("metadata.json")
        metadata: dict[str, Any] = loads(zf.read(metadata_info))
        if "name" not in metadata:
            raise InvalidSongError(f"No name in metadata of {fpath}")
        if "mapper" not in metadata:
            raise InvalidSongError(f"No mapper in metadata of {fpath}")
        if "audio_file" not in metadata:
            raise InvalidSongError(f"No audio_file in metadata of {fpath}")
        if "song_file" not in metadata:
            raise InvalidSongError(f"No song_file in metadata of {fpath}")
        if "version" not in metadata:
            raise InvalidSongError(f"No version in metadata of {fpath}")
        name = metadata.get("name")
        artist = metadata.get("artist", metadata.get("author", "Unknown Artist"))
        mapper = metadata.get("mapper")
        audio_file = metadata.get("audio_file")
        song_file = metadata.get("song_file")
        version = metadata.get("version", -1)
        new_song = Song(name, audio_file=audio_file, song_file=song_file, song_artist=artist, mapper=mapper, version=version, filepath=fpath)
        if metadata.get("version") >= 2:
            new_song.music_offset = metadata.get("music_offset", 0)
        return new_song


class SongSelector:
    ITEM_HEIGHT = 140
    ITEM_SPACING = 10
    SCROLL_SPEED = 20

    def __init__(self):
        self.songs: list[Song] = []
        self.reload_songs()
        self.scroll = 0
        self.scroll_velocity = 0
        self.active = False
        self.selected_index = -1
        self.anim = 0
        self.play_button_rect = pygame.Rect(Config.SCREEN_WIDTH - 300, 150, 250, 100)
        self.play_button_text = get_font(48).render(lang_key("play"), True, (0, 0, 0))
        self.back_button_rect = pygame.Rect(Config.SCREEN_WIDTH - 300, 20, 250, 150 - 40)
        self.back_button_text = get_font(48).render(lang_key("back"), True, (0, 0, 0))

    def reload_songs(self):
        self.songs = []
        for song_name in reversed(listdir("songs")):
            path = join("songs", song_name)
            if isfile(path):
                if path.lower().endswith(".zip") or path.lower().endswith(".midiplayground"):
                    self.songs.append(make_song_from_zip(path))
                if path.lower().endswith(".osz"):
                    self.songs.extend(make_songs_from_osz(path))

    def get_song_rect(self, index: int):
        rect = pygame.Rect(
            -200,
            index * (SongSelector.ITEM_HEIGHT + SongSelector.ITEM_SPACING) + 100 + self.scroll,
            int(Config.SCREEN_WIDTH / 2),
            SongSelector.ITEM_HEIGHT
        )
        rect.move_ip(-int(abs(Config.SCREEN_HEIGHT / 2 - rect.centery)) / 10, 0)
        rect.move_ip(interpolate_fn(self.songs[index].anim) * 60, 0)
        return rect

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return
        if event.type == pygame.MOUSEWHEEL:
            self.scroll_velocity += event.y / 2
        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                for index, song in enumerate(self.songs):
                    rect = self.get_song_rect(index)
                    if rect.collidepoint(pygame.mouse.get_pos()):
                        if self.selected_index == index:
                            continue
                        play_sound("select.mp3")
                        with ZipFile(song.fp) as zf:
                            if song.audio_file_name.lower().endswith(".osu"):
                                pygame.mixer.music.load(zf.read(song.audio_file_name))
                            else:
                                with zf.open(song.audio_file_name) as f:
                                    pygame.mixer.music.load(BytesIO(f.read()))
                        pygame.mixer.music.set_volume(Config.volume/100)
                        pygame.mixer.music.play()
                        self.selected_index = index
                        return
                if self.play_button_rect.collidepoint(pygame.mouse.get_pos()):
                    if self.selected_index+1:
                        play_sound("select.mp3")
                        pygame.mixer.music.stop()
                        self.active = False
                        return self.songs[self.selected_index]
                if self.back_button_rect.collidepoint(pygame.mouse.get_pos()):
                    play_sound("select.mp3")
                    self.active = False
                    return True

    def draw(self, screen: pygame.Surface):
        if self.active:
            self.anim += 1.8 / FRAMERATE
        else:
            self.anim = 0

        self.anim = max(min(self.anim, 1), 0)
        if self.anim == 0:
            return

        self.scroll_velocity = min(max(self.scroll_velocity, -1), 1) * (1 - 8 / FRAMERATE)
        keys = pygame.key.get_pressed()
        self.scroll += self.scroll_velocity * SongSelector.SCROLL_SPEED * (1 + keys[pygame.K_LSHIFT])

        for index, song in enumerate(self.songs):
            rect = self.get_song_rect(index)
            if not screen.get_rect().colliderect(rect):
                continue

            # for sounds
            new_hover = rect.collidepoint(pygame.mouse.get_pos())
            if new_hover and not song.before_hover:
                play_sound("wood.wav")
            song.before_hover = new_hover

            # for anim
            if new_hover:
                if self.anim == 0:
                    self.anim = 0.3
                song.anim += 3.6 / FRAMERATE
            else:
                song.anim -= 3.6 / FRAMERATE

            song.anim = max(min(song.anim, 1), 0)

            if self.selected_index == index:
                # song.selected_surface.set_alpha(int(self.anim*255))
                screen.blit(song.selected_surface, rect.move(80, 0))
            else:
                # song.surface.set_alpha(int(self.anim*255))
                screen.blit(song.surface, rect)

        if self.scroll > 100:
            self.scroll_velocity -= self.scroll/2000

        self.back_button_rect.x = Config.SCREEN_WIDTH - 500 * interpolate_fn(self.anim) + 200
        back_button_hovered = self.back_button_rect.collidepoint(pygame.mouse.get_pos())
        pygame.draw.rect(screen, pygame.Color(226, 109, 92).lerp((0, 0, 0), 0.4), self.back_button_rect, border_radius=8)
        pygame.draw.rect(
            screen, pygame.Color(226, 109, 92).lerp(
                (255, 255, 255), back_button_hovered * 0.1
            ), self.back_button_rect.inflate(-8, -8), border_radius=2
        )
        screen.blit(self.back_button_text, self.back_button_text.get_rect(center=self.back_button_rect.center))

        # only continue from here if something is selected
        if not self.selected_index + 1:
            return

        # play button
        self.play_button_rect.x = Config.SCREEN_WIDTH - 500 * interpolate_fn(self.anim) + 200
        play_button_hovered = self.play_button_rect.collidepoint(pygame.mouse.get_pos())
        pygame.draw.rect(screen, [int(_) for _ in (143 * 0.4, 247 * 0.4, 167 * 0.4)], self.play_button_rect,
                         border_radius=8)
        pygame.draw.rect(
            screen, pygame.Color(143, 247, 167).lerp(
                (255, 255, 255), play_button_hovered * 0.2
            ), self.play_button_rect.inflate(-8, -8), border_radius=2
        )
        screen.blit(self.play_button_text, self.play_button_text.get_rect(center=self.play_button_rect.center))

contributors = "quasar098, TheCodingCrafter, PurpleJuiceBox, sled45, Times0, Spring-Forever-with-me, cangerjun, lucmsilva651, leo539, simpansoftware, slideglide, Guavvva, zetlen, SpeckyYT, suzuuuuu09"

TRANSLATIONS = {
    "english": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Play",
        "config": "Config",
        "contribute": "Contribute",
        "open-songs-folder": "Open Songs Folder",
        "quit": "Quit",

        "back": "Back",
        "title": "midi-playground",
        "title-marquee": f"""Contributors: {contributors}. Just want to 
watch the square and don't want to play the game? Turn on theatre mode in the config if that is the case. Interested in playing your 
own song but don't know how? Check out docs/SONGS.md in the source code to learn how to add your own songs.""".replace(
            "\n", ''),

        "do-custom-pgui-font": False,
        'camera-mode': 'Camera mode',
        "rng-seed": "RNG seed",
        "starting-time-delay": "Starting time delay",
        "max-notes-to-generate": "Max notes to generate",
        "bounce-min-spacing": "Bounce min spacing",
        "square-speed": "Square speed",
        "music-offset": "Music offset",
        "change-dir-chance": "Change dir chance",
        "hp-drain-rate": "HP drain rate",
        "music-volume": "Music volume",
        "color-theme": "Color theme",
        "theatre-mode": "Theatre mode",
        "particle-trail": "Particle trail",
        "shader": "Shader",
        "color-pegs-on-bounce": "Color Pegs on Bounce",
        "particles-on-bounce": "Particles on bounce",
        "resolution": "Resolution",

        "restart-required": "Restart Required"
    },
    "brazilian-pt": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Jogar",
        "config": "Configurações",
        "contribute": "Contribuir",
        "open-songs-folder": "Abrir Pasta de Músicas",
        "quit": "Sair",

        "back": "Voltar",
        "title": "midi-playground",
        "title-marquee": f"""Contribuintes: {contributors}. 
Quer apenas assistir ao quadrado e não quer jogar o jogo? Se for esse o caso, ative o Modo Teatro nas configurações. Tem interesse em colocar sua própria música, 
mas não sabe como? Consulte docs/SONGS.md no código-fonte para saber como adicionar suas próprias músicas.""".replace(
            "\n", ''),

        "do-custom-pgui-font": False,
        "camera-mode": "Modo câmera",
        "rng-seed": "Semente RNG",
        "starting-time-delay": "Atraso inicial",
        "max-notes-to-generate": "Máx. de notas a gerar",
        "bounce-min-spacing": "Espaçamento mínimo do salto",
        "square-speed": "Velocidade do quadrado",
        "music-offset": "Deslocamento da música",
        "change-dir-chance": "Chance de mudar de direção",
        "hp-drain-rate": "Taxa de drenagem de HP",
        "music-volume": "Volume da música",
        "color-theme": "Tema de cores",
        "theatre-mode": "Modo teatro",
        "particle-trail": "Rastro de partículas",
        "shader": "Shader",
        "color-pegs-on-bounce": "Pinos coloridos no salto",
        "particles-on-bounce": "Partículas no salto",
        "resolution": "Resolução",

        "restart-required": "Reinicialização Necessária"
    },
    "spanish": {
        "font": "poppins-regular.ttf",
    
        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Jugar",
        "config": "Configuración",
        "contribute": "Contribuir",
        "open-songs-folder": "Abrir Carpeta de Canciones",
        "quit": "Salir",
    
        "back": "Volver",
        "title": "midi-playground",
        "title-marquee": f"""Colaboradores: {contributors}. 
    ¿Solo quieres ver el cuadrado y no quieres jugar? Si es así, activa el Modo Teatro en la configuración. ¿Interesado en agregar tu propia canción pero no sabes cómo? Consulta docs/SONGS.md en el código fuente para aprender a añadir tus propias canciones.""".replace(
            "\n", ''),
    
        "do-custom-pgui-font": False,
        "camera-mode": "Modo cámara",
        "rng-seed": "Semilla RNG",
        "starting-time-delay": "Retraso inicial",
        "max-notes-to-generate": "Máx. de notas a generar",
        "bounce-min-spacing": "Espaciado mínimo de rebote",
        "square-speed": "Velocidad del cuadrado",
        "music-offset": "Desfase de la música",
        "change-dir-chance": "Probabilidad de cambiar de dirección",
        "hp-drain-rate": "Tasa de drenaje de HP",
        "music-volume": "Volumen de la música",
        "color-theme": "Tema de colores",
        "theatre-mode": "Modo teatro",
        "particle-trail": "Estela de partículas",
        "shader": "Shader",
        "color-pegs-on-bounce": "Pernos de color en el rebote",
        "particles-on-bounce": "Partículas en el rebote",
        "resolution": "Resolución",
    
        "restart-required": "Reinicio Necesario"
    },
    "chinese": {
        "font": "hanyisongjian.ttf",

        "font-menu-shadow-offset": 24,
        "font-menu-shadow-length-offset": -15,
        "play": "开始游戏",
        "config": "设置",
        "contribute": "贡献",
        "open-songs-folder": "打开歌曲文件",
        "quit": "退出",

        "back": "返回",
        "title": "midi游乐场",
        "title-marquee": f"""贡献者: {contributors}. 快来加入我们！
想要看着方块自己动，不想玩游戏？
如果是这样，请在设置中打开剧场模式。
想要播放自己喜欢的歌曲，但不知道如何播放？
查看文件夹中的docs/SONGS.md，了解如何添加自己喜欢的歌曲。""".replace("\n", ''),

        "do-custom-pgui-font": True,
        "camera-mode": "相机模型",
        "rng-seed": "随机粒子数",
        "starting-time-delay": "开始时间延迟",
        "max-notes-to-generate": "最大音符生成数",
        "bounce-min-spacing": "最小弹跳间隔时间",
        "square-speed": "方块速度",
        "music-offset": "音乐偏移",
        "change-dir-chance": "改变方向概率",
        "hp-drain-rate": "HP的衰减速率",
        "music-volume": "音乐音量",
        "color-theme": "颜色主题",
        "theatre-mode": "剧院模式",
        "particle-trail": "粒子轨迹",
        "shader": "着色器",
        "color-pegs-on-bounce": "弹跳时是否显示颜色",
        "particles-on-bounce": "弹跳时是否显示粒子",
        "resolution": "分辨率",

        "restart-required": "需要重新启动"
    },
    "french": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Jouer",
        "config": "Configuration",
        "contribute": "Contribuer",
        "open-songs-folder": "Dossier Chansons",
        "quit": "Quitter",

        "back": "Précédent",
        "title": "midi-playground",
        "title-marquee": f"""Contributeurs: {contributors}. Vous voulez simplement 
regarder le carré et ne pas jouer au jeu ? Activez le mode théâtre dans la configuration si tel est le cas. Vous êtes intéressé à jouer votre propre chanson mais ne savez pas comment faire ? 
Consultez docs/SONGS.md dans le code source pour apprendre comment ajouter vos propres chansons.""".replace("\n", ''),

        "restart-required": "Redémarrage nécessaire"
    },
    "swedish": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Spela",
        "config": "Inställningar",
        "contribute": "Bidra",
        "open-songs-folder": "Öppna låt-mappen",
        "quit": "Avsluta",

        "back": "Tillbaka",
        "title": "midi-playground",
        "title-marquee": f"""Medhjälpare: {contributors}. Vill du bara se 
kvadraten och inte vill spela? Sätt på teaterläge i konfigurationerna i så fall. Intresserad i att spela dina egna låtar men du vet inte hur? 
Kolla docs/SONGS.md i källkoden för att lära dig hur du lägger till dina egna låtar.""".replace("\n", ''),

        "restart-required": "Omstart krävs",
    },
    "turkish": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Oyna",
        "config": "Yapılandırma",
        "contribute": "Katkıda Bulun",
        "open-songs-folder": "Şarkılar Klasörünü Aç",
        "quit": "Çıkış",

        "back": "Geri",
        "title": "midi-oyunalanı",
        "title-marquee": f"""Katkıda Bulunanlar: {contributors}. Sadece kareyi izliyor ve oyunu oynamak istemiyor musunuz? Durum böyleyse yapılandırmadan tiyatro modunu açın. Kendi şarkınızı oynamakla ilgilisiniz fakat nasıl yapılacağını bilmiyor musunuz? Kendi şarkılarınızı nasıl ekleyeceğinizi öğrenmek için kaynak koddaki docs/SONGS.md dosyasına göz atın.""".replace(
            "\n", ''),

        "restart-required": "Yeniden Başlatma Gerekiyor"
    },
    "russian": {
        "font": "nunito-bold.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "играть",
        "config": "настройки",
        "contribute": "репозиторий",
        "open-songs-folder": "папка с песнями",
        "quit": "выйти",

        "back": "назад",
        "title": "проигрыватель-миди",
        "title-marquee": f"""Участники: {contributors}. Хочешь просто смотреть, а не играть? Включи режим "Theatre Mode". Как добавить песню? Перейди по docs/SONGS.md в репозитории чтобы узнать.""".replace(
            "\n", ''),

        "restart-required": "требуется перезагрузка"
    },
    "italian": {
        "font": "poppins-regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "Gioca",
        "config": "Impostazioni",
        "contribute": "Contribuisci",
        "open-songs-folder": "Apri cartella canzoni",
        "quit": "Esci",

        "back": "Indietro",
        "title": "midi-playground",
        "title-marquee": f"""Contributori: {contributors}. Vuoi vedere il quadrato senza giocare al gioco? Ti basta accendere la modalità "Modalità teatro" nelle impostazioni. Sei interessato a suonare le tue canzoni ma non sai come? Dai un' occhiata a docs/SONGS.md nel codice sorgente per scoprire come aggiungere le tue canzoni.""",

        "do-custom-pgui-font": False,
        "camera-mode": "Modalità videocamera",
        "rng-seed": "Seme RNG",
        "starting-time-delay": "Inizio ritardato",
        "max-notes-to-generate": "Massimo note da generare",
        "bounce-min-spacing": "Minima distanza di rimbalzo",
        "square-speed": "Velocità del quadrato",
        "music-offset": "Offset musica",
        "change-dir-chance": "Probabilità cambio direzione",
        "hp-drain-rate": "Velocità di drenaggio degli HP",
        "music-volume": "Volume musica",
        "color-theme": "Tema colori",
        "theatre-mode": "Modalità teatro",
        "particle-trail": "Scia di particelle",
        "shader": "Shader",
        "color-pegs-on-bounce": "Punti colorati quando rimbalzi",
        "particles-on-bounce": "Particelle quando rimbalzi",
        "resolution": "Risoluzione",

        "restart-required": "Riavvio richiesto"
    },
    "japanese": {
        "font": "NotoSansJP-Regular.ttf",

        "font-menu-shadow-offset": 9,
        "font-menu-shadow-length-offset": 0,
        "play": "遊ぶ",
        "config": "設定",
        "contribute": "貢献する",
        "open-songs-folder": "楽曲フォルダを開く",
        "quit": "終了",

        "back": "戻る",
        "title": "midi-playground",
        "title-marquee": f"""貢献者: {contributors}. ブロックを見ているだけじゃつまらないからゲームをプレイしてみたい？設定でシアターモードをオンにしてみてください。自分の曲を演奏したいけどやり方がわからない？ソースコードのdocs/SONGS.mdを見れば、自分の曲を追加する方法が分かります。""".replace(
            "\n", ''),
        
        "do-custom-pgui-font": True,
        "camera-mode": "カメラモデル",
        "rng-seed": "ランダム粒子数",
        "starting-time-delay": "起動時間遅延",
        "max-notes-to-generate": "音符最大生成数",
        "bounce-min-spacing": "バウンドの最小間隔時間",
        "square-speed": "ブロック速度",
        "music-offset": "音楽オフセット",
        "change-dir-chance": "方向を変える確率",
        "hp-drain-rate": "HHPの減衰速度",
        "music-volume": "音楽の音量",
        "color-theme": "カラーテーマ",
        "theatre-mode": "シアターモード",
        "particle-trail": "粒子の軌跡",
        "shader": "シェーダしぇーだ",
        "color-pegs-on-bounce": "バウンス時のカラー釘", #ここで中国人のために記入するのは必ずしも正確ではありません。日本のネットユーザーは中国語を参照して適切に修正してください。
        "particles-on-bounce": "バウンス粒子",
        "resolution": "解像度",
        "restart-required": "再起動が必要です"
    },
}

# vsync framerate if on windows, else 60
if platform == "win32":
    try:
        # noinspection PyPackageRequirements
        import win32api
        FRAMERATE = win32api.EnumDisplaySettings(win32api.EnumDisplayDevices().DeviceName, -1).DisplayFrequency
    except ModuleNotFoundError:
        print("The win32api python module is not found! Install it with \"pip install pywin32\"")
        FRAMERATE = 60
elif "linux" in platform:
    # noinspection PyPackageRequirements
    from Xlib import display
    # noinspection PyPackageRequirements
    from Xlib.ext import randr
    d = display.Display()
    default_screen = d.get_default_screen()
    info = d.screen(default_screen)

    resources = randr.get_screen_resources(info.root)
    active_modes = set()
    for crtc in resources.crtcs:
        crtc_info = randr.get_crtc_info(info.root, crtc, resources.config_timestamp)
        if crtc_info.mode:
            active_modes.add(crtc_info.mode)

    for mode in resources.modes:
        if mode.id in active_modes:
            FRAMERATE = round(mode.dot_clock / (mode.h_total * mode.v_total))
            break
else:
    FRAMERATE = 60


# camera follow modes
class CameraFollow(Enum):
    Center = 0  # center the square
    Lazy = 1  # lazy camera, used by Crazy Nutter 101
    Smoothed = 2  # smoothed camera, interpolates camera a little bit between current and previous every frame
    Predictive = 3  # smoothed camera, but you can see where the square will bounce better


def read_osu_file(filedata: bytes):
    filedata = filedata.decode("utf-8")
    filelines = filedata.splitlines(False)
    started_counting = False
    timestamps = []
    for line in filelines:
        if not started_counting:
            if "HitObjects" in line:
                started_counting = True
            continue
        if len(line) < 3:
            continue
        args = line.split(",")
        if (int(args[3]) & 3) == 0:
            continue
        timestamps.append(int(args[2])/1000)
    return timestamps


def surf_to_texture(in_surface: pygame.Surface) -> moderngl.Texture:
    tex = Config.ctx.texture(in_surface.get_size(), 4)
    tex.filter = (moderngl.NEAREST, moderngl.NEAREST)
    tex.swizzle = 'BGRA'
    tex.write(in_surface.get_view('1'))
    return tex


def update_screen(screen: pygame.Surface, glsl_program: moderngl.Program, render_object: moderngl.VertexArray):
    frame_tex = surf_to_texture(screen)
    frame_tex.use(0)
    glsl_program['tex'] = 0
    if "ascii.glsl" in Config.shader_file_name:
        if Config.ascii_tex is None:
            Config.ascii_tex = surf_to_texture(pygame.image.load('./assets/shaders/ascii.png').convert_alpha())
            Config.ascii_tex.use(1)
        try:
            glsl_program['asciipng'] = 1
        except KeyError:
            pass
    render_object.render(mode=moderngl.TRIANGLE_STRIP)

    pygame.display.flip()

    frame_tex.release()


def open_file(filename):
    if platform == "win32":
        from os import startfile
        startfile(filename)
    else:
        opener = "open" if platform == "darwin" else "xdg-open"
        subprocess.call([opener, filename])


# noinspection PyUnresolvedReferences
def read_midi_file(file):
    midi_file = mido.MidiFile(file=file)

    notes = []
    current_time = 0

    for msg in midi_file:
        if msg.type == 'note_on' and msg.velocity != 0:
            timestamp = current_time + msg.time
            notes.append(round(timestamp*1000)/1000)
        current_time += msg.time
    return notes


# remove values that are too close to each other
def remove_too_close_values(lst: list[float], threshold=30) -> list[float]:
    """Assumes the list is sorted"""
    new = []
    before = None
    for _ in lst:
        if before is None:
            before = _
            new.append(_)
            continue
        if before+threshold/1000 > _:
            continue
        before = _
        new.append(_)
    return new


def fix_overlap(rects: list[pygame.Rect], callback=None):
    if callback is None:
        callback = lambda _: None
    xvs = set()
    yvs = set()
    for rect in rects:
        xvs.add(rect.right)
        xvs.add(rect.left)
        yvs.add(rect.top)
        yvs.add(rect.bottom)
    xvs = sorted(list(xvs))
    yvs = sorted(list(yvs))

    outputs = []
    for xv1 in range(len(xvs)-1):
        xv2 = xv1+1
        for yv1 in range(len(yvs)-1):
            yv2 = yv1+1
            r = pygame.Rect(xvs[xv1], yvs[yv1], xvs[xv2]-xvs[xv1], yvs[yv2]-yvs[yv1])
            if r.collidelist(rects)+1:
                outputs.append(r)

        if callback(f"Checking minirectangles ({int(100*xv1*len(yvs)/(len(xvs)*len(yvs)))}% done)"):
            raise UserCancelsLoadingError()

    callback("Merging adjacent minirectangles")

    for ai in range(len(outputs)-1):
        a = outputs[ai+1]
        b = outputs[ai]
        if a.width == 0 or a.height == 0 or b.width == 0 or b.height == 0:
            continue
        if not (a.right == b.left or a.left == b.right or a.top == b.bottom or b.bottom == a.top):
            continue
        if a.x == b.x and a.width == b.width:
            a.y = min(a.y, b.y)
            a.height = a.height+b.height
            b.height = 0
            continue

    outputs = [out for out in outputs if out.width > 0 and out.height > 0]

    outputs.sort(key=lambda _: _.y*100000+_.x)

    for ai in range(len(outputs)-1):
        a = outputs[ai+1]
        b = outputs[ai]
        if a.width == 0 or a.height == 0 or b.width == 0 or b.height == 0:
            continue
        if not (a.right == b.left or a.left == b.right or a.top == b.bottom or b.bottom == a.top):
            continue
        if a.y == b.y and a.height == b.height:
            a.x = min(a.x, b.x)
            a.width = a.width+b.width
            b.width = 0
            continue

    callback("Finished loading")

    return [out for out in outputs if out.width > 0 and out.height > 0]


_font_registry: dict[str, pygame.font.Font] = {}


def lang_key(key: str):
    """
    useful for different languages, so everyone can be able to read the text.
    translations are found in translations.py btw
    """
    english_language = TRANSLATIONS["english"]
    my_language = TRANSLATIONS.get(Config.language, {})
    if key not in english_language:
        print(f"warning: there is no english text for {key} yet!!")
    return my_language.get(key, english_language.get(key, "<missing>"))


def get_font(size: int = 24, font_file_name: str = None) -> pygame.font.Font:
    if font_file_name is None:
        font_file_name = lang_key("font")
    font_path = f'./assets/fonts/{font_file_name}'
    fn_id = f"[{font_path}/{size}]"
    if fn_id not in _font_registry:
        try:
            _font_registry[fn_id] = pygame.font.Font(font_path, size)
        except FileNotFoundError:
            print(f"Font {fn_id} not found!")
            _font_registry[fn_id] = pygame.font.SysFont("Arial", size)
    return _font_registry[fn_id]


_channels = []
_sound_registry: dict[str, pygame.mixer.Sound] = {}


def play_sound(snd_name: str, vol: float = 0.5):
    if len(_channels) == 0:
        pygame.mixer.set_num_channels(40)
        for _ in range(1, 20):
            _channels.append(pygame.mixer.Channel(_))

    if snd_name not in _sound_registry:
        _sound_registry[snd_name] = pygame.mixer.Sound(join("assets", snd_name))

    chan = pygame.mixer.find_channel(False)
    if not chan:
        return False
    if chan.get_busy():
        return False
    chan.set_volume(vol)
    chan.play(_sound_registry[snd_name])
    return True


def interpolate_fn(n):
    """Interpolate sigmoidally from 0-1"""
    n = min(max(n, 0), 1)

    return sin(pi * (n - 0.5)) / 2 + 0.5


class Particle:
    SPEED_VARIATION = 4
    SIZE_MIN = 7
    SIZE_MAX = 14
    AGE_RATE = 20
    SLOW_DOWN_RATE = 1.2

    def __init__(self, pos: list[float], delta: list[float], invert_color: bool = False):
        self.pos = pos.copy()
        self.size = random.randint(Particle.SIZE_MIN, Particle.SIZE_MAX)
        self.delta = delta.copy()
        self.delta[0] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        self.delta[1] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        # color is hallway color if invert_color is false, else it's background color
        self.color = get_colors()["hallway"] if not invert_color else get_colors()["background"]

    def age(self):
        self.size -= Particle.AGE_RATE*Config.dt
        self.x += self.delta[0] * Config.PARTICLE_SPEED
        self.y += self.delta[1] * Config.PARTICLE_SPEED
        self.delta[0] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
        self.delta[1] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
        return self.size <= 0

    @property
    def x(self):
        return self.pos[0]

    @x.setter
    def x(self, val: float):
        self.pos[0] = val

    @property
    def y(self):
        return self.pos[1]

    @y.setter
    def y(self, val: float):
        self.pos[1] = val

    @property
    def rect(self):
        return pygame.Rect(self.x-self.size/2, self.y-self.size/2, *(2*[self.size]))

import cv2
import numpy as np

import pygame

"""
https://stackoverflow.com/questions/67561142/bloom-effect-in-pygame-so-that-text-glows
"""


class Colors:
    WHITE_ISH = (246, 246, 246)
    YELLOW_ISH = (214, 198, 136)
    RED_ISH = (156, 60, 60)


def create_border(image: np.ndarray, margin: int, thickness: int, color: Colors) -> np.ndarray:
    height, width = image.shape[:2]
    cv2.rectangle(image, (margin, margin), (width - margin, height - margin), color, thickness=thickness)
    return image


def apply_blooming(image: np.ndarray) -> np.ndarray:
    # Provide some blurring to image, to create some bloom.
    cv2.GaussianBlur(image, ksize=(15, 15), sigmaX=20, sigmaY=20, dst=image)
    cv2.blur(image, ksize=(12, 12), dst=image)
    return image


def glowing_border(image: np.ndarray, margin=20, thickness=20, color: Colors = Colors.WHITE_ISH):
    """

    Create a glowing border around an image.

    Args:
        image: The image, that requires a border.
        margin: The border distance from the sides of the image.
        thickness: The thickness of the border.
        color: The border color, by default a slightly yellow color.

    Modifies:
        The input image, will be modified with a blooming border.

    Returns:
        The same image, with a blooming border inserted.
    """

    # Generate yellowish colored box
    image = create_border(image, margin, thickness, color)

    # Apply the blooming.
    image = apply_blooming(image)

    # Reassert the original border, to get a clear outline.
    # Similar to the Watson-Scott test, two borders were added here.
    image = create_border(image, margin - 1, 1, color)
    image = create_border(image, margin + 1, 1, color)
    return image


def make_glowy2(size, color, intensity=0) -> pygame.Surface:
    image = np.zeros((*size[::-1], 3), dtype=np.uint8)
    border = glowing_border(image.copy(), color=color, thickness=intensity)
    return pygame.surfarray.make_surface(np.fliplr(np.rot90(border, k=-1)))

class Bounce:
    def __init__(self, sq_pos: list[float], sq_dir: list[int], time: float, bounce_dir: int):
        self.square_pos = sq_pos  # new square position
        self.square_dir = sq_dir  # new square direction
        self.bounce_dir = bounce_dir  # bounce direction for squish effect; i can't remember if 0 or 1 is horizontal
        self.time = time  # time during bounce

    def get_collision_rect(self):
        sx, sy = self.square_pos
        if self.bounce_dir == 0:
            # bounce left or right wall
            if self.square_dir[0] == -1:
                # right wall
                return pygame.Rect(
                    sx+Config.SQUARE_SIZE/2+1,
                    sy-10,
                    10,
                    20
                )
            elif self.square_dir[0] == 1:
                # left wall
                return pygame.Rect(
                    sx-10-Config.SQUARE_SIZE/2-1,
                    sy-10,
                    10,
                    20
                )
        elif self.bounce_dir == 1:
            # bounce top or bottom wall
            if self.square_dir[1] == -1:
                # bottom wall
                return pygame.Rect(
                    sx-10,
                    sy+Config.SQUARE_SIZE/2+1,
                    20,
                    10
                )
            elif self.square_dir[1] == 1:
                # top wall
                return pygame.Rect(
                    sx-10,
                    sy-10-Config.SQUARE_SIZE/2-1,
                    20,
                    10
                )

    def copy(self) -> "Bounce":
        return Bounce(self.square_pos, self.square_dir, self.time, self.bounce_dir)

    def __repr__(self):
        return f"<Bounce(sq_pos={self.square_pos}, sq_dir={self.square_dir}, time={self.time}, dir={self.bounce_dir})"



class Square:
    def __init__(self, x: float = 0, y: float = 0, dx: int = 1, dy: int = 1):
        self.pos: list[float] = [x, y]
        self.dir: list[int] = [dx, dy]
        self.last_bounce_time = -100
        self.latest_bounce_direction = 0  # 0 = horiz, 1 = vert
        self.past_colors = []
        self.died = False

        self.time_since_glow_start = 0
        self.glowy_surfaces = {}

    def register_past_color(self, col: tuple[int, int, int]):
        for _ in range(max(Config.square_swipe_anim_speed, 1)):
            self.past_colors.insert(0, col)
        while len(self.past_colors) > Config.SQUARE_SIZE * 4 / 5:
            self.past_colors.pop()

    def get_surface(self, size: tuple[int, int]):
        ss = int(Config.SQUARE_SIZE * 4 / 5)
        surf = pygame.Surface((ss, ss))
        for index, col in enumerate(self.past_colors):
            y = index if self.dir_y != 1 else ss - 1 - index
            pygame.draw.line(surf, col, (0, y), (ss, y))
        return pygame.transform.scale(surf, size)

    def copy(self) -> "Square":
        new = Square(*self.pos, *self.dir)
        new.last_bounce_time = self.last_bounce_time
        new.latest_bounce_direction = self.latest_bounce_direction
        return new

    @property
    def x(self):
        return self.pos[0]

    @property
    def y(self):
        return self.pos[1]

    def title_screen_physics(self, bounding: pygame.Rect):
        self.reg_move()
        r = self.rect
        if r.right > bounding.right:
            self.dir[0] = -1
            self.latest_bounce_direction = 0
        elif r.left < bounding.left:
            self.dir[0] = 1
            self.latest_bounce_direction = 0
        elif r.bottom > bounding.bottom:
            self.dir[1] = -1
            self.latest_bounce_direction = 1
        elif r.top < bounding.top:
            self.dir[1] = 1
            self.latest_bounce_direction = 1
        else:
            return False
        self.start_bounce()
        self.last_bounce_time = get_current_time()
        return True

    def compute_glowy_surface(self, rect, val):
        glowy_borders = make_glowy2((rect.size[0] + 40, rect.size[1] + 40), Color(Config.glow_color), val)
        surface = pygame.Surface(rect.inflate(100, 100).size, pygame.SRCALPHA)
        surface.blit(glowy_borders, (20, 20), special_flags=pygame.BLEND_RGBA_ADD)
        return surface

    def draw_glowing3(self, win, rect):
        if self.died:
            return

        if Config.square_glow:
            if pygame.time.get_ticks() - self.time_since_glow_start < Config.square_glow_duration * 1000:
                progress = 1 - (pygame.time.get_ticks() - self.time_since_glow_start) / (
                        Config.square_glow_duration * 1000)
                val = int(progress * Config.glow_intensity)
            else:
                val = 1
            val = max(val, Config.square_min_glow)
            surf = self.compute_glowy_surface(rect, val)

            win.blit(surf, rect.move(-40, -40).topleft, special_flags=pygame.BLEND_RGBA_ADD)

    def draw(self, screen: pygame.Surface, sqrect: pygame.Rect):
        if self.died:
            return
        square_color_index = round((self.dir_x + 1) / 2 + self.dir_y + 1)
        self.register_past_color(get_colors()["square"][square_color_index % len(get_colors()["square"])])

        if Config.theme == "dark_modern" and make_glowy2 is not None:
            self.draw_glowing3(screen, sqrect)
        else:
            pygame.draw.rect(screen, (0, 0, 0), sqrect)
            sq_surf = self.get_surface(
                tuple(sqrect.inflate(-int(Config.SQUARE_SIZE / 5), -int(Config.SQUARE_SIZE / 5))[2:]))
            screen.blit(sq_surf, sq_surf.get_rect(center=sqrect.center))

    @x.setter
    def x(self, val: int):
        self.pos[0] = val

    @y.setter
    def y(self, val: int):
        self.pos[1] = val

    @property
    def dir_x(self):
        return self.dir[0]

    @property
    def dir_y(self):
        return self.dir[1]

    @property
    def rect(self):
        return pygame.Rect(self.x - Config.SQUARE_SIZE / 2, self.y - Config.SQUARE_SIZE / 2,
                           *([Config.SQUARE_SIZE] * 2))

    def start_bounce(self):
        self.time_since_glow_start = pygame.time.get_ticks()

    def obey_bounce(self, bounce: Bounce):
        # planned bounces
        self.start_bounce()
        self.pos = bounce.square_pos
        self.dir = bounce.square_dir
        self.latest_bounce_direction = bounce.bounce_dir
        self.last_bounce_time = bounce.time
        return

    def reg_move(self, use_dt: bool = True):
        self.x += self.dir_x * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)
        self.y += self.dir_y * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)

class Camera:
    def __init__(self, x: int = 0, y: int = 0):
        self.x = x
        self.y = y
        # ?x, ?y are variables for misc things
        self.ax = 0
        self.ay = 0
        self.bx = 0
        self.by = 0
        self.locked_on_square = True
        self.lock_type: CameraFollow = CameraFollow(2)

    def attempt_movement(self):
        if not self.locked_on_square:
            keys = pygame.key.get_pressed()
            shift_modifier = (keys[pygame.K_LSHIFT] | keys[pygame.K_RSHIFT]) + 1
            self.x += (keys[pygame.K_d] - keys[pygame.K_a]) * Config.CAMERA_SPEED * shift_modifier / FRAMERATE
            self.y += (keys[pygame.K_s] - keys[pygame.K_w]) * Config.CAMERA_SPEED * shift_modifier / FRAMERATE

    @property
    def pos(self):
        return self.x, self.y

    @pos.setter
    def pos(self, val: Union[tuple[int, int], list[int]]):
        self.x, self.y = val

    def offset(self, pos_or_rect: Union[pygame.Rect, tuple[int, int]]) -> Union[pygame.Rect, list[int]]:
        if isinstance(pos_or_rect, pygame.Rect):
            return pos_or_rect.move(-self.x, -self.y)
        else:
            return [pos_or_rect[0]-self.x, pos_or_rect[1]-self.y]

    def follow(self, square: Square):

        # square in center
        if self.lock_type == CameraFollow.Center:
            self.pos = [square.x - Config.SCREEN_WIDTH / 2, square.y - Config.SCREEN_HEIGHT / 2]

        # camera only follows if necessary
        if self.lock_type == CameraFollow.Lazy:
            lazy_follow_distance = 250
            while square.x - Config.SCREEN_WIDTH + lazy_follow_distance > self.x:
                self.x += 1
            while square.y - Config.SCREEN_HEIGHT + lazy_follow_distance > self.y:
                self.y += 1
            while square.x - lazy_follow_distance < self.x:
                self.x -= 1
            while square.y - lazy_follow_distance < self.y:
                self.y -= 1

        # smooth camera
        if self.lock_type == CameraFollow.Smoothed:
            easing_rate = 3
            self.x = (square.x - Config.SCREEN_WIDTH / 2) * easing_rate * Config.dt + self.x - easing_rate * self.x * Config.dt
            self.y = (square.y - Config.SCREEN_HEIGHT / 2) * easing_rate * Config.dt + self.y - easing_rate * self.y * Config.dt

        # camera in front of square
        if self.lock_type == CameraFollow.Predictive:
            self.ax = (square.x - Config.SCREEN_WIDTH / 2) * 3 * Config.dt + self.ax - 3 * self.ax * Config.dt
            self.ay = (square.y - Config.SCREEN_HEIGHT / 2) * 3 * Config.dt + self.ay - 3 * self.ay * Config.dt
            damping = 1
            self.bx = square.x - damping * (self.ax - square.x) - Config.SCREEN_WIDTH / 2 - Config.SCREEN_WIDTH / 2 * damping
            self.by = square.y - damping * (self.ay - square.y) - Config.SCREEN_HEIGHT / 2 - Config.SCREEN_HEIGHT / 2 * damping
            self.x = self.x*(1-3*Config.dt)+self.bx*3*Config.dt
            self.y = self.y*(1-3*Config.dt)+self.by*3*Config.dt



class World:
    """it's a cruel world out there"""

    def __init__(self):
        self.future_bounces: list[Bounce] = []
        self.past_bounces: list[Bounce] = []
        self.start_time = 0
        self.time = 0
        self.rectangles: list[pygame.Rect] = []
        self.collision_times: list[float] = []
        self.particles: list[Particle] = []
        self.timestamps = []
        self.square = Square()
        self.scorekeeper = Scorekeeper(self)
        self.colors = []           

    def update_time(self) -> None:
        self.time = get_current_time() - self.start_time

    def get_next_bounce(self) -> Bounce:
        """Also pops the bounce from the future_bounces list"""
        self.past_bounces.append(self.future_bounces.pop(0))
        return self.past_bounces[-1]

    def add_bounce_particles(self, sp: list[float], sd: list[float]):
        for _ in range(Config.particle_amount):
            new = Particle([sp[0]+random.randint(-10, 10), sp[1]+random.randint(-10, 10)], sd)
            self.particles.append(new)

    def handle_bouncing(self, square: Square):
        if len(self.future_bounces):
            if (self.time * 1000 + Config.music_offset)/1000 > self.future_bounces[0].time:
                current_bounce = self.get_next_bounce()
                before = square.dir.copy()
                square.obey_bounce(current_bounce)
                changed = square.dir.copy()
                for _ in range(2):
                    if before[_] == changed[_]:
                        changed[_] = 0
                    else:
                        changed[_] = -changed[_]
                if Config.do_particles_on_bounce:
                    self.add_bounce_particles(square.pos, changed)

                # stop square at end
                if len(self.future_bounces) == 0:
                    square.dir = [0, 0]
                    square.pos = current_bounce.square_pos

    def handle_keypress(self, time_from_start, misses):
        return self.scorekeeper.do_keypress(time_from_start, misses)

    def gen_future_bounces(self, _start_notes: list[tuple[int, int, int]], percent_update_callback):
        """Recursive solution may be necessary"""
        total_notes = len(_start_notes)
        max_percent = 0
        path = []
        safe_areas = []
        force_return = 0

        def recurs(
                square: Square,
                notes: list[float],
                bounces_so_far: list[Bounce] = None,
                prev_index_priority=None,
                t: float = 0,
                depth: int = 0
        ) -> Union[list[Bounce], bool]:
            nonlocal force_return, max_percent
            if prev_index_priority is None:
                prev_index_priority = [0, 1]
            if bounces_so_far is None:
                bounces_so_far = []
            gone_through_percent = (total_notes-len(notes)) * 100 // total_notes
            while gone_through_percent > max_percent:
                max_percent += 1
                if percent_update_callback(f"{max_percent}% done generating map"):
                    raise UserCancelsLoadingError()

            all_bounce_rects = [_bounc.get_collision_rect() for _bounc in bounces_so_far]
            if len(notes) == 0:
                return bounces_so_far
            # print(depth * 100 // total_notes)
            path_segment_start = len(path)
            start_rect = square.rect.copy()
            while True:
                t += 1/FRAMERATE
                square.reg_move(False)
                path.append(square.rect)
                if t > notes[0]:
                    # no collision (we good)
                    bounce_indexes = prev_index_priority

                    # randomly change direction every X% of the time
                    if random.random() * 100 < Config.direction_change_chance:
                        bounce_indexes = list(bounce_indexes.__reversed__())

                    # add safe area
                    safe_areas.append(start_rect.union(square.rect))

                    for direction_to_bounce in bounce_indexes:
                        square.dir[direction_to_bounce] *= -1
                        bounces_so_far.append(Bounce(square.pos, square.dir, t, direction_to_bounce))

                        toextend = recurs(
                            square=square.copy(),
                            notes=notes[1:],
                            bounces_so_far=[_b.copy() for _b in bounces_so_far],
                            t=t,
                            prev_index_priority=bounce_indexes.copy(),
                            depth=depth+1
                        )

                        if toextend:
                            return toextend
                        else:
                            bounces_so_far = bounces_so_far[:-1]
                            square.dir[direction_to_bounce] *= -1

                            # instead of trying other path from here, just exit a bit back to try another from previous
                            if force_return:
                                force_return -= 1
                                while len(path) != path_segment_start:
                                    path.pop()
                                return False

                            continue
                    while len(path) != path_segment_start:
                        path.pop()
                    return False

                othercheck = False
                if len(bounces_so_far):
                    othercheck = bounces_so_far[-1].get_collision_rect().collidelist(path[:-10])+1

                if square.rect.collidelist(all_bounce_rects) != -1 or othercheck:
                    if depth > 200:
                        if random.random() < Config.backtrack_chance:
                            max_percent -= (Config.backtrack_amount * 100 // total_notes) + 1
                            force_return = Config.backtrack_amount

                    while len(path) != path_segment_start:
                        path.pop()
                    return False

        _start_notes = _start_notes[:Config.max_notes] if Config.max_notes is not None else _start_notes

        self.scorekeeper.unhit_notes = remove_too_close_values([_sn for _sn in _start_notes], Config.bounce_min_spacing)

        self.future_bounces = recurs(
            square=self.square.copy(),
            notes=remove_too_close_values(
                [_sn for _sn in _start_notes],
                threshold=Config.bounce_min_spacing
            )
        )

        if self.future_bounces is False:
            raise MapLoadingFailureError("The map failed to generate because of the recursion function. " +
                                         "If the midi has too many notes too close, it may not generate. " +
                                         "Maybe try changing the \"bounce min spacing\", \"square speed\" or \"change dir chance\" in the config")

        if len(self.future_bounces) == 0:
            raise MapLoadingFailureError("Map safearea list empty. Please report to the github under the issues tab")

        percent_update_callback("Removing overlapping safe areas")

        # eliminate fully overlapping safe areas
        safe_areas: list[pygame.Rect]
        while True:
            new = []
            before_safe_count = len(safe_areas)
            for safe1 in safe_areas:
                for safe2 in safe_areas:
                    if safe2 == safe1:
                        continue
                    if safe2.contains(safe1):
                        break
                else:
                    new.append(safe1)
            safe_areas = new.copy()
            after_safe_count = len(safe_areas)
            if after_safe_count == before_safe_count:
                break
        safe_areas = safe_areas

        self.rectangles = [_fb.get_collision_rect() for _fb in self.future_bounces]
        self.collision_times = [_fb.time for _fb in self.future_bounces]
        
        # Setting random colors for the generated pegs
        self.colors = [random.choice([(224, 50, 50), (80, 210, 100), (230, 220, 50), (174, 170, 210), (245, 77, 247), (255, 153, 0)]) for _ in self.future_bounces]
        return safe_areas

class Game:
    def __init__(self):
        self.active = False
        self.notes = []
        self.camera = Camera()
        self.world = World()
        self.safe_areas: list[pygame.Rect] = []
        self.camera_ctrl_text = get_font(24).render("Manual Camera Control Activated", True, (0, 255, 0))
        self.music_has_played = False
        self.offset_happened = False
        self.loading_text = get_font(24).render("Loading...", True, (255, 255, 255))
        self.keystrokes = Keystrokes()
        self.misses = 0
        self.mouse_down = False
        self.hitted_rectangles = []                           

    def start_song(self, screen: pygame.Surface):
        random.seed(Config.seed)

        # load song and notes
        with ZipFile(Config.current_song.fp) as zf:
            if Config.current_song.fp.lower().endswith(".osz"):
                # Config.music_offset = 0
                notes = read_osu_file(filedata=zf.read(Config.current_song.song_file_name))
            else:
                with zf.open(Config.current_song.song_file_name) as f:
                    notes = read_midi_file(file=f)
        notes = [note for note in notes]
        self.notes = notes

        # other settings

        self.world = World()
        self.music_has_played = False
        self.offset_happened = False
        self.camera.lock_type = CameraFollow(Config.camera_mode)
        screen.fill(get_colors()["background"])
        information_texts = [
            "Map loading stuck at some percentage? Try changing the \"Square speed\" in the config",
            "Also try changing the \"Change dir chance\" and the \"Bounce min spacing\"",
            "",
            "Too large maps will crash the program, so try with some smaller ones first",
            "",
            "Don't want to play the game? Turn on \"Theatre mode\" in the config",
            "",
            "Music off-sync? Change the \"Music offset\" in the settings"
        ]
        for index, info_text in enumerate(information_texts):
            rendered_text = get_font(24).render(info_text, True, get_colors()["hallway"])
            screen.blit(rendered_text, (50, 200 + 30 * index))
        pygame.display.flip()

        def update_loading_screen(message: str):
            screen.fill(get_colors()["background"], pygame.Rect(0, 0, Config.SCREEN_WIDTH, 100))
            screen.blit(get_font(60).render(message, True, get_colors()["hallway"]), (10, 10))
            for event in pygame.event.get():
                if event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_ESCAPE:
                        return 1
            update_screen(Config.screen, Config.glsl_program, Config.render_object)

        try:
            self.safe_areas = self.world.gen_future_bounces(self.notes, update_loading_screen)
            self.safe_areas = fix_overlap(self.safe_areas, update_loading_screen)
        except UserCancelsLoadingError:
            return True
        except MapLoadingFailureError as e:
            return e.args[0] if len(e.args) else "Big error... can't load map :("
        self.world.start_time = get_current_time()
        self.world.square.dir = [0, 0]
        self.world.square.pos = self.world.future_bounces[0].square_pos

    def draw(self, screen: pygame.Surface, n_frames: int):

        if not self.active:
            return

        if not self.music_has_played:
            if not self.offset_happened:
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += Config.start_playing_delay / 1000
            self.offset_happened = True
            if self.world.time-Config.current_song.music_offset/1000 > Config.start_playing_delay/1000:
                self.music_has_played = True
                song_load_before = get_current_time()
                pygame.mixer.music.play()
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += (get_current_time()-song_load_before)/1000

        screen_rect = screen.get_rect()

        # set world time
        self.world.update_time()

        # move camera (only works if not locked on square)
        self.camera.attempt_movement()

        # handle square bounces
        self.world.handle_bouncing(self.world.square)

        # move square
        self.world.square.reg_move()

        # square in center of camera if locked
        if self.camera.locked_on_square:
            self.camera.follow(self.world.square)

        # bounce anim
        sqrect = self.camera.offset(self.world.square.rect)
        if (self.world.time - 0.25) + Config.music_offset / 1000 < self.world.square.last_bounce_time:
            lerp = abs((self.world.time - 0.25 + Config.music_offset / 1000) - self.world.square.last_bounce_time) * 5
            lerp = lerp ** 2  # square it for better-looking interpolation
            if self.world.square.latest_bounce_direction:
                sqrect.inflate_ip((lerp * 5, -10 * lerp))
            else:
                sqrect.inflate_ip((-10 * lerp, lerp * 5))

        # safe areas
        total_rects = 0
        for safe_area in self.safe_areas:
            offsetted = self.camera.offset(safe_area)
            if screen_rect.colliderect(offsetted):
                total_rects += 1
                pygame.draw.rect(screen, get_colors()["hallway"], offsetted)

        # draw pegs
        for i, bounce_rect in enumerate(self.world.rectangles):
            offsetted = self.camera.offset(bounce_rect)

            if offsetted.colliderect(screen_rect):
                total_rects += 1
                if Config.do_color_bounce_pegs and self.world.collision_times[i] < (self.world.time * 1000 + Config.music_offset - Config.start_playing_delay)/1000:
                    pygame.draw.rect(screen, self.world.colors[i], offsetted)
                else:
                    pygame.draw.rect(screen, get_colors()["background"], offsetted)

        # particles
        for particle in self.world.particles:
            pygame.draw.rect(screen, particle.color, self.camera.offset(particle.rect))
        for remove_particle in [particle for particle in self.world.particles if particle.age()]:
            self.world.particles.remove(remove_particle)

        # particle trail in game
        if Config.particle_trail:
            # every 2 frames add a particle
            if not self.world.square.died:
                if n_frames % 2 == 0:
                    new = Particle(self.world.square.pos, [0, 0], True)
                    new.delta = [random.randint(-10, 10)/20, random.randint(-10, 10)/20]
                    self.world.particles.append(new)
                
        # scorekeeper drawing
        time_from_start = self.world.time-Config.start_playing_delay/1000+Config.music_offset/1000
        if not Config.theatre_mode:
            self.misses = self.world.scorekeeper.draw(screen, time_from_start if len(self.world.future_bounces) else -1, self.misses)

            # hit icons
            to_remove = []
            for hiticon in self.world.scorekeeper.hit_icons:
                if hiticon.draw(screen, self.camera):
                    to_remove.append(hiticon)
            for remove in to_remove:
                self.world.scorekeeper.hit_icons.remove(remove)

            if self.world.scorekeeper.hp <= 0 and not self.world.square.died:
                self.world.square.died = True
                self.world.square.dir = [0, 0]
                pygame.mixer.music.stop()
                play_sound("death.mp3", 0.5)
                self.world.future_bounces = []
                for _ in range(100):
                    self.world.particles.append(Particle(self.world.square.pos, [random.randint(-3, 3), random.randint(-3, 3)]))
            if self.world.square.died:
                self.world.scorekeeper.hp = 0

        # draw square
        self.world.square.draw(screen, sqrect)

        if not Config.theatre_mode:
            # keystrokes
            self.keystrokes.draw(screen)

            # countdown to start
            if time_from_start < 0:
                repr_time = f"{abs(int((time_from_start+0.065)*10)/10)}s"
                countdown_surface = get_font(36).render(repr_time, True, (255, 255, 255))
                screen.blit(countdown_surface, countdown_surface.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 4)))
            elif time_from_start < 0.5:
                repr_zero = f"0.0s"
                countdown_surface = get_font(36).render(repr_zero, True, (255, 255, 255))
                countdown_surface.set_alpha((0.5-time_from_start)*2*255)
                screen.blit(countdown_surface, countdown_surface.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 4)))

            # handle mouse clicks because self.handle_event doesn't get called for mouse clicks
            if pygame.mouse.get_pressed()[0] and not self.mouse_down:
                self.misses = self.world.handle_keypress(time_from_start, self.misses)
                self.mouse_down = True
            elif not pygame.mouse.get_pressed()[0]:
                self.mouse_down = False
            
            # draw accuracy
            # noinspection PyBroadException
            try:
                n_bounces = len(self.world.past_bounces)
                n_total_bounces = len(self.world.past_bounces)+len(self.world.future_bounces)
                if n_bounces > 0:
                    n_misses = self.misses
                    acc = round((n_bounces-n_misses)/n_bounces*100, 2)
                    acct = round((n_total_bounces-n_misses)/n_total_bounces*100, 2)
                    # clamp to 0-100
                    acc = max(0, min(100, acc))
                    acct = max(0, min(100, acct))
                    acc_text = get_font(24).render(f"Accuracy: {acc}%", True, (255, 255, 255))
                    acct_text = get_font(24).render(f"Total Accuracy: {acct}%", True, (255, 255, 255))
                    topleft1 = self.world.scorekeeper.life_bar_rect.move(0, 10).bottomleft
                    screen.blit(acc_text, acc_text.get_rect(topleft=topleft1))
                    screen.blit(acct_text, acct_text.get_rect(topleft=acc_text.get_rect(topleft=topleft1).move(0, 10).bottomleft))
            except ZeroDivisionError:
                pass
            except Exception:
                pass

            # failure message
            if self.world.square.died:
                # calculate accuracy
                n_bounces = len(self.world.past_bounces)
                n_total_bounces = len(self.world.past_bounces)+len(self.world.future_bounces)
                # clamp bounces to 1-infinity
                n_bounces = max(1, n_bounces)
                n_total_bounces = max(1, n_total_bounces)
                n_misses = self.misses

                acc = round((n_bounces-n_misses)/n_bounces*100, 2)
                acct = round((n_total_bounces-n_misses)/n_total_bounces*100, 2)

                # clamp to 0-100
                acc = max(0, min(100, acc))
                acct = max(0, min(100, acct))

                try_again_text = get_font(36).render("Press escape to go back.", True, (255, 255, 255))
                acc_text = get_font(36).render(f"Accuracy: {acc}%", True, (255, 255, 255))
                acct_text = get_font(36).render(f"Total Accuracy: {acct}%", True, (255, 255, 255))
                screen.blit(try_again_text, try_again_text.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 4)))
                screen.blit(acc_text, acc_text.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 4 + 50)))
                screen.blit(acct_text, acct_text.get_rect(center=(Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 4 + 100)))

        if not self.camera.locked_on_square:
            screen.blit(self.camera_ctrl_text, (10, 10))

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return False

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                return True
            if event.key == pygame.K_TAB:
                self.camera.locked_on_square = not self.camera.locked_on_square
            if not Config.theatre_mode:
                if self.camera.locked_on_square:
                    time_from_start = self.world.time-Config.start_playing_delay/1000+Config.music_offset/1000
                    if time_from_start < -0.2:
                        return
                    arrows_n_space = (pygame.K_SPACE, pygame.K_LEFT, pygame.K_RIGHT, pygame.K_UP, pygame.K_DOWN)

                    if 97+26 > event.key >= 97 or event.key in arrows_n_space:  # press a to z key or space or arrows
                        self.misses = self.world.handle_keypress(time_from_start, self.misses)
                   
def main():
    # patch to fix mouse on high dpi displays
    if "Windows" in get_os():
        from ctypes import windll
        windll.user32.SetProcessDPIAware()
    # patch to fix opengl error on mac
    elif "Darwin" in get_os():
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE)
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_FORWARD_COMPATIBLE_FLAG, True)

    # pygame and other boilerplate
    n_frames = 0
    pygame.mixer.music.load("./assets/mainmenu.mp3")
    pygame.mixer.music.set_volume(Config.volume / 100)
    pygame.mixer.music.play(loops=-1, start=2)

    clock = pygame.time.Clock()

    flags = pygame.HWACCEL | pygame.HWSURFACE | pygame.OPENGL | pygame.DOUBLEBUF
    do_vsync = 1
    if "Linux" in get_os():
        do_vsync = 0  # otherwise error of "regular vsync for OpenGL not available" at least that's what i got under wsl
    # noinspection PyUnusedLocal
    if [Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT] == [pygame.display.Info().current_w,pygame.display.Info().current_h]:
        flags |= pygame.FULLSCREEN
    
    real_screen = pygame.display.set_mode(
        [Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT],
        flags,
        vsync=do_vsync
    )
    screen = pygame.Surface([Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT])

    # noinspection PyBroadException
    try:
        pygame.display.set_caption("Midi Playground")
        pygame.display.set_icon(pygame.image.load("./assets/icon.png").convert_alpha())
    except Exception as e:
        print(e)

    # moderngl stuff
    ctx = moderngl.create_context()
    Config.ctx = ctx

    quad_buffer = ctx.buffer(data=array('f', [
        # position, uv coords
        -1.0, 1.0, 0.0, 0.0,  # topleft
        1.0, 1.0, 1.0, 0.0,  # topright
        -1.0, -1.0, 0.0, 1.0,  # bottomleft
        1.0, -1.0, 1.0, 1.0  # bottomright
    ]))

    vert_shader = '''
    #version 330 core
    
    in vec2 vert;
    in vec2 texcoord;
    out vec2 uvs;
    
    void main() {
        uvs = texcoord;
        gl_Position = vec4(vert.x, vert.y, 0.0, 1.0);
    }
    '''

    with open(f"./assets/shaders/{Config.shader_file_name}") as shader_file:
        frag_shader = shader_file.read()

    glsl_program = ctx.program(vertex_shader=vert_shader, fragment_shader=frag_shader)
    render_object = ctx.vertex_array(glsl_program, [(quad_buffer, '2f 2f', 'vert', 'texcoord')])

    Config.glsl_program = glsl_program
    Config.render_object = render_object
    Config.screen = screen

    # the big guns
    menu = Menu()
    song_selector = SongSelector()
    config_page = ConfigPage()
    error_screen = ErrorScreen()
    game = Game()

    # game loop
    running = True
    while running:
        n_frames += 1
        # thanks to TheCodingCrafter for the implementation
        if Config.theme == "rainbow":
            to_set_as_rainbow = pygame.Color((0, 0, 0))
            to_set_as_rainbow2 = pygame.Color((0, 0, 0))
            to_set_as_rainbow.hsva = (((pygame.time.get_ticks() / 1000) * Config.rainbow_speed) % 360, 100, 75, 100)
            to_set_as_rainbow2.hsva = ((((pygame.time.get_ticks() / 1000) * Config.rainbow_speed) + 180) % 360, 100, 75, 100)
            get_colors()["background"] = to_set_as_rainbow
            get_colors()["hallway"] = to_set_as_rainbow2
            get_colors()["square"][0] = to_set_as_rainbow

        screen.fill(get_colors()["background"])
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            if event.type == pygame.KEYDOWN:
                # artificial lag spike for debugging purposes
                if event.key == pygame.K_F12:
                    total = 0
                    for _ in range(10_000_000):
                        total += 1
                if event.key == pygame.K_F3:
                    print("Debug information copied to clipboard")
                    print_debug_info()
                if event.key == pygame.K_F2:
                    if game.active:
                        debug_rectangles(game.safe_areas)
                if event.key == pygame.K_ESCAPE:
                    if song_selector.active:
                        song_selector.active = False
                        menu.active = True
                        if song_selector.selected_index + 1:
                            pygame.mixer.music.load("./assets/mainmenu.mp3")
                            pygame.mixer.music.set_volume(Config.volume / 100)
                            pygame.mixer.music.play(loops=-1, start=2)
                            song_selector.selected_index = -1
                        continue
                    if game.active:
                        game.active = False
                        song_selector.active = True
                        pygame.mixer.music.load("./assets/mainmenu.mp3")
                        pygame.mixer.music.set_volume(Config.volume / 100)
                        pygame.mixer.music.play(loops=-1, start=2)
                        song_selector.selected_index = -1
                        continue
                    if config_page.active:
                        config_page.active = False
                        menu.active = True
                        continue
                    if error_screen.active:
                        error_screen.active = False
                        song_selector.active = True
                        continue
                    running = False

            # handle menu events
            option_id = menu.handle_event(event)
            if option_id:
                if option_id == "open-songs-folder":
                    open_file(join(getcwd(), "songs"))
                    continue
                if option_id == "contribute":
                    webbrowser.open("https://github.com/quasar098/midi-playground")
                    continue
                menu.active = False
                if option_id == "config":
                    config_page.active = True
                if option_id == "play":
                    song_selector.active = True
                    song_selector.reload_songs()
                if option_id == "quit":
                    running = False
                continue

            # handle song selector events
            song = song_selector.handle_event(event)
            if song:
                if isinstance(song, bool):
                    menu.active = True
                    if song_selector.selected_index + 1:
                        pygame.mixer.music.load("./assets/mainmenu.mp3")
                        pygame.mixer.music.set_volume(Config.volume / 100)
                        pygame.mixer.music.play(loops=-1, start=2)
                        song_selector.selected_index = -1
                    continue
                # starting song now
                Config.current_song = song
                game.active = True
                if msg := game.start_song(screen):
                    if isinstance(msg, str):
                        game.active = False
                        error_screen.active = True
                        error_screen.msg = msg
                    else:
                        game.active = False
                        song_selector.active = True
                    pygame.mixer.music.load("./assets/mainmenu.mp3")
                    pygame.mixer.music.set_volume(Config.volume / 100)
                    pygame.mixer.music.play(loops=-1, start=2)

            # handle config page events
            if config_page.handle_event(event):
                config_page.active = False
                menu.active = True

            # handle game events
            if game.handle_event(event):
                game.active = False
                song_selector.active = True

        # draw stuff here
        game.draw(screen, n_frames)
        song_selector.draw(screen)
        config_page.draw(screen)
        menu.draw(screen, n_frames)
        error_screen.draw(screen)

        update_screen(screen, glsl_program, render_object)

        Config.dt = clock.tick(FRAMERATE) / 1000
    pygame.quit()
    save_to_file()


if __name__ == '__main__':
    main()