#!/usr/bin/python3 from dataclasses import dataclass from functools import lru_cache, partial from pathlib import Path import subprocess import selectors #import pygame_sdl2 as pygame import pygame import yaml from .profiledb import ProfileDB from .gamechest.cliactions import run from .gamechest.gamedb import GameDB from .gamechest.statusdb import StatusDB from .gamechest.gameconfig import config, status from .gamechest.runners.install import Install from .gamechest.runners.remove import Remove # 16:9 ratio #WINDOW_RESOLUTION = (1280, 720) #WINDOW_RESOLUTION = (800, 600) WINDOW_RESOLUTION = (1024, 576) if pygame.ver[0] != '2': raise ValueError(f'pygame2 required, got {pygame.ver}') class Event(Exception): pass class QuitEvent(Event): pass @lru_cache(maxsize=40) def get_text_surface(text, font, color): new_surface = font.render(text, Parameters.font_antialias, color) print('cache miss for', text) return new_surface def calculate_new_size(size_max, size): width_max, height_max = size_max width, height = size if width > width_max: ratio = width_max / width width = width_max height = height * ratio if height > height_max: ratio = height_max / height height = height_max width = width * ratio return width, height @lru_cache(maxsize=40) def get_image_surface(path, resize=None, alpha=False): print('cache miss for', path) image = pygame.image.load(str(path)) if alpha: image = image.convert_alpha() else: image = image.convert() if resize: new_size = calculate_new_size(resize, (image.get_width(), image.get_height())) image = pygame.transform.scale(image, new_size) print('new_size', new_size) return image @dataclass class AppState: menu_selected_index: int = 0 class Parameters: key_delay = 330 key_interval = 1000//25 font_antialias = True class GuiApp: def __init__(self): pygame.init() pygame.key.set_repeat(Parameters.key_delay, Parameters.key_interval) #print(pygame.display.list_modes()) self.window = pygame.display.set_mode(WINDOW_RESOLUTION) self.clock = pygame.time.Clock() self.cycles_per_second = 60 self.menu_font = pygame.font.Font(pygame.font.get_default_font(), 16) self.menu_color = (0, 0, 200) self.render_timer = 0 self.render_timeout = 1000//30 # 30 fps def process_input(self, last_frame_time, event=None): if event is None: event = pygame.event.poll() if event.type == pygame.KEYDOWN: print('event', event, 'key name', pygame.key.name(event.key)) if event.key in (pygame.K_q, pygame.K_ESCAPE): raise QuitEvent() if event.type == pygame.QUIT: raise QuitEvent() def update(self, last_frame_time): pass def render(self, last_frame_time): self.render_timer += last_frame_time if self.render_timer < self.render_timeout: return self.render_timer = 0 self.window.fill((0, 0, 0)) # Initial y y = 50 x = 50 self.window.blit(surface, (x, y)) pygame.display.update() def loop(self): last_frame_time = 1 # equivalent to 0 but avoid div0 last_render_time = 1 try: while 1: self.process_input(last_frame_time) self.update(last_frame_time) self.render(last_frame_time) last_frame_time = self.clock.tick(self.cycles_per_second) except QuitEvent: pass print('quitting') pygame.quit() class ProfileDataObserver: def event_current_profile_changed(self, new_profile): pass class ObservedSubject: def __init__(self): self.observers = [] def add_observer(self, observer): self.observers.append(observer) def del_observer(self, observer): self.observers.remove(observer) def notify_event(self, event_name, *args, **kws): for observer in self.observers: getattr(observer, event_name)(*args, **kws) class ProfileData(ObservedSubject): def __init__(self): super().__init__() self.profiledb = ProfileDB() self.profiles_len = len(self.profiledb.get_profiles()) self.current_profile_index = 0 configured_default_profile = config.get_profile_id() last_loaded_profile = status.last_loaded_profile or configured_default_profile for index, profile in enumerate(self.profiledb.get_profiles()): if profile['name'] == last_loaded_profile: self.current_profile_index = index def change_active_profile(self, new_index): if new_index < 0: new_index = 0 elif new_index >= self.profiles_len: new_index = self.profiles_len-1 if self.current_profile_index != new_index: self.current_profile_index = new_index self.notify_event('event_current_profile_changed', self.get_current_profile()) def change_profile_increase(self): self.change_active_profile(self.current_profile_index +1) def change_profile_decrease(self): self.change_active_profile(self.current_profile_index -1) def get_current_profile(self): return self.profiledb.get_profiles()[self.current_profile_index]['name'] def get_current_profile_avatar(self): return self.profiledb.get_profiles()[self.current_profile_index].get('avatar', None) def get_current_display(self): return self.profiledb.get_profiles()[self.current_profile_index]['display'] class ProfileRenderer(ProfileDataObserver): def __init__(self, profiledata): self.profiledata = profiledata self.surface_to_render = None profiledata.add_observer(self) self.font_filename = pygame.font.match_font('bitstreamverasans') print('ProfileRenderer font:', self.font_filename) self.font = pygame.font.Font(self.font_filename, 24) self.font_fg_color = (0, 200, 200) self.avatar_surface = None self.avatar_size = (100, 100) self.image_box = get_image_surface(config.get_data_d() / 'asset/box.png', resize=(170, 170), alpha=True) self.load_avatar() def load_avatar(self): self.avatar_surface = None if path := self.profiledata.get_current_profile_avatar(): path = config.get_data_d() / path if path.exists(): self.avatar_surface = get_image_surface(path, self.avatar_size) else: print('no avatar found at', path) def event_current_profile_changed(self, new_profile): self.surface_to_render = None self.load_avatar() print('changing rendering profile to', new_profile) def render(self, surface, pos): if self.surface_to_render is None: profile_name = self.profiledata.get_current_profile() new_surface = get_text_surface( self.profiledata.get_current_display(), self.font, self.font_fg_color) #new_surface = self.font.render(self.profiledata.get_current_display(), Parameters.font_antialias, self.font_fg_color) self.surface_to_render = new_surface avatar_pos = (pos[0] + 15, pos[1] + 15) #text_pos = (avatar_pos[0] + self.avatar_size[0] + 5, avatar_pos[1]) text_pos = (avatar_pos[0], avatar_pos[1] + self.avatar_size[1]) surface.blit(self.image_box, pos) if self.avatar_surface: surface.blit(self.avatar_surface, avatar_pos) surface.blit(self.surface_to_render, text_pos) class GameData(ObservedSubject): def __init__(self): super().__init__() self.game_db = GameDB() self.games_idtitles = list(self.game_db.get_idtitles()) self.games_len = len(self.games_idtitles) self.game_index = 0 last_loaded_game = status.last_loaded_game for index, (game_id, _) in enumerate(self.games_idtitles): if game_id == last_loaded_game: self.game_index = index def change_active_game(self, new_index): if new_index < 0: new_index = 0 elif new_index >= self.games_len: new_index = self.games_len-1 if self.game_index != new_index: self.game_index = new_index self.notify_event('event_current_game_changed', self.get_current_gameinfo()) #def get_idtitles(self): # return self.games_idtitles def get_games_subset(self): low_index = max(self.game_index-3, 0) high_index = low_index + 6 for index, item in enumerate(self.games_idtitles[low_index:high_index], start=low_index): selected = 1 if self.game_index == index else 0 game_info = self.game_db.get_info(item[0]) current_icon = game_info.get( 'icon', None) if current_icon is not None: current_icon = config.get_data_d() / current_icon else: current_icon = config.get_data_d() / 'asset/default_icon.jpeg' yield selected, item[0], item[1], current_icon, game_info def get_current_gameinfo(self): return self.game_db.get_info(self.get_current_id()) def get_current_id(self): current_id = self.games_idtitles[self.game_index][0] return current_id def get_current_title(self): current_title = self.games_idtitles[self.game_index][1] return current_title def change_game_increase(self): self.change_active_game(self.game_index +1) def change_game_decrease(self): self.change_active_game(self.game_index -1) class GameDataObserver: def event_current_game_changed(self, new_game_info): pass class GameRenderer(GameDataObserver): def __init__(self, gamedata, status_db): self.gamedata = gamedata self.status_db = status_db gamedata.add_observer(self) self.font_filename = pygame.font.match_font('bitstreamverasans') print('GameRenderer font:', self.font_filename) self.font = pygame.font.Font(self.font_filename, 16) self.font_selected = pygame.font.Font(self.font_filename, 24) self.font_fg_color = (90, 90, 200) #self.image_right_arrow = get_image_surface(config.get_data_d() / 'asset/arrow-right.png', # resize=(16, 16), # alpha=True) self.image_right_arrow = get_image_surface(config.get_data_d() / 'asset/bulb.png', resize=(16, 16), alpha=True) self.image_box = get_image_surface(config.get_data_d() / 'asset/box.png', resize=(400, 400), alpha=True) self.image_box.set_alpha(220) def event_current_game_changed(self, new_game_info): print('game changed, new game:', new_game_info['title']) def render(self, dest_surface, pos): x = pos[0] y = pos[1] y_pad = 32 surface_height = None #selected_item_y = y #low_index = max(selected_index-3, 0) #high_index = low_index + 6 dest_surface.blit(self.image_box, pos) x += 15 y += 45 for selected, game_id, game_title, game_icon, game_info in self.gamedata.get_games_subset(): text = game_title if not self.status_db.is_installed(game_info): text = f'[u] {text}' else: text = f'[i] {text}' arrow_width = self.image_right_arrow.get_width() text_x = x + y_pad surface = get_text_surface(text, self.font_selected if selected else self.font, self.font_fg_color) if game_icon and game_icon.exists(): icon_surface = get_image_surface(game_icon, resize=(y_pad, y_pad), alpha=True) icon_pos = (x + arrow_width, y + (surface.get_height() - icon_surface.get_height())/2) dest_surface.blit(icon_surface, icon_pos) dest_surface.blit(surface, (text_x + arrow_width + 5, y)) if selected: surface_height = surface.get_height() #selected_item_y = y dest_surface.blit(self.image_right_arrow, (x, y + (surface_height - self.image_right_arrow.get_height())/2)) y += surface.get_height() + y_pad class GameCoverRenderer(GameDataObserver): def __init__(self, gamedata): self.gamedata = gamedata gamedata.add_observer(self) self.current_game_info = self.gamedata.get_current_gameinfo() self.cover_shift = (0, 0) self.default_cover_surface = get_image_surface(config.get_data_d() / 'asset/default_cover.jpeg', resize=WINDOW_RESOLUTION) self.default_cover_shift = ( (WINDOW_RESOLUTION[0] - self.default_cover_surface.get_width())/2, (WINDOW_RESOLUTION[1] - self.default_cover_surface.get_height())/2, ) self.load_cover() def event_current_game_changed(self, new_game_info): print('game changed, cover:', new_game_info.get('cover', None)) self.current_game_info = new_game_info self.load_cover() def load_cover(self): self.cover_surface = None path = self.current_game_info.get('cover', None) if path is None: self.cover_surface = self.default_cover_surface self.cover_shift = self.default_cover_shift return path = config.get_data_d() / Path(path) if path.exists(): self.cover_surface = get_image_surface(path, resize=WINDOW_RESOLUTION) self.cover_shift = ( (WINDOW_RESOLUTION[0] - self.cover_surface.get_width())/2, (WINDOW_RESOLUTION[1] - self.cover_surface.get_height())/2, ) else: self.cover_surface = self.default_cover_surface self.cover_shift = self.default_cover_shift def render(self, dest_surface, pos): if self.cover_surface is None: return pos = ( pos[0] + self.cover_shift[0], pos[1] + self.cover_shift[1], ) dest_surface.blit(self.cover_surface, pos) class ProgressBarRenderer: def __init__(self): self.progress = .0 self.back_color = 0xEEEEEEF0 self.front_color = 0x0000FF00 def set_progress(self, progress): 'progress is between .0 and 1.0' self.progress = progress def render(self, dest_surface, pos): size = (100, 10) background_rect = pygame.Rect(pos[0], pos[1], size[0], size[1]) inside_rect = pygame.Rect(pos[0]+2, pos[1]+2, int(96*self.progress), 6) dest_surface.lock() pygame.draw.rect(dest_surface, self.back_color, background_rect, border_radius=5) pygame.draw.rect(dest_surface, self.front_color, inside_rect, border_radius=5) dest_surface.unlock() class DoubleProgressBarRenderer: def __init__(self): self.progress_bar_global = ProgressBarRenderer() self.progress_bar_step = ProgressBarRenderer() self.title = "" self.title_surface = None self.font_filename = pygame.font.match_font('bitstreamverasans') print('GameRenderer font:', self.font_filename) self.font = pygame.font.Font(self.font_filename, 10) self.font_color = 0xFFFFFFFF self.set_title("") def set_progress(self, global_progress, step_progress): self.progress_bar_global.set_progress(global_progress) self.progress_bar_step.set_progress(step_progress) def set_title(self, title): self.title = title or "" self.title_surface = get_text_surface(title, self.font, self.font_color) def render(self, dest_surface, pos): x = pos[0] y = pos[1] rect = pygame.Rect(x, y, 104, 10+10+10+2+2+2+2) x += 2 y += 2 pygame.draw.rect(dest_surface, 0x00000000, rect, border_radius=3) if self.title_surface: dest_surface.blit(self.title_surface, (x, y)) #y += self.title_surface.get_height() + 2 y += 10 + 2 self.progress_bar_global.render(dest_surface, (x, y)) self.progress_bar_step.render(dest_surface, (x, y+12)) class GuiAppSub1(GuiApp): def __init__(self): super().__init__() self.input_timer = 0 self.input_timeout = 1000//10 self.state = AppState() self.base_window_fill_color = (0, 0, 0) self.user_move_vector = (0, 0) self.game_db = GameDB() #self.menu_font_cache = ({}, []) #self.menu_font_cache_max = 40 #self.last_movement_update_time = self.clock. self.joysticks = {} self.joysticks_axis_threshold = 0 self.profile_data = ProfileData() self.profile_renderer = ProfileRenderer(self.profile_data) self.game_data = GameData() self.status_db = StatusDB() self.game_renderer = GameRenderer(self.game_data, self.status_db) self.game_cover_renderer = GameCoverRenderer(self.game_data) self.action = None self.runner = None self.progress_bar = DoubleProgressBarRenderer() self.selector = selectors.DefaultSelector() self.delay_change = None def process_input(self, last_frame_time): movement_up_keys = ( pygame.K_UP, pygame.K_k, ) movement_down_keys = ( pygame.K_DOWN, pygame.K_j, ) axis_change = 0 for event in pygame.event.get(): go_super = 1 x, y = self.user_move_vector if event.type == pygame.KEYDOWN: if event.key in (*movement_up_keys, *movement_down_keys): y = 1 if event.key in movement_down_keys else -1 go_super = 0 elif event.key in (pygame.K_LEFT, pygame.K_RIGHT): x = -1 if event.key == pygame.K_LEFT else 1 go_super = 0 elif event.type == pygame.KEYUP: x, y = self.user_move_vector if event.key in (*movement_up_keys, *movement_down_keys): y = 0 go_super = 0 elif event.key in (pygame.K_LEFT, pygame.K_RIGHT): x = 0 go_super = 0 elif event.key == pygame.K_RETURN: self.action = 'run' elif event.key == pygame.K_i: self.action = 'install' elif event.key == pygame.K_d: self.action = 'remove' elif event.type in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP): go_super = 0 factor = 1 if pygame.JOYBUTTONDOWN else 0 joystick = self.joysticks[event.instance_id] print('gamepad event', event) #if event.button == 0: #y += 1 * factor if event.type == pygame.JOYBUTTONDOWN: joystick.rumble(0.3, 0.8, 40) if event.button == 1: raise QuitEvent() elif event.button == 2: self.action = 'run' #if event.button == 1: #y -= 1 * factor elif event.type == pygame.JOYAXISMOTION: go_super = 0 if event.axis == 1: # left stick up-down axis new_axis = self.joysticks_axis_threshold #print('motion event', event) if event.value > 0.70: new_axis = 1 # down activated elif event.value < 0.5 and event.value > -0.5: new_axis = 0 # down and up deactivated elif event.value < -0.70: new_axis = -1 # up activated if new_axis != self.joysticks_axis_threshold: axis_change = 1 self.joysticks_axis_threshold = new_axis print('threshold', self.joysticks_axis_threshold) elif event.type == pygame.JOYDEVICEADDED: # This event will be generated when the program starts for every # joystick, filling up the list without needing to create them manually. joy = pygame.joystick.Joystick(event.device_index) self.joysticks[joy.get_instance_id()] = joy print(f"Gamepad {joy.get_instance_id()} connected") elif event.type == pygame.JOYDEVICEREMOVED: del self.joysticks[event.instance_id] print(f"Gamepad {event.instance_id} disconnected") if go_super == 0: if axis_change: self.user_move_vector = (x, self.joysticks_axis_threshold) else: self.user_move_vector = (x, y) #print('new move vector:', self.user_move_vector) else: super().process_input(last_frame_time, event) def update(self, last_frame_time): #self.input_timer += last_frame_time #if self.input_timer < self.input_timeout: # return #self.input_timer = 0 if self.user_move_vector[1] < 0: self.game_data.change_game_decrease() elif self.user_move_vector[1] > 0: self.game_data.change_game_increase() if self.user_move_vector[0] < 0: self.profile_data.change_profile_decrease() elif self.user_move_vector[0] > 0: self.profile_data.change_profile_increase() if action := self.action: self.action = None profile_id = self.profile_renderer.profiledata.get_current_profile() game_id = self.game_data.get_current_id() game_info = self.game_data.get_current_gameinfo() print('current id', game_id) print('profile id', profile_id) if not self.runner: # action not already running, else skip action if action == 'run': if not self.status_db.is_installed(game_info): print('Game', game_id, 'is not installed, aborting.', file=sys.stderr) else: status.set_last_loaded_game(game_id) status.set_last_loaded_profile(profile_id) command = self.game_db.get_game_command(profile_id, game_id) subprocess.run(command) elif action == 'install': #subprocess.run([ # 'gamechest', # 'install', # game_id]) remote_basedir = config.get_remote_basedir() source = f'{remote_basedir}/{game_info["package_name"]}' dest = config.get_games_install_basedir() / game_info['id'] dest.mkdir(parents=True, exist_ok=True) self.runner = Install(source, dest) self.selector.register(self.runner.get_read_fd(), selectors.EVENT_READ) self.delay_change = partial(self.status_db.set_installed, game_info) self.progress_bar.set_title("installing...") elif action == 'remove': remote_basedir = config.get_remote_basedir() path = ( config.get_games_install_basedir() / game_id ) #subprocess.run([ # 'gamechest', # 'remove', # game_id]) self.runner = Remove(path) self.selector.register(self.runner.get_read_fd(), selectors.EVENT_READ) self.delay_change = partial(self.status_db.set_uninstalled, game_info) self.progress_bar.set_title("removing...") if self.runner: if self.runner.poll() is None: if self.selector.select(0): progress = self.runner.progress_read() self.progress_bar.set_progress(progress.step / progress.steps_count, progress.percent / 100) else: self.progress_bar.set_progress(1, 1) self.progress_bar.set_title("done") self.runner = None if self.delay_change is not None: self.delay_change() self.delay_change = None self.user_move_vector = (0, 0) def render(self, last_frame_time): self.window.fill(self.base_window_fill_color) self.game_cover_renderer.render(self.window, (0, 0)) # Initial y y = 50 x = 50 + 64 + 5 self.game_renderer.render(self.window, (x, y)) fps_surface = get_text_surface(f'{self.clock.get_fps():02.0f}', self.menu_font, self.menu_color) fps_pos = (self.window.get_width() - fps_surface.get_width(), 0) self.window.blit(fps_surface, fps_pos) self.profile_renderer.render(self.window, (700, 50)) self.progress_bar.render(self.window, (500, 50)) pygame.display.update() app = GuiAppSub1() app.loop()