exp-runtime-player 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exp_runtime/app_icon.py +12 -0
- exp_runtime/app_info.py +16 -0
- exp_runtime/application.py +825 -0
- exp_runtime/assets/kiwi.svg +49 -0
- exp_runtime/desktop.py +726 -0
- exp_runtime/desktop_choice.py +202 -0
- exp_runtime/desktop_dialogue.py +271 -0
- exp_runtime/desktop_input.py +176 -0
- exp_runtime/desktop_loading.py +45 -0
- exp_runtime/desktop_menu.py +448 -0
- exp_runtime/desktop_message.py +123 -0
- exp_runtime/desktop_pause.py +105 -0
- exp_runtime/desktop_picker.py +68 -0
- exp_runtime/desktop_text.py +88 -0
- exp_runtime/desktop_title.py +82 -0
- exp_runtime/graphics.py +79 -0
- exp_runtime/platforms/__init__.py +1 -0
- exp_runtime/platforms/drawing.py +612 -0
- exp_runtime/platforms/ios.py +225 -0
- exp_runtime/pointer.py +25 -0
- exp_runtime/projection.py +68 -0
- exp_runtime/settings.py +46 -0
- exp_runtime/updates.py +144 -0
- exp_runtime_player-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_player-0.5.0.dist-info/RECORD +29 -0
- exp_runtime_player-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_player-0.5.0.dist-info/entry_points.txt +3 -0
- exp_runtime_player-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_player-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""Shared application lifecycle: import, menu, episode sessions and shutdown.
|
|
2
|
+
|
|
3
|
+
The executable starts here with no game data. A host supplies graphics/input
|
|
4
|
+
primitives; imports run on a worker and publish a validated library atomically.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('PYGAME_HIDE_SUPPORT_PROMPT', '1')
|
|
15
|
+
from . import graphics as pygame
|
|
16
|
+
|
|
17
|
+
from .app_icon import icon_surface
|
|
18
|
+
from .app_info import PROJECT_URL
|
|
19
|
+
from .content import ContentError, ContentLibrary, import_game, is_bundled
|
|
20
|
+
from .desktop import Desktop, SIZE
|
|
21
|
+
from .desktop_menu import MenuRenderer
|
|
22
|
+
from .languages import TITLE_LANGUAGES
|
|
23
|
+
from .games import GAMES
|
|
24
|
+
from .menu import (EPISODE_HEADER_HEIGHT, EPISODE_ROW_HEIGHT, MenuState,
|
|
25
|
+
default_library, group_episodes, remember_library)
|
|
26
|
+
from .runtime import SaveError, Session
|
|
27
|
+
from .settings import AppSettings
|
|
28
|
+
from .updates import UpdateCheck
|
|
29
|
+
from .pointer import ButtonPress
|
|
30
|
+
from .vm import VMError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ERRORS = (ContentError, SaveError, VMError, OSError, ValueError, pygame.error)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Application:
|
|
37
|
+
def __init__(self, directory=None, *, audio=True, game_key=None, file_picker=None,
|
|
38
|
+
library_root=None, url_opener=None, settings_path=None,
|
|
39
|
+
update_fetcher=None, check_updates=True):
|
|
40
|
+
if game_key is not None and game_key not in GAMES:
|
|
41
|
+
GAMES[game_key] # Explain which optional game component is missing.
|
|
42
|
+
if not GAMES:
|
|
43
|
+
raise ContentError('No game adapters are installed. Install "exp-runtime[shs]", '
|
|
44
|
+
'"exp-runtime[cod]", or "exp-runtime[player]" for both.')
|
|
45
|
+
default_game = next(iter(GAMES))
|
|
46
|
+
pygame.display.init()
|
|
47
|
+
pygame.font.init()
|
|
48
|
+
pygame.display.set_icon(icon_surface())
|
|
49
|
+
self.window = pygame.display.set_mode(SIZE, pygame.RESIZABLE)
|
|
50
|
+
pygame.display.set_caption('EXP Runtime')
|
|
51
|
+
self.remember_location = directory is None and library_root is None
|
|
52
|
+
self.file_picker = file_picker
|
|
53
|
+
self.url_opener = url_opener
|
|
54
|
+
self.settings = AppSettings(settings_path)
|
|
55
|
+
self.updates = UpdateCheck(update_fetcher)
|
|
56
|
+
self.check_updates = check_updates # Diagnostics can remain entirely offline.
|
|
57
|
+
self.update_prompt = None
|
|
58
|
+
self.library_path = (default_library if library_root is None else
|
|
59
|
+
lambda key: Path(library_root) / key)
|
|
60
|
+
self.selected_game = game_key
|
|
61
|
+
self.directory = Path(directory) if directory is not None else self.library_path(game_key or default_game)
|
|
62
|
+
self.library = self.state = self.game = None
|
|
63
|
+
self.renderer = MenuRenderer()
|
|
64
|
+
self.screen = 'games' if directory is None and game_key is None else 'setup'
|
|
65
|
+
self.history = []
|
|
66
|
+
self.message = ''
|
|
67
|
+
self.scope = 'all'
|
|
68
|
+
self.selected = None
|
|
69
|
+
self.saved = set()
|
|
70
|
+
self.query = ''
|
|
71
|
+
self.scroll = 0
|
|
72
|
+
self.episode_scroll = 0
|
|
73
|
+
self.expanded_groups = set()
|
|
74
|
+
self.focus = None
|
|
75
|
+
self.press = ButtonPress()
|
|
76
|
+
self.focus_index = 0
|
|
77
|
+
self.active = True
|
|
78
|
+
self.intro = True
|
|
79
|
+
self.menu_age = 0
|
|
80
|
+
self.transition_age = 200
|
|
81
|
+
self.transition_from = None
|
|
82
|
+
self.buttons = []
|
|
83
|
+
self.viewport = pygame.Rect(0, 0, *SIZE)
|
|
84
|
+
self.browser_kind = None
|
|
85
|
+
self.folder = Path.cwd()
|
|
86
|
+
self.path_text = str(self.folder)
|
|
87
|
+
self.files = []
|
|
88
|
+
self.dropped_files = []
|
|
89
|
+
self.dropping = False
|
|
90
|
+
self.audio = False
|
|
91
|
+
self.menu_music = pygame.create_menu_music()
|
|
92
|
+
self.menu_music_token = None
|
|
93
|
+
self.menu_music_loaded = self.menu_music_paused = False
|
|
94
|
+
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='exp-content')
|
|
95
|
+
self.job = None
|
|
96
|
+
self.job_kind = None
|
|
97
|
+
if audio:
|
|
98
|
+
try:
|
|
99
|
+
pygame.mixer.init()
|
|
100
|
+
self.audio = True
|
|
101
|
+
except pygame.error as error:
|
|
102
|
+
logging.warning('Audio unavailable: %s', error)
|
|
103
|
+
if self.screen != 'games' and self.directory.exists():
|
|
104
|
+
try:
|
|
105
|
+
self.open_library(self.directory)
|
|
106
|
+
except ERRORS as error:
|
|
107
|
+
self.message = str(error)
|
|
108
|
+
if self.screen == 'setup' and self.selected_game is None:
|
|
109
|
+
self.selected_game = default_game
|
|
110
|
+
if self.check_updates and self.settings.check_for_updates:
|
|
111
|
+
self.updates.start()
|
|
112
|
+
|
|
113
|
+
def input_token(self):
|
|
114
|
+
return self.screen, self.message, self.update_prompt
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def game_title(self):
|
|
118
|
+
return GAMES[self.selected_game].TITLE if self.selected_game else 'EXP Runtime'
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def pressed(self):
|
|
122
|
+
return self.press.pressed
|
|
123
|
+
|
|
124
|
+
def cancel_pointer(self):
|
|
125
|
+
self.press.cancel()
|
|
126
|
+
if self.game:
|
|
127
|
+
self.game.cancel_pointer()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def paused_menu(self):
|
|
131
|
+
return bool(self.game and self.game.menu_open and 'game' in self.history)
|
|
132
|
+
|
|
133
|
+
def choose_game(self, key):
|
|
134
|
+
if key not in GAMES:
|
|
135
|
+
raise ContentError(f'Unknown game: {key}')
|
|
136
|
+
self.release_library()
|
|
137
|
+
self.selected_game, self.directory = key, self.library_path(key)
|
|
138
|
+
self.renderer = MenuRenderer()
|
|
139
|
+
self.screen, self.history, self.message = 'setup', [], ''
|
|
140
|
+
pygame.display.set_caption(self.game_title)
|
|
141
|
+
if self.directory.exists():
|
|
142
|
+
self.open_library(self.directory)
|
|
143
|
+
|
|
144
|
+
def release_library(self, *, checkpoint=True):
|
|
145
|
+
# Do not discard a live session if its checkpoint cannot be written.
|
|
146
|
+
if checkpoint and self.game:
|
|
147
|
+
self.state.checkpoint(self.game.session)
|
|
148
|
+
if self.audio:
|
|
149
|
+
self.menu_music.stop()
|
|
150
|
+
self.menu_music_token = None
|
|
151
|
+
self.menu_music_loaded = self.menu_music_paused = False
|
|
152
|
+
pygame.mixer.music.stop()
|
|
153
|
+
pygame.mixer.stop()
|
|
154
|
+
if self.library:
|
|
155
|
+
self.library.close()
|
|
156
|
+
self.library = self.state = self.game = None
|
|
157
|
+
self.saved = set()
|
|
158
|
+
self.selected = None
|
|
159
|
+
if hasattr(self, 'click_sound'):
|
|
160
|
+
del self.click_sound
|
|
161
|
+
|
|
162
|
+
def switch_games(self):
|
|
163
|
+
other = next((key for key in GAMES if key != self.selected_game
|
|
164
|
+
and (self.library_path(key) / 'library.json').is_file()), None)
|
|
165
|
+
if other is not None:
|
|
166
|
+
self.choose_game(other)
|
|
167
|
+
return
|
|
168
|
+
self.show_game_chooser()
|
|
169
|
+
|
|
170
|
+
def show_game_chooser(self):
|
|
171
|
+
self.release_library()
|
|
172
|
+
self.selected_game = None
|
|
173
|
+
self.renderer = MenuRenderer()
|
|
174
|
+
self.screen, self.history, self.message = 'games', [], ''
|
|
175
|
+
self.focus = None
|
|
176
|
+
self.cancel_pointer()
|
|
177
|
+
self.transition_from = None
|
|
178
|
+
self.transition_age = 200
|
|
179
|
+
pygame.key.stop_text_input()
|
|
180
|
+
pygame.display.set_caption('EXP Runtime')
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def busy(self):
|
|
184
|
+
return self.job is not None
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def ready(self):
|
|
188
|
+
return (not self.busy and self.transition_age >= 200
|
|
189
|
+
and (self.screen != 'main' or self.menu_age >= (3000 if self.intro else 500)))
|
|
190
|
+
|
|
191
|
+
def open_library(self, directory, *, remember=False):
|
|
192
|
+
library = ContentLibrary(directory)
|
|
193
|
+
try:
|
|
194
|
+
if self.selected_game and library.game_id != self.selected_game:
|
|
195
|
+
raise ContentError(f'This library belongs to {library.game_title}. '
|
|
196
|
+
f'Select {library.game_title} in the game chooser to open it.')
|
|
197
|
+
library.ensure_builtin_episodes()
|
|
198
|
+
state = MenuState(library)
|
|
199
|
+
renderer = MenuRenderer(library)
|
|
200
|
+
if self.game:
|
|
201
|
+
self.state.checkpoint(self.game.session)
|
|
202
|
+
if remember and self.remember_location:
|
|
203
|
+
remember_library(directory, library.game_id)
|
|
204
|
+
except Exception:
|
|
205
|
+
library.close()
|
|
206
|
+
raise
|
|
207
|
+
self.release_library(checkpoint=False)
|
|
208
|
+
self.directory, self.library = Path(directory), library
|
|
209
|
+
self.selected_game = library.game_id
|
|
210
|
+
self.state, self.renderer, self.game = state, renderer, None
|
|
211
|
+
self.expanded_groups = {'saved'}
|
|
212
|
+
self.episode_scroll = 0
|
|
213
|
+
if hasattr(self, 'click_sound'):
|
|
214
|
+
del self.click_sound
|
|
215
|
+
self.screen, self.history, self.selected = 'main', [], state.selected
|
|
216
|
+
self.menu_age, self.intro = 0, True
|
|
217
|
+
self.message = state.warning
|
|
218
|
+
pygame.display.set_caption(self.game_title)
|
|
219
|
+
self.refresh_saves()
|
|
220
|
+
|
|
221
|
+
def refresh_saves(self):
|
|
222
|
+
self.saved = {e['id'] for e in self.library.episodes if self.state.resume_path(e['id'])}
|
|
223
|
+
if self.game and not self.game.session.episode_exited:
|
|
224
|
+
self.saved.add(self.game.session.resources.record['id'])
|
|
225
|
+
|
|
226
|
+
def can_resume(self, episode=None):
|
|
227
|
+
return (episode or self.state.selected) in self.saved
|
|
228
|
+
|
|
229
|
+
def visible_episodes(self):
|
|
230
|
+
records = self.library.episodes
|
|
231
|
+
if self.scope == 'weekly':
|
|
232
|
+
records = [e for e in records if not is_bundled(e)]
|
|
233
|
+
if self.query:
|
|
234
|
+
query = self.query.casefold()
|
|
235
|
+
matching_groups = {e['id'] for section in self._episode_sections(records)
|
|
236
|
+
if query in section.title.casefold() for e in section.episodes}
|
|
237
|
+
records = [e for e in records if any(query in title.casefold() for title in e['titles'])
|
|
238
|
+
or query in e['name'].casefold()
|
|
239
|
+
or any(query in alias.casefold() for alias in e.get('aliases', []))
|
|
240
|
+
or e['id'] in matching_groups]
|
|
241
|
+
def key(record):
|
|
242
|
+
title = self.state.title(record).casefold()
|
|
243
|
+
identity = record['pack_id'], record['episode_id']
|
|
244
|
+
# Distinct versions retain their content identity; bundle first
|
|
245
|
+
# when their numeric IDs and titles coincide.
|
|
246
|
+
tie = not is_bundled(record), record['name'].casefold(), record['id']
|
|
247
|
+
return (title, *identity, *tie) if self.state.order == 'title' else (*identity, title, *tie)
|
|
248
|
+
return sorted(records, key=key)
|
|
249
|
+
|
|
250
|
+
def episode_sections(self):
|
|
251
|
+
return self._episode_sections(self.visible_episodes())
|
|
252
|
+
|
|
253
|
+
def _episode_sections(self, records):
|
|
254
|
+
return group_episodes(records, self.library.catalog,
|
|
255
|
+
mega_label=self.renderer.strings[82], novel_label=self.renderer.strings[302],
|
|
256
|
+
saved_label=self.renderer.strings[238],
|
|
257
|
+
saved=self.saved if self.scope == 'play' else ())
|
|
258
|
+
|
|
259
|
+
def episode_rows(self):
|
|
260
|
+
rows, y = [], 0
|
|
261
|
+
for section in self.episode_sections():
|
|
262
|
+
rows.append((y, EPISODE_HEADER_HEIGHT, section, None))
|
|
263
|
+
y += EPISODE_HEADER_HEIGHT
|
|
264
|
+
if self.query or section.key in self.expanded_groups:
|
|
265
|
+
for record in section.episodes:
|
|
266
|
+
rows.append((y, EPISODE_ROW_HEIGHT, section, record))
|
|
267
|
+
y += EPISODE_ROW_HEIGHT
|
|
268
|
+
return rows, y
|
|
269
|
+
|
|
270
|
+
def show(self, screen, *, remember=True):
|
|
271
|
+
if self.screen == 'episodes':
|
|
272
|
+
self.episode_scroll = self.scroll
|
|
273
|
+
self.transition_from = self.renderer.canvas.copy()
|
|
274
|
+
if remember and screen != self.screen:
|
|
275
|
+
self.history.append(self.screen)
|
|
276
|
+
self.screen = screen
|
|
277
|
+
self.scroll = self.episode_scroll if screen == 'episodes' else 0
|
|
278
|
+
self.focus_index = 0
|
|
279
|
+
self.focus = None
|
|
280
|
+
self.cancel_pointer()
|
|
281
|
+
pygame.key.stop_text_input()
|
|
282
|
+
self.transition_age = 0
|
|
283
|
+
if screen == 'main':
|
|
284
|
+
self.menu_age, self.intro = 0, False
|
|
285
|
+
self.buttons = []
|
|
286
|
+
|
|
287
|
+
def back(self):
|
|
288
|
+
if self.message:
|
|
289
|
+
self.message = ''
|
|
290
|
+
elif self.history:
|
|
291
|
+
screen = self.history.pop()
|
|
292
|
+
self.show(screen, remember=False)
|
|
293
|
+
elif self.library and self.screen != 'main':
|
|
294
|
+
self.show('main', remember=False)
|
|
295
|
+
elif self.screen == 'setup':
|
|
296
|
+
self.show_game_chooser()
|
|
297
|
+
|
|
298
|
+
def _attempt(self, operation):
|
|
299
|
+
try:
|
|
300
|
+
return operation()
|
|
301
|
+
except ERRORS as error:
|
|
302
|
+
self.message = str(error)
|
|
303
|
+
logging.warning('%s', error)
|
|
304
|
+
|
|
305
|
+
def _click_sound(self):
|
|
306
|
+
sound = GAMES[self.selected_game].CLICK_SOUND if self.selected_game else None
|
|
307
|
+
if self.audio and self.library and self.state.sound and sound is not None:
|
|
308
|
+
try:
|
|
309
|
+
if not hasattr(self, 'click_sound'):
|
|
310
|
+
self.click_sound = pygame.mixer.Sound(file=BytesIO(self.library.read_asset(sound)))
|
|
311
|
+
self.click_sound.play()
|
|
312
|
+
except (ContentError, pygame.error) as error:
|
|
313
|
+
logging.debug('Menu sound unavailable: %s', error)
|
|
314
|
+
|
|
315
|
+
def _sync_menu_music(self):
|
|
316
|
+
if not self.audio:
|
|
317
|
+
return
|
|
318
|
+
enabled = bool(self.library and self.state.music and self.screen != 'game' and not self.paused_menu)
|
|
319
|
+
token = (id(self.library), enabled)
|
|
320
|
+
if token != self.menu_music_token:
|
|
321
|
+
self.menu_music_token = token
|
|
322
|
+
self.menu_music_loaded = self.menu_music_paused = False
|
|
323
|
+
self.menu_music.stop()
|
|
324
|
+
if enabled:
|
|
325
|
+
try:
|
|
326
|
+
resource = GAMES[self.selected_game].MENU_MUSIC
|
|
327
|
+
self.menu_music.load(BytesIO(self.library.read_asset(resource)))
|
|
328
|
+
self.menu_music.play()
|
|
329
|
+
self.menu_music_loaded = True
|
|
330
|
+
except (ContentError, pygame.error, ValueError) as error:
|
|
331
|
+
logging.warning('Menu music cannot be played: %s', error)
|
|
332
|
+
paused = not self.active
|
|
333
|
+
if self.menu_music_loaded and paused != self.menu_music_paused:
|
|
334
|
+
self.menu_music_paused = paused
|
|
335
|
+
if paused:
|
|
336
|
+
self.menu_music.pause()
|
|
337
|
+
else:
|
|
338
|
+
self.menu_music.unpause()
|
|
339
|
+
|
|
340
|
+
def start(self, *, resume=True, load_path=None):
|
|
341
|
+
episode = self.selected
|
|
342
|
+
if self.game and resume and not load_path and self.game.session.resources.record['id'] == episode:
|
|
343
|
+
self.game.menu_open = False
|
|
344
|
+
else:
|
|
345
|
+
if load_path:
|
|
346
|
+
session = Session.load(self.library.open_episode(episode), load_path)
|
|
347
|
+
self.state.selected = episode
|
|
348
|
+
self.state.persist()
|
|
349
|
+
else:
|
|
350
|
+
session = self.state.session(episode, resume=resume)
|
|
351
|
+
# A fresh renderer per episode prevents local image IDs from
|
|
352
|
+
# accidentally reusing the previous episode's cached artwork.
|
|
353
|
+
self.game = Desktop(session, audio=self.audio, window=self.window, on_main_menu=self.return_to_menu,
|
|
354
|
+
episode_title=self.state.title(session.resources.record), on_menu_page=self.show)
|
|
355
|
+
self.game.episode_title = self.state.title(self.game.session.resources.record)
|
|
356
|
+
pygame.display.set_caption(self.game_title + ' — ' + self.game.episode_title)
|
|
357
|
+
self.game.music_enabled, self.game.sound_enabled = self.state.music, self.state.sound
|
|
358
|
+
self.game.choice_hints = self.state.choice_hints
|
|
359
|
+
self.game.active = self.active
|
|
360
|
+
self.screen, self.history, self.focus = 'game', [], None
|
|
361
|
+
self.game.screen_token = None
|
|
362
|
+
pygame.key.stop_text_input()
|
|
363
|
+
if self.game.session.episode_exited:
|
|
364
|
+
self.return_to_menu()
|
|
365
|
+
return
|
|
366
|
+
self._sync_menu_music()
|
|
367
|
+
self.game._sync_music()
|
|
368
|
+
self.refresh_saves()
|
|
369
|
+
|
|
370
|
+
def return_to_menu(self):
|
|
371
|
+
# Keep the live session even if a filesystem error prevents a checkpoint.
|
|
372
|
+
if self.game.session.engine.word_grid:
|
|
373
|
+
self.game.session.grid_pointer('cancel')
|
|
374
|
+
checkpoint = self._attempt(lambda: self.state.checkpoint(self.game.session))
|
|
375
|
+
# A live session retains its mixer stream and playhead through menu
|
|
376
|
+
# navigation. A fresh Desktop or explicit save load selects a new cue.
|
|
377
|
+
self.game.active = False
|
|
378
|
+
if not self.game.session.episode_exited:
|
|
379
|
+
self.game._sync_music()
|
|
380
|
+
if self.audio:
|
|
381
|
+
pygame.mixer.stop()
|
|
382
|
+
self.game.menu_open = False
|
|
383
|
+
if self.game.session.episode_exited:
|
|
384
|
+
# Completed sessions cannot resume. The terminal automatic save
|
|
385
|
+
# masks older progress without deleting the player's manual slot.
|
|
386
|
+
if self.audio:
|
|
387
|
+
pygame.mixer.music.stop()
|
|
388
|
+
if checkpoint is not None:
|
|
389
|
+
self.game = None
|
|
390
|
+
self.history = []
|
|
391
|
+
self.show('main', remember=False)
|
|
392
|
+
self.refresh_saves()
|
|
393
|
+
pygame.display.set_caption(self.game_title)
|
|
394
|
+
|
|
395
|
+
def browse(self, kind):
|
|
396
|
+
if self.file_picker is not None:
|
|
397
|
+
pygame.key.stop_text_input()
|
|
398
|
+
self.file_picker(kind)
|
|
399
|
+
return
|
|
400
|
+
self.browser_kind = kind
|
|
401
|
+
self.show('browser')
|
|
402
|
+
self.read_folder(self.folder)
|
|
403
|
+
|
|
404
|
+
def read_folder(self, folder):
|
|
405
|
+
folder = Path(folder).expanduser().resolve()
|
|
406
|
+
extensions = ({'.apk', '.ipa'} if self.browser_kind == 'apk' else {'.apk'}
|
|
407
|
+
if self.browser_kind == 'music_apk' else {'.exp', '.zip', '.rar'} if self.browser_kind == 'episodes' else set())
|
|
408
|
+
catalog_name = GAMES[self.selected_game].CATALOG_FILENAME if self.selected_game else None
|
|
409
|
+
files = [p for p in folder.iterdir() if not p.name.startswith('.')
|
|
410
|
+
and (p.is_dir() or p.suffix.lower() in extensions
|
|
411
|
+
or (self.browser_kind == 'episodes' and p.name.lower() == catalog_name))]
|
|
412
|
+
# Existing hidden content libraries are useful in the library picker.
|
|
413
|
+
if self.browser_kind == 'library':
|
|
414
|
+
files.extend(p for p in folder.iterdir() if p.name.startswith('.')
|
|
415
|
+
and p.is_dir() and (p / 'library.json').is_file())
|
|
416
|
+
self.files = sorted(files, key=lambda p: (not p.is_dir(), p.name.casefold()))
|
|
417
|
+
self.folder, self.path_text, self.scroll = folder, str(folder), 0
|
|
418
|
+
self.focus = None
|
|
419
|
+
pygame.key.stop_text_input()
|
|
420
|
+
|
|
421
|
+
def import_paths(self, paths):
|
|
422
|
+
if self.busy:
|
|
423
|
+
return
|
|
424
|
+
if self.selected_game is None:
|
|
425
|
+
raise ContentError('Choose SHS or Cause of Death before importing that game’s files.')
|
|
426
|
+
paths = [Path(p).expanduser() for p in paths]
|
|
427
|
+
if not self.library:
|
|
428
|
+
ipas = [p for p in paths if p.suffix.lower() == '.ipa']
|
|
429
|
+
apks = [p for p in paths if p.suffix.lower() == '.apk']
|
|
430
|
+
if len(ipas) > 1 or len(apks) > 1 or not (ipas or apks):
|
|
431
|
+
raise ContentError('Choose one APK or IPA. An IPA can also include one APK for missing music.')
|
|
432
|
+
source = ipas[0] if ipas else apks[0]
|
|
433
|
+
music_apk = apks[0] if ipas and apks else None
|
|
434
|
+
episodes = [p for p in paths if p not in ipas + apks]
|
|
435
|
+
self.job_kind = 'library'
|
|
436
|
+
self.job = self.executor.submit(import_game, source, episodes, self.directory,
|
|
437
|
+
music_apk=music_apk, game=self.selected_game)
|
|
438
|
+
elif any(p.suffix.lower() == '.apk' for p in paths):
|
|
439
|
+
if len(paths) != 1:
|
|
440
|
+
raise ContentError('Add one music APK at a time; add episode files separately.')
|
|
441
|
+
self.job_kind = 'music'
|
|
442
|
+
self.job = self.executor.submit(self.library.add_music_apk, paths[0])
|
|
443
|
+
else:
|
|
444
|
+
self.job_kind = 'episodes'
|
|
445
|
+
self.job = self.executor.submit(self.library.add_episodes, paths)
|
|
446
|
+
self.focus = None
|
|
447
|
+
self.cancel_pointer()
|
|
448
|
+
pygame.key.stop_text_input()
|
|
449
|
+
|
|
450
|
+
def flush_drops(self):
|
|
451
|
+
if self.dropped_files and not self.busy and not self.dropping:
|
|
452
|
+
paths, self.dropped_files = self.dropped_files, []
|
|
453
|
+
self._attempt(lambda: self.import_paths(paths))
|
|
454
|
+
|
|
455
|
+
def command(self, command):
|
|
456
|
+
kind = command[0]
|
|
457
|
+
self.cancel_pointer()
|
|
458
|
+
if self.busy:
|
|
459
|
+
return
|
|
460
|
+
if self.update_prompt is not None and kind not in ('update_yes', 'update_no', 'back'):
|
|
461
|
+
return
|
|
462
|
+
if kind == 'dismiss':
|
|
463
|
+
self.message = ''
|
|
464
|
+
return
|
|
465
|
+
self._click_sound()
|
|
466
|
+
def perform():
|
|
467
|
+
if kind == 'back':
|
|
468
|
+
if self.update_prompt is not None:
|
|
469
|
+
self.dismiss_update()
|
|
470
|
+
else:
|
|
471
|
+
self.back()
|
|
472
|
+
elif kind in ('update_yes', 'update_no'):
|
|
473
|
+
release = self.update_prompt
|
|
474
|
+
self.dismiss_update()
|
|
475
|
+
if release is not None and kind == 'update_yes':
|
|
476
|
+
self.open_url(release.url)
|
|
477
|
+
elif kind == 'choose_game':
|
|
478
|
+
self.choose_game(command[1])
|
|
479
|
+
elif kind == 'switch_games':
|
|
480
|
+
self.switch_games()
|
|
481
|
+
elif kind == 'episodes':
|
|
482
|
+
self.scope, self.query = command[1], ''
|
|
483
|
+
self.episode_scroll = self.scroll = 0
|
|
484
|
+
self.refresh_saves()
|
|
485
|
+
self.show('episodes')
|
|
486
|
+
elif kind == 'episode':
|
|
487
|
+
self.selected = command[1]
|
|
488
|
+
self.show('episode')
|
|
489
|
+
elif kind in ('options', 'help', 'library', 'restart', 'title_languages', 'cheats'):
|
|
490
|
+
self.show(kind)
|
|
491
|
+
elif kind == 'title_language':
|
|
492
|
+
language = command[1]
|
|
493
|
+
if language not in TITLE_LANGUAGES:
|
|
494
|
+
raise ValueError('Unsupported episode title language')
|
|
495
|
+
previous = self.state.title_language
|
|
496
|
+
self.state.title_language = language
|
|
497
|
+
try:
|
|
498
|
+
self.state.persist()
|
|
499
|
+
except OSError:
|
|
500
|
+
self.state.title_language = previous
|
|
501
|
+
raise
|
|
502
|
+
self.episode_scroll = self.scroll = 0
|
|
503
|
+
elif kind == 'start':
|
|
504
|
+
self.start(resume=command[1])
|
|
505
|
+
elif kind == 'project':
|
|
506
|
+
self.open_url(PROJECT_URL)
|
|
507
|
+
elif kind == 'toggle_updates':
|
|
508
|
+
self.settings.set_update_check(not self.settings.check_for_updates)
|
|
509
|
+
if not self.settings.check_for_updates:
|
|
510
|
+
self.updates.cancel()
|
|
511
|
+
self.dismiss_update()
|
|
512
|
+
elif self.check_updates:
|
|
513
|
+
self.updates.start()
|
|
514
|
+
elif kind == 'toggle':
|
|
515
|
+
key = command[1]
|
|
516
|
+
if key not in ('music', 'sound', 'choice_hints'):
|
|
517
|
+
raise ValueError('Unknown preference')
|
|
518
|
+
previous = getattr(self.state, key)
|
|
519
|
+
setattr(self.state, key, not previous)
|
|
520
|
+
try:
|
|
521
|
+
self.state.persist()
|
|
522
|
+
except OSError:
|
|
523
|
+
setattr(self.state, key, previous)
|
|
524
|
+
raise
|
|
525
|
+
if self.game:
|
|
526
|
+
self.game.music_enabled, self.game.sound_enabled = self.state.music, self.state.sound
|
|
527
|
+
self.game.choice_hints = self.state.choice_hints
|
|
528
|
+
self.game._sync_music()
|
|
529
|
+
elif kind in ('save', 'load') and self.paused_menu:
|
|
530
|
+
self.game.command((kind,))
|
|
531
|
+
if kind == 'load' and not self.game.menu_open:
|
|
532
|
+
self.screen, self.history = 'game', []
|
|
533
|
+
self.game._sync_music()
|
|
534
|
+
else:
|
|
535
|
+
self.message = self.game.message
|
|
536
|
+
elif kind == 'order':
|
|
537
|
+
self.state.order = 'title' if self.state.order == 'episode' else 'episode'
|
|
538
|
+
self.state.persist()
|
|
539
|
+
self.scroll = 0
|
|
540
|
+
elif kind == 'episode_group':
|
|
541
|
+
key = command[1]
|
|
542
|
+
self.expanded_groups.symmetric_difference_update({key})
|
|
543
|
+
self._scroll(0)
|
|
544
|
+
elif kind == 'episode_groups':
|
|
545
|
+
keys = {section.key for section in self.episode_sections()}
|
|
546
|
+
if keys <= self.expanded_groups:
|
|
547
|
+
self.expanded_groups.difference_update(keys)
|
|
548
|
+
else:
|
|
549
|
+
self.expanded_groups.update(keys)
|
|
550
|
+
self.scroll = 0
|
|
551
|
+
elif kind == 'clear_search':
|
|
552
|
+
self.query, self.scroll, self.focus = '', 0, None
|
|
553
|
+
pygame.key.stop_text_input()
|
|
554
|
+
elif kind == 'browse':
|
|
555
|
+
self.browse(command[1])
|
|
556
|
+
elif kind == 'up':
|
|
557
|
+
self.read_folder(self.folder.parent)
|
|
558
|
+
elif kind == 'file':
|
|
559
|
+
if command[1].is_dir():
|
|
560
|
+
self.read_folder(command[1])
|
|
561
|
+
else:
|
|
562
|
+
self.import_paths([command[1]])
|
|
563
|
+
elif kind == 'folder':
|
|
564
|
+
if self.browser_kind == 'library':
|
|
565
|
+
self.open_library(self.folder, remember=True)
|
|
566
|
+
else:
|
|
567
|
+
self.import_paths([self.folder])
|
|
568
|
+
elif kind in ('search', 'path'):
|
|
569
|
+
self.focus = kind
|
|
570
|
+
if kind == 'path':
|
|
571
|
+
self.path_text = ''
|
|
572
|
+
pygame.key.start_text_input()
|
|
573
|
+
self._attempt(perform)
|
|
574
|
+
|
|
575
|
+
def open_url(self, url):
|
|
576
|
+
if self.url_opener is not None:
|
|
577
|
+
self.url_opener(url)
|
|
578
|
+
else:
|
|
579
|
+
import webbrowser
|
|
580
|
+
if not webbrowser.open(url):
|
|
581
|
+
self.message = f'Open this page in your browser: {url}'
|
|
582
|
+
|
|
583
|
+
def dismiss_update(self):
|
|
584
|
+
self.update_prompt = None
|
|
585
|
+
self.updates.dismiss()
|
|
586
|
+
self.cancel_pointer()
|
|
587
|
+
self.focus, self.focus_index = None, 0
|
|
588
|
+
self.buttons = []
|
|
589
|
+
|
|
590
|
+
def tick(self, elapsed):
|
|
591
|
+
if self.job and self.job.done():
|
|
592
|
+
job, kind = self.job, self.job_kind
|
|
593
|
+
self.job = self.job_kind = None
|
|
594
|
+
try:
|
|
595
|
+
result = job.result()
|
|
596
|
+
if kind == 'library':
|
|
597
|
+
self.open_library(self.directory, remember=True)
|
|
598
|
+
elif kind == 'music':
|
|
599
|
+
# Retry the current cue on Resume if its earlier load failed.
|
|
600
|
+
# Keep the live story and all existing checkpoints intact.
|
|
601
|
+
if self.game and result and not self.game.music_loaded:
|
|
602
|
+
self.game.music_token = None
|
|
603
|
+
self.history = ['main']
|
|
604
|
+
self.show('library', remember=False)
|
|
605
|
+
self.message = (f'Added {result} missing music tracks.' if result
|
|
606
|
+
else 'No missing music tracks to add.')
|
|
607
|
+
else:
|
|
608
|
+
self.refresh_saves()
|
|
609
|
+
self.scope, self.query, self.history = 'all', '', ['main']
|
|
610
|
+
self.episode_scroll = self.scroll = 0
|
|
611
|
+
self.show('episodes', remember=False)
|
|
612
|
+
self.message = f'Added {result} episodes.' if result else 'Library updated. No new episodes were added.'
|
|
613
|
+
except ERRORS as error:
|
|
614
|
+
self.message = str(error)
|
|
615
|
+
self.flush_drops()
|
|
616
|
+
if self.settings.check_for_updates:
|
|
617
|
+
available = self.updates.poll()
|
|
618
|
+
if (available is not None and self.update_prompt is None and self.active
|
|
619
|
+
and not self.busy and not self.message and self.ready
|
|
620
|
+
and self.screen in ('games', 'setup', 'main')):
|
|
621
|
+
self.update_prompt = available
|
|
622
|
+
self.cancel_pointer()
|
|
623
|
+
self.focus, self.focus_index = None, 0
|
|
624
|
+
self.buttons = []
|
|
625
|
+
pygame.key.stop_text_input()
|
|
626
|
+
if not self.active or self.busy or self.message or self.update_prompt is not None:
|
|
627
|
+
return
|
|
628
|
+
if self.screen == 'game':
|
|
629
|
+
self.game.tick(elapsed)
|
|
630
|
+
if self.game.session.episode_exited:
|
|
631
|
+
self.return_to_menu()
|
|
632
|
+
else:
|
|
633
|
+
self.menu_age += elapsed
|
|
634
|
+
self.transition_age = min(200, self.transition_age + elapsed)
|
|
635
|
+
|
|
636
|
+
def render(self):
|
|
637
|
+
self._sync_menu_music()
|
|
638
|
+
if self.screen == 'game':
|
|
639
|
+
if self.game.session.episode_exited:
|
|
640
|
+
self.return_to_menu()
|
|
641
|
+
else:
|
|
642
|
+
self.game.render()
|
|
643
|
+
return
|
|
644
|
+
canvas = self.renderer.draw(self)
|
|
645
|
+
if (self.transition_from is not None and self.transition_age < 200
|
|
646
|
+
and not self.message and not self.busy and self.update_prompt is None):
|
|
647
|
+
layer = canvas.copy()
|
|
648
|
+
layer.set_alpha(round(255 * self.transition_age / 200))
|
|
649
|
+
canvas = self.transition_from.copy()
|
|
650
|
+
canvas.blit(layer, (0, 0))
|
|
651
|
+
self.buttons = self.renderer.buttons if self.ready or self.message or self.update_prompt else []
|
|
652
|
+
if self.buttons and self.focus == 'buttons':
|
|
653
|
+
rect, _ = self.buttons[self.focus_index % len(self.buttons)]
|
|
654
|
+
pygame.draw.rect(canvas, (255, 255, 255), rect.inflate(4, 4), 1, border_radius=4)
|
|
655
|
+
width, height = self.window.get_size()
|
|
656
|
+
factor = min(width / 320, height / 480)
|
|
657
|
+
size = max(1, round(320 * factor)), max(1, round(480 * factor))
|
|
658
|
+
self.viewport = pygame.Rect((width - size[0]) // 2, (height - size[1]) // 2, *size)
|
|
659
|
+
self.window.fill((8, 12, 20))
|
|
660
|
+
self.window.blit(pygame.transform.smoothscale(canvas, size), self.viewport)
|
|
661
|
+
pygame.display.flip()
|
|
662
|
+
|
|
663
|
+
def _scroll(self, delta):
|
|
664
|
+
self.press.cancel()
|
|
665
|
+
total, height = (len(self.files) * 35, 210) if self.screen == 'browser' else (self.episode_rows()[1], 252)
|
|
666
|
+
self.scroll = max(0, min(max(0, total - height), self.scroll + delta))
|
|
667
|
+
|
|
668
|
+
def handle_event(self, event):
|
|
669
|
+
if event.type == pygame.QUIT:
|
|
670
|
+
return False
|
|
671
|
+
if event.type in (pygame.WINDOWFOCUSLOST, pygame.WINDOWFOCUSGAINED):
|
|
672
|
+
self.active = event.type == pygame.WINDOWFOCUSGAINED
|
|
673
|
+
self.cancel_pointer()
|
|
674
|
+
self._sync_menu_music()
|
|
675
|
+
if self.screen == 'game':
|
|
676
|
+
return self.game.handle_event(event)
|
|
677
|
+
if self.update_prompt is not None:
|
|
678
|
+
if event.type == pygame.KEYDOWN:
|
|
679
|
+
if event.key == pygame.K_ESCAPE:
|
|
680
|
+
self.command(('update_no',))
|
|
681
|
+
elif event.key in (pygame.K_TAB, pygame.K_UP, pygame.K_DOWN):
|
|
682
|
+
self.focus, self.focus_index = 'buttons', 1 - self.focus_index % 2
|
|
683
|
+
elif event.key in (pygame.K_RETURN, pygame.K_SPACE) and self.buttons:
|
|
684
|
+
self.command(self.buttons[self.focus_index % len(self.buttons)][1])
|
|
685
|
+
elif event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION):
|
|
686
|
+
self._pointer_event(event)
|
|
687
|
+
return True
|
|
688
|
+
if event.type == pygame.DROPBEGIN:
|
|
689
|
+
self.dropping = True
|
|
690
|
+
if event.type == pygame.DROPFILE:
|
|
691
|
+
self.dropped_files.append(Path(event.file))
|
|
692
|
+
return True
|
|
693
|
+
if event.type == pygame.DROPCOMPLETE:
|
|
694
|
+
self.dropping = False
|
|
695
|
+
self.flush_drops()
|
|
696
|
+
if self.busy:
|
|
697
|
+
return True
|
|
698
|
+
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
|
699
|
+
self.back()
|
|
700
|
+
return True
|
|
701
|
+
if self.message:
|
|
702
|
+
if event.type == pygame.KEYDOWN and event.key in (pygame.K_RETURN, pygame.K_SPACE):
|
|
703
|
+
self.command(('dismiss',))
|
|
704
|
+
elif event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION):
|
|
705
|
+
self._pointer_event(event)
|
|
706
|
+
return True
|
|
707
|
+
if not self.ready:
|
|
708
|
+
return True
|
|
709
|
+
if event.type == pygame.TEXTINPUT and self.focus in ('search', 'path'):
|
|
710
|
+
value = ''.join(c for c in event.text if c.isprintable())
|
|
711
|
+
if self.focus == 'search':
|
|
712
|
+
self.query = (self.query + value)[:100]
|
|
713
|
+
self.scroll = 0
|
|
714
|
+
else:
|
|
715
|
+
self.path_text = (self.path_text + value)[:4096]
|
|
716
|
+
elif event.type == pygame.KEYDOWN:
|
|
717
|
+
if self.focus in ('search', 'path'):
|
|
718
|
+
if event.key == pygame.K_BACKSPACE:
|
|
719
|
+
if self.focus == 'search':
|
|
720
|
+
self.query = self.query[:-1]
|
|
721
|
+
self.scroll = 0
|
|
722
|
+
else:
|
|
723
|
+
self.path_text = self.path_text[:-1]
|
|
724
|
+
elif event.key == pygame.K_RETURN:
|
|
725
|
+
if self.focus == 'path':
|
|
726
|
+
self._attempt(lambda: self.read_folder(self.path_text))
|
|
727
|
+
else:
|
|
728
|
+
self.focus = None
|
|
729
|
+
pygame.key.stop_text_input()
|
|
730
|
+
elif event.key in (pygame.K_TAB, pygame.K_UP, pygame.K_DOWN):
|
|
731
|
+
if self.focus == 'buttons':
|
|
732
|
+
self.focus_index += -1 if event.key == pygame.K_UP else 1
|
|
733
|
+
self.focus = 'buttons'
|
|
734
|
+
elif event.key in (pygame.K_RETURN, pygame.K_SPACE) and self.buttons:
|
|
735
|
+
self.command(self.buttons[self.focus_index % len(self.buttons)][1])
|
|
736
|
+
elif event.key in (pygame.K_PAGEUP, pygame.K_PAGEDOWN) and self.screen in ('browser', 'episodes'):
|
|
737
|
+
self._scroll(-210 if event.key == pygame.K_PAGEUP else 210)
|
|
738
|
+
elif event.type == pygame.MOUSEWHEEL and self.screen in ('browser', 'episodes'):
|
|
739
|
+
self._scroll(-event.y * 35)
|
|
740
|
+
elif event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION):
|
|
741
|
+
self._pointer_event(event)
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
def _pointer_event(self, event):
|
|
745
|
+
if event.type != pygame.MOUSEMOTION and event.button != 1:
|
|
746
|
+
return
|
|
747
|
+
point = ((event.pos[0] - self.viewport.x) * 320 / self.viewport.width,
|
|
748
|
+
(event.pos[1] - self.viewport.y) * 480 / self.viewport.height)
|
|
749
|
+
hit = (next((command for rect, command in self.buttons if rect.collidepoint(point)), None)
|
|
750
|
+
if self.viewport.collidepoint(event.pos) else None)
|
|
751
|
+
phase = {pygame.MOUSEBUTTONDOWN: 'down', pygame.MOUSEBUTTONUP: 'up',
|
|
752
|
+
pygame.MOUSEMOTION: 'move'}[event.type]
|
|
753
|
+
command = self.press.update(phase, hit, self.input_token())
|
|
754
|
+
if command is not None:
|
|
755
|
+
self.command(command)
|
|
756
|
+
|
|
757
|
+
def close(self):
|
|
758
|
+
self.updates.cancel()
|
|
759
|
+
# Worker must finish its atomic publication before its ZIP is closed.
|
|
760
|
+
self.executor.shutdown(wait=True)
|
|
761
|
+
if self.game and self.screen == 'game':
|
|
762
|
+
try:
|
|
763
|
+
self.state.checkpoint(self.game.session)
|
|
764
|
+
except ERRORS as error:
|
|
765
|
+
logging.error('Automatic checkpoint failed: %s', error)
|
|
766
|
+
if self.library:
|
|
767
|
+
self.library.close()
|
|
768
|
+
pygame.quit()
|
|
769
|
+
|
|
770
|
+
def run(self):
|
|
771
|
+
clock = pygame.time.Clock()
|
|
772
|
+
running = True
|
|
773
|
+
try:
|
|
774
|
+
while running:
|
|
775
|
+
elapsed = clock.tick(60)
|
|
776
|
+
self.render()
|
|
777
|
+
token = self.input_token()
|
|
778
|
+
for event in pygame.event.get():
|
|
779
|
+
running = self.handle_event(event)
|
|
780
|
+
# Do not deliver a queued click to a newly opened screen.
|
|
781
|
+
if not running or self.input_token() != token:
|
|
782
|
+
break
|
|
783
|
+
if self.screen == 'game' and self.game.screen_token != self.game._screen_token():
|
|
784
|
+
break
|
|
785
|
+
if running:
|
|
786
|
+
self.tick(elapsed)
|
|
787
|
+
finally:
|
|
788
|
+
self.close()
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def main(argv=None):
|
|
792
|
+
parser = argparse.ArgumentParser(description='EXP Runtime — play SHS or Cause of Death with your own assets')
|
|
793
|
+
parser.add_argument('--library', type=Path, help='Local content library (default: per-user application data)')
|
|
794
|
+
parser.add_argument('--game', choices=GAMES, help='Open this game directly instead of the chooser')
|
|
795
|
+
parser.add_argument('--no-audio', action='store_true')
|
|
796
|
+
parser.add_argument('--smoke-test', action='store_true', help=argparse.SUPPRESS)
|
|
797
|
+
args = parser.parse_args(argv)
|
|
798
|
+
if args.smoke_test:
|
|
799
|
+
from .app_info import runtime_version
|
|
800
|
+
from .rar import library
|
|
801
|
+
library() # Frozen downloads must include a usable RAR4/RAR5 decoder.
|
|
802
|
+
if runtime_version() == 'development':
|
|
803
|
+
raise RuntimeError('Packaged runtime version is missing')
|
|
804
|
+
# A packaged-build check must never open or alter the player's library.
|
|
805
|
+
with tempfile.TemporaryDirectory(prefix='exp-setup-test-') as temporary:
|
|
806
|
+
app = Application(audio=False, check_updates=False)
|
|
807
|
+
try:
|
|
808
|
+
if app.screen != 'games' or app.library is not None:
|
|
809
|
+
raise RuntimeError('Expected game chooser without game content')
|
|
810
|
+
app.render()
|
|
811
|
+
# Exercise both setup screens without opening any real library.
|
|
812
|
+
app.directory = Path(temporary) / 'library'
|
|
813
|
+
for key in GAMES:
|
|
814
|
+
app.selected_game, app.screen = key, 'setup'
|
|
815
|
+
app.render()
|
|
816
|
+
app.screen = 'help'
|
|
817
|
+
app.render()
|
|
818
|
+
finally:
|
|
819
|
+
app.close()
|
|
820
|
+
return
|
|
821
|
+
Application(args.library, audio=not args.no_audio, game_key=args.game).run()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
if __name__ == '__main__':
|
|
825
|
+
main()
|