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.
@@ -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()