diff --git a/.gitignore b/.gitignore index 66769de..d0aaf4d 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ test*.py .zed/ logs/ logs +settings.json diff --git a/CREDITS b/CREDITS new file mode 100644 index 0000000..91e1e51 --- /dev/null +++ b/CREDITS @@ -0,0 +1,11 @@ +Thanks to OpenGameArt and pixelsphere.org / The Cynic Project for the music! (https://opengameart.org/content/crystal-cave-mysterious-ambience-seamless-loop) + +Huge Thanks to Python for being the programming language used in this game. +https://www.python.org/ + +Huge thanks to Arcade and Pyglet for being the graphical engines used in this game. +https://arcade.academy/ +https://pyglet.readthedocs.io/en/latest/ + +Thanks to the following other libraries used in this game: +pypresence - https://github.com/qwertyquerty/pypresence - Used for Discord Rich Presence diff --git a/assets/sound/click.wav b/assets/sound/click.wav new file mode 100644 index 0000000..6214a96 Binary files /dev/null and b/assets/sound/click.wav differ diff --git a/assets/sound/music.ogg b/assets/sound/music.ogg new file mode 100644 index 0000000..dfa8412 Binary files /dev/null and b/assets/sound/music.ogg differ diff --git a/game/play.py b/game/play.py index 31cc5e3..c9caed0 100644 --- a/game/play.py +++ b/game/play.py @@ -1,8 +1,8 @@ -import arcade, arcade.gui, random, math +import arcade, arcade.gui, random, math, json from game.sprites import Shape from utils.constants import SHAPES, CELL_SIZE, ROWS, COLS, menu_background_color, OUTLINE_WIDTH, COLORS, button_style -from utils.preload import button_texture, button_hovered_texture +from utils.preload import button_texture, button_hovered_texture, click_sound class Game(arcade.gui.UIView): def __init__(self, pypresence_client): @@ -28,9 +28,13 @@ class Game(arcade.gui.UIView): self.anchor = self.add_widget(arcade.gui.UIAnchorLayout()) - def main_exit(self): - from menus.main import Main + with open("settings.json", "r") as file: + self.settings_dict = json.load(file) + def main_exit(self): + self.window.set_mouse_visible(True) + + from menus.main import Main self.window.show_view(Main()) def on_mouse_motion(self, x, y, dx, dy): @@ -40,16 +44,16 @@ class Game(arcade.gui.UIView): def on_show_view(self): super().on_show_view() - arcade.set_background_color(arcade.color.BLACK) - self.setup_grid() self.mouse_shape = Shape(0, 0, self.shape_to_place, self.shape_color, self.mouse_shape_list) self.next_shape_ui = Shape(self.window.width - (CELL_SIZE * 4), self.window.height - (CELL_SIZE * 4), self.next_shape_to_place, self.next_shape_color, self.shape_list) self.score_label = self.anchor.add(arcade.gui.UILabel(text="Score: 0", font_name="Protest Strike", font_size=24), anchor_x="center", anchor_y="top") - self.back_to_menu_button = self.anchor.add(arcade.gui.UITextureButton(text="Back to Main Menu", texture=button_texture, texture_hovered=button_hovered_texture, width=self.window.width / 3, height=100, style=button_style), anchor_x="center", anchor_y="bottom") - self.back_to_menu_button.on_click = lambda event: self.main_exit() + + self.back_button = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='<--', style=button_style, width=100, height=50) + self.back_button.on_click = lambda e: self.main_exit() + self.anchor.add(self.back_button, anchor_x="left", anchor_y="top", align_x=5, align_y=-5) self.pypresence_client.update(state='In Game', details='Shattering Stacks', start=self.pypresence_client.start_time) @@ -66,7 +70,7 @@ class Game(arcade.gui.UIView): self.shape_list.append(arcade.SpriteSolidColor( width=CELL_SIZE, height=CELL_SIZE, - color=menu_background_color, + color=arcade.color.GRAY, center_x=center_x, center_y=center_y )) @@ -139,6 +143,9 @@ class Game(arcade.gui.UIView): def on_mouse_press(self, x: int, y: int, button: int, modifiers: int) -> bool | None: super().on_mouse_press(x, y, button, modifiers) + if self.settings_dict.get("sfx", True): + click_sound.play(volume=self.settings_dict.get("sfx_volume", 50) / 100) + grid_col = math.ceil((x - self.start_x + (CELL_SIZE / 2)) // (CELL_SIZE + OUTLINE_WIDTH)) grid_row = math.ceil((y - self.start_y + (CELL_SIZE / 2)) // (CELL_SIZE + OUTLINE_WIDTH)) diff --git a/menus/main.py b/menus/main.py index d1d060b..e79e1e3 100644 --- a/menus/main.py +++ b/menus/main.py @@ -41,9 +41,18 @@ class Main(arcade.gui.UIView): def on_show_view(self): super().on_show_view() + self.title_label = self.box.add(arcade.gui.UILabel(text="ShatterStack", font_name="Protest Strike", font_size=48)) + self.play_button = self.box.add(arcade.gui.UITextureButton(text="Play", texture=button_texture, texture_hovered=button_hovered_texture, width=self.window.width / 2, height=150, style=button_style)) self.play_button.on_click = lambda event: self.play() + self.settings_button = self.box.add(arcade.gui.UITextureButton(text="Settings", texture=button_texture, texture_hovered=button_hovered_texture, width=self.window.width / 2, height=150, style=button_style)) + self.settings_button.on_click = lambda event: self.settings() + def play(self): from game.play import Game self.window.show_view(Game(self.pypresence_client)) + + def settings(self): + from menus.settings import Settings + self.window.show_view(Settings(self.pypresence_client)) diff --git a/menus/settings.py b/menus/settings.py new file mode 100644 index 0000000..ab18575 --- /dev/null +++ b/menus/settings.py @@ -0,0 +1,294 @@ +import copy, pypresence, json, os + +import arcade, arcade.gui + +from utils.constants import button_style, dropdown_style, slider_style, settings +from utils.utils import FakePyPresence +from utils.preload import button_texture, button_hovered_texture, theme_sound + +from arcade.gui import UIBoxLayout, UIAnchorLayout +from arcade.gui.experimental.focus import UIFocusGroup + +class Settings(arcade.gui.UIView): + def __init__(self, pypresence_client): + super().__init__() + + with open("settings.json", "r") as file: + self.settings_dict = json.load(file) + + self.pypresence_client = pypresence_client + self.pypresence_client.update(state='In Settings', details='Modifying Settings', start=self.pypresence_client.start_time) + + self.slider_labels = {} + self.sliders = {} + + self.on_radiobuttons = {} + self.off_radiobuttons = {} + + self.current_category = "Graphics" + + self.modified_settings = {} + + def create_layouts(self): + self.anchor = self.add_widget(UIAnchorLayout(size_hint=(1, 1))) + + self.box = UIBoxLayout(space_between=50, align="center", vertical=False) + self.anchor.add(self.box, anchor_x="center", anchor_y="top", align_x=10, align_y=-75) + + self.top_box = UIBoxLayout(space_between=self.window.width / 160, vertical=False) + self.anchor.add(self.top_box, anchor_x="left", anchor_y="top", align_x=10, align_y=-10) + + self.key_layout = self.box.add(UIBoxLayout(space_between=20, align='left')) + self.value_layout = self.box.add(UIBoxLayout(space_between=13, align='left')) + + def on_show_view(self): + super().on_show_view() + + self.create_layouts() + + self.ui.push_handlers(self) + + self.back_button = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='<--', style=button_style, width=100, height=50) + self.back_button.on_click = lambda e: self.main_exit() + self.top_box.add(self.back_button) + + self.display_categories() + + self.display_category("Graphics") + + def display_categories(self): + for category in settings: + category_button = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text=category, style=button_style, width=self.window.width / 10, height=50) + + if not category == "Credits": + category_button.on_click = lambda e, category=category: self.display_category(category) + else: + category_button.on_click = lambda e: self.credits() + + self.top_box.add(category_button) + + def display_category(self, category): + if hasattr(self, 'apply_button'): + self.anchor.remove(self.apply_button) + del self.apply_button + + if hasattr(self, 'credits_label'): + self.anchor.remove(self.credits_label) + del self.credits_label + + self.current_category = category + + self.key_layout.clear() + self.value_layout.clear() + + for setting in settings[category]: + label = arcade.gui.UILabel(text=setting, font_name="Protest Strike", font_size=28, text_color=arcade.color.WHITE ) + self.key_layout.add(label) + + setting_dict = settings[category][setting] + + if setting_dict['type'] == "option": + dropdown = arcade.gui.UIDropdown(options=setting_dict['options'], width=200, height=50, default=self.settings_dict.get(setting_dict["config_key"], setting_dict["options"][0]), active_style=dropdown_style, dropdown_style=dropdown_style, primary_style=dropdown_style) + dropdown.on_change = lambda _, setting=setting, dropdown=dropdown: self.update(setting, dropdown.value, "option") + self.value_layout.add(dropdown) + + elif setting_dict['type'] == "bool": + button_layout = self.value_layout.add(arcade.gui.UIBoxLayout(space_between=50, vertical=False)) + + on_radiobutton = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text="ON", style=button_style, width=150, height=50) + self.on_radiobuttons[setting] = on_radiobutton + on_radiobutton.on_click = lambda _, setting=setting: self.update(setting, True, "bool") + button_layout.add(on_radiobutton) + + off_radiobutton = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text="OFF", style=button_style, width=150, height=50) + self.off_radiobuttons[setting] = off_radiobutton + off_radiobutton.on_click = lambda _, setting=setting: self.update(setting, False, "bool") + button_layout.add(off_radiobutton) + + if self.settings_dict.get(setting_dict["config_key"], setting_dict["default"]): + self.set_highlighted_style(on_radiobutton) + self.set_normal_style(off_radiobutton) + else: + self.set_highlighted_style(off_radiobutton) + self.set_normal_style(on_radiobutton) + + elif setting_dict['type'] == "slider": + if setting == "FPS Limit": + if self.settings_dict.get(setting_dict["config_key"]) == 0: + label_text = "FPS Limit: Disabled" + else: + label_text = f"FPS Limit: {self.settings_dict.get(setting_dict["config_key"], setting_dict["default"])}" + else: + label_text = f"{setting}: {int(self.settings_dict.get(setting_dict["config_key"], setting_dict["default"]))}" + + label.text = label_text + + self.slider_labels[setting] = label + + slider = arcade.gui.UISlider(width=400, height=50, value=self.settings_dict.get(setting_dict["config_key"], setting_dict["default"]), min_value=setting_dict['min'], max_value=setting_dict['max'], style=slider_style) + slider.on_change = lambda _, setting=setting, slider=slider: self.update(setting, slider.value, "slider") + + self.sliders[setting] = slider + self.value_layout.add(slider) + + self.apply_button = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='Apply', style=button_style, width=200, height=100) + self.apply_button.on_click = lambda e: self.apply_settings() + self.anchor.add(self.apply_button, anchor_x="right", anchor_y="bottom", align_x=-10, align_y=10) + + def apply_settings(self): + for config_key, value in self.modified_settings.items(): + self.settings_dict[config_key] = value + + if self.settings_dict['window_mode'] == "Fullscreen": + self.window.set_fullscreen(True) + else: + self.window.set_fullscreen(False) + width, height = map(int, self.settings_dict['resolution'].split('x')) + self.window.set_size(width, height) + + if self.settings_dict['music']: + if not theme_sound.source._players: + theme_sound.play(volume=self.settings_dict.get("music_volume", 50) / 100, loop=True) + else: + for n, sound in enumerate(theme_sound.source._players): + sound.volume = self.settings_dict.get("music_volume", 50) / 100 + else: + for n, sound in enumerate(theme_sound.source._players): # stop all theme sounds + sound.delete() + del theme_sound.source._players[n] + + if self.settings_dict['vsync']: + self.window.set_vsync(True) + display_mode = self.window.display.get_default_screen().get_mode() + refresh_rate = display_mode.rate + self.window.set_update_rate(1 / refresh_rate) + self.window.set_draw_rate(1 / refresh_rate) + + elif not self.settings_dict['fps_limit'] == 0: + self.window.set_vsync(False) + self.window.set_update_rate(1 / self.settings_dict['fps_limit']) + self.window.set_draw_rate(1 / self.settings_dict['fps_limit']) + + else: + self.window.set_vsync(False) + self.window.set_update_rate(1 / 99999999) + self.window.set_draw_rate(1 / 99999999) + + if self.settings_dict['discord_rpc']: + if isinstance(self.pypresence_client, FakePyPresence): # the user has enabled RPC in the settings in this session. + start_time = copy.deepcopy(self.pypresence_client.start_time) + self.pypresence_client.close() + del self.pypresence_client + try: + self.pypresence_client = pypresence.Presence(1363780625928028200) + self.pypresence_client.connect() + self.pypresence_client.update(state='In Settings', details='Modifying Settings', start=start_time) + self.pypresence_client.start_time = start_time + except: + self.pypresence_client = FakePyPresence() + self.pypresence_client.start_time = start_time + else: + if not isinstance(self.pypresence_client, FakePyPresence): + start_time = copy.deepcopy(self.pypresence_client.start_time) + self.pypresence_client.update() + self.pypresence_client.close() + del self.pypresence_client + self.pypresence_client = FakePyPresence() + self.pypresence_client.start_time = start_time + + self.ui_cleanup() + + self.ui = arcade.gui.UIManager() + self.ui.enable() + + self.create_layouts() + + self.back_button = arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='<--', style=button_style, width=100, height=50) + self.back_button.on_click = lambda e: self.main_exit() + self.top_box.add(self.back_button) + + self.display_categories() + + self.display_category(self.current_category) + + with open("settings.json", "w") as file: + file.write(json.dumps(self.settings_dict, indent=4)) + + def update(self, setting=None, button_state=None, setting_type="bool"): + setting_dict = settings[self.current_category][setting] + config_key = settings[self.current_category][setting]["config_key"] + + if setting_type == "option": + self.modified_settings[config_key] = button_state + + elif setting_type == "bool": + self.modified_settings[config_key] = button_state + + if button_state: + self.set_highlighted_style(self.on_radiobuttons[setting]) + self.set_normal_style(self.off_radiobuttons[setting]) + else: + self.set_highlighted_style(self.off_radiobuttons[setting]) + self.set_normal_style(self.on_radiobuttons[setting]) + + elif setting_type == "slider": + new_value = int(button_state) + + self.modified_settings[config_key] = new_value + self.sliders[setting].value = new_value + + if setting == "FPS Limit": + if new_value == 0: + label_text = "FPS Limit: Disabled" + else: + label_text = f"FPS Limit: {str(new_value).rjust(8)}" + else: + label_text = f"{setting}: {str(new_value).rjust(8)}" + + self.slider_labels[setting].text = label_text + + def credits(self): + if hasattr(self, 'apply_button'): + self.anchor.remove(self.apply_button) + del self.apply_button + + if hasattr(self, 'credits_label'): + self.anchor.remove(self.credits_label) + del self.credits_label + + self.key_layout.clear() + self.value_layout.clear() + + with open('CREDITS', 'r') as file: + text = file.read() + + if self.window.width == 3840: + font_size = 30 + elif self.window.width == 2560: + font_size = 20 + elif self.window.width == 1920: + font_size = 17 + elif self.window.width >= 1440: + font_size = 14 + else: + font_size = 12 + + self.credits_label = arcade.gui.UILabel(text=text, text_color=arcade.color.WHITE, font_name="Protest Strike", font_size=font_size, align="center", multiline=True) + + self.key_layout.add(self.credits_label) + + def set_highlighted_style(self, element): + element.texture = button_hovered_texture + element.texture_hovered = button_texture + + def set_normal_style(self, element): + element.texture_hovered = button_hovered_texture + element.texture = button_texture + + def main_exit(self): + from menus.main import Main + self.window.show_view(Main(pypresence_client=self.pypresence_client)) + + def ui_cleanup(self): + self.ui.clear() + del self.ui diff --git a/pyproject.toml b/pyproject.toml index 6105b12..28da6fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,10 @@ version = "0.1.0" description = "A game where you have to place shapes in lines to get points!" readme = "README.md" requires-python = ">=3.13" -dependencies = ["arcade", "pillow>=11.0.0", "pypresence>=4.3.0"] +dependencies = [ + "arcade", + "pypresence>=4.3.0", +] [tool.uv.sources] arcade = { git = "https://github.com/pythonarcade/arcade.git", rev = "gui/controller" } diff --git a/run.py b/run.py index 5e4c2d3..88a0bff 100644 --- a/run.py +++ b/run.py @@ -7,8 +7,7 @@ import logging, datetime, os, json, sys, arcade from utils.utils import get_closest_resolution, print_debug_info, on_exception from utils.constants import log_dir, menu_background_color from menus.main import Main -import utils.preload # type: ignore -from arcade.experimental.controller_window import ControllerWindow +from utils.preload import theme_sound # type: ignore # needed for preload sys.excepthook = on_exception @@ -56,7 +55,24 @@ else: vsync = True fps_limit = 0 -window = ControllerWindow(width=resolution[0], height=resolution[1], title='ShatterStack', samples=antialiasing, antialiasing=antialiasing > 0, fullscreen=fullscreen, vsync=vsync, resizable=False, style=style) + settings = { + "music": True, + "music_volume": 50, + "resolution": f"{resolution[0]}x{resolution[1]}", + "antialiasing": "4x MSAA", + "window_mode": "Windowed", + "vsync": True, + "fps_limit": 60, + "discord_rpc": True + } + + with open("settings.json", "w") as file: + file.write(json.dumps(settings)) + +if settings.get("music", True): + theme_sound.play(volume=settings.get("music_volume", 50) / 100, loop=True) + +window = arcade.Window(width=resolution[0], height=resolution[1], title='Game Of Life', samples=antialiasing, antialiasing=antialiasing > 0, fullscreen=fullscreen, vsync=vsync, resizable=False, style=style) if vsync: window.set_vsync(True) @@ -71,6 +87,8 @@ else: window.set_update_rate(1 / 99999999) window.set_draw_rate(1 / 99999999) +arcade.set_background_color(menu_background_color) + print_debug_info() main = Main() diff --git a/utils/constants.py b/utils/constants.py index 07bf826..ce98ef5 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,8 +1,9 @@ import arcade.color from arcade.types import Color -from arcade.gui.widgets.buttons import UITextureButtonStyle +from arcade.gui.widgets.buttons import UITextureButtonStyle, UIFlatButtonStyle +from arcade.gui.widgets.slider import UISliderStyle -menu_background_color = Color(28, 28, 28) +menu_background_color = (30, 30, 47) log_dir = 'logs' CELL_SIZE = 48 ROWS = 14 @@ -11,6 +12,33 @@ OUTLINE_WIDTH = 2 button_style = {'normal': UITextureButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK), 'hover': UITextureButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK), 'press': UITextureButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK), 'disabled': UITextureButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK)} +dropdown_style = {'normal': UIFlatButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK, bg=Color(128, 128, 128)), 'hover': UIFlatButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK, bg=Color(49, 154, 54)), + 'press': UIFlatButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK, bg=Color(128, 128, 128)), 'disabled': UIFlatButtonStyle(font_name="Protest Strike", font_color=arcade.color.BLACK, bg=Color(128, 128, 128))} + +slider_default_style = UISliderStyle(bg=Color(128, 128, 128), unfilled_track=Color(128, 128, 128), filled_track=Color(49, 154, 54)) +slider_hover_style = UISliderStyle(bg=Color(49, 154, 54), unfilled_track=Color(128, 128, 128), filled_track=Color(49, 154, 54)) + +slider_style = {'normal': slider_default_style, 'hover': slider_hover_style, 'press': slider_hover_style, 'disabled': slider_default_style} + +settings = { + "Graphics": { + "Window Mode": {"type": "option", "options": ["Windowed", "Fullscreen", "Borderless"], "config_key": "window_mode", "default": "Windowed"}, + "Resolution": {"type": "option", "options": ["1366x768", "1440x900", "1600x900", "1920x1080", "2560x1440", "3840x2160"], "config_key": "resolution"}, + "Anti-Aliasing": {"type": "option", "options": ["None", "2x MSAA", "4x MSAA", "8x MSAA", "16x MSAA"], "config_key": "anti_aliasing", "default": "4x MSAA"}, + "VSync": {"type": "bool", "config_key": "vsync", "default": True}, + "FPS Limit": {"type": "slider", "min": 0, "max": 480, "config_key": "fps_limit", "default": 60}, + }, + "Sound": { + "Music": {"type": "bool", "config_key": "music", "default": True}, + "SFX": {"type": "bool", "config_key": "sfx", "default": True}, + "Music Volume": {"type": "slider", "min": 0, "max": 100, "config_key": "music_volume", "default": 50}, + "SFX Volume": {"type": "slider", "min": 0, "max": 100, "config_key": "sfx_volume", "default": 50}, + }, + "Miscellaneous": { + "Discord RPC": {"type": "bool", "config_key": "discord_rpc", "default": True}, + }, + "Credits": {} +} SHAPES = { "I": [(0, 0), (1, 0), (2, 0), (3, 0)], diff --git a/utils/preload.py b/utils/preload.py index 18b76e3..6f664c4 100644 --- a/utils/preload.py +++ b/utils/preload.py @@ -2,3 +2,6 @@ import arcade.gui, arcade button_texture = arcade.gui.NinePatchTexture(64 // 4, 64 // 4, 64 // 4, 64 // 4, arcade.load_texture("assets/graphics/button.png")) button_hovered_texture = arcade.gui.NinePatchTexture(64 // 4, 64 // 4, 64 // 4, 64 // 4, arcade.load_texture("assets/graphics/button_hovered.png")) + +theme_sound = arcade.Sound("assets/sound/music.ogg") +click_sound = arcade.Sound("assets/sound/click.wav") diff --git a/utils/utils.py b/utils/utils.py index 2258068..cb85874 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -65,7 +65,7 @@ def on_exception(*exc_info): logging.error(f"Unhandled exception:\n{''.join(traceback.format_exception(exc_info[1], limit=None))}") def get_closest_resolution(): - allowed_resolutions = [(800, 600), (1024, 768), (1280, 720), (1366, 768), (1440, 900), (1600,900), (1920,1080), (2560,1440), (3840,2160)] + allowed_resolutions = [(1366, 768), (1440, 900), (1600,900), (1920,1080), (2560,1440), (3840,2160)] screen_width, screen_height = arcade.get_screens()[0].width, arcade.get_screens()[0].height if (screen_width, screen_height) in allowed_resolutions: if not allowed_resolutions.index((screen_width, screen_height)) == 0: diff --git a/uv.lock b/uv.lock index 273ea5a..770a32a 100644 --- a/uv.lock +++ b/uv.lock @@ -136,14 +136,12 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "arcade" }, - { name = "pillow" }, { name = "pypresence" }, ] [package.metadata] requires-dist = [ { name = "arcade", git = "https://github.com/pythonarcade/arcade.git?rev=gui%2Fcontroller" }, - { name = "pillow", specifier = ">=11.0.0" }, { name = "pypresence", specifier = ">=4.3.0" }, ]