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/desktop.py ADDED
@@ -0,0 +1,726 @@
1
+ """Shared gameplay frontend; all story decisions remain in the KiWi runtime."""
2
+ from collections import OrderedDict
3
+ from io import BytesIO
4
+ import logging
5
+ import os
6
+
7
+ os.environ.setdefault('PYGAME_HIDE_SUPPORT_PROMPT', '1')
8
+ from . import graphics as pygame
9
+
10
+ from .app_icon import icon_surface
11
+ from .content import ContentError
12
+ from .audio import music_cue
13
+ from .games import GAMES, game_id
14
+ from .desktop_text import BitmapTextRenderer
15
+ from .desktop_dialogue import DialogueRenderer
16
+ from .desktop_choice import ChoiceRenderer
17
+ from .desktop_pause import PAUSE_OPEN_MS, PauseRenderer, draw_pause_gear
18
+ from .desktop_picker import CharacterPickerRenderer
19
+ from .desktop_loading import LoadingRenderer
20
+ from .desktop_message import MessageRenderer
21
+ from .desktop_title import TitleRenderer
22
+ from .desktop_input import InputRenderer, INPUT_RECT
23
+ from .fonts import TextStyle
24
+ from .runtime import SaveError, Session
25
+ from .scene_badge import outline_badge_text
26
+ from .pointer import ButtonPress
27
+ from .text_input import accept_name_character, validate_name
28
+ from .ui_assets import ImagePack, Rect
29
+ from .vm import VMError
30
+
31
+
32
+ SIZE = (480, 720)
33
+ INK = (245, 241, 233)
34
+ MUTED = (183, 187, 198)
35
+ ACCENT = (247, 190, 94)
36
+ PANEL = (23, 29, 45)
37
+
38
+
39
+ class Desktop:
40
+ def __init__(self, session: Session, *, audio: bool = True, window=None, on_main_menu=None,
41
+ episode_title=None, on_menu_page=None):
42
+ pygame.display.init()
43
+ pygame.font.init()
44
+ if window is None:
45
+ pygame.display.set_icon(icon_surface())
46
+ self.window = window if window is not None else pygame.display.set_mode(SIZE, pygame.RESIZABLE)
47
+ self.on_main_menu = on_main_menu
48
+ self.on_menu_page = on_menu_page
49
+ self.pause_renderer = None
50
+ self.pause_elapsed_ms = PAUSE_OPEN_MS
51
+ self.gear_pointer_down = False
52
+ self.ui_sounds = {}
53
+ self.episode_title = episode_title if episode_title is not None else session.resources.record['titles'][0]
54
+ pygame.display.set_caption(GAMES[game_id(session.resources)].TITLE + ' — ' + self.episode_title)
55
+ # Cocoa's default opaque surface still carries an alpha bitmask.
56
+ # Explicit RGB avoids its incorrect blending of translucent layers.
57
+ self.canvas = pygame.Surface(SIZE).convert(32)
58
+ self.font = pygame.font.Font(None, 27)
59
+ self.heading = pygame.font.Font(None, 34)
60
+ self.small = pygame.font.Font(None, 22)
61
+ self.session = session
62
+ self.story_text = BitmapTextRenderer(session.resources.library)
63
+ self.dialogue_renderer = DialogueRenderer(session.resources, self.story_text, self._image)
64
+ self.choice_renderer = ChoiceRenderer(session.resources, self.story_text, self.dialogue_renderer)
65
+ self.grid_renderer = self.football_renderer = None
66
+ if game_id(session.resources) == 'shs':
67
+ from .shs.desktop_grid import GridRenderer
68
+ from .shs.desktop_football import FootballRenderer
69
+ self.grid_renderer = GridRenderer(session.resources, self.story_text, self.dialogue_renderer)
70
+ self.football_renderer = FootballRenderer(session.resources, self.story_text, self.dialogue_renderer)
71
+ self.picker_renderer = CharacterPickerRenderer(session.resources, self.story_text, self.dialogue_renderer)
72
+ self.loading_renderer = LoadingRenderer(session.resources, self.story_text, self.dialogue_renderer)
73
+ self.message_renderer = MessageRenderer(session.resources, self.story_text, self.dialogue_renderer)
74
+ self.title_renderer = TitleRenderer(session.resources, self.story_text, self.dialogue_renderer)
75
+ self.input_renderer = InputRenderer(session.resources, self.story_text, self.dialogue_renderer)
76
+ self.input_action = None
77
+ self.input_error = None
78
+ self.input_keyboard = False
79
+ self.text_input_started = None # Force the first sync to set SDL's state.
80
+ self.picker_pointer_down = False
81
+ self.press = ButtonPress()
82
+ self.images = OrderedDict()
83
+ self.buttons = []
84
+ self.scroll = self.max_scroll = 0
85
+ self.screen_token = None
86
+ self.message = ''
87
+ self.message_until = 0
88
+ self.error = None
89
+ self.music_token = None
90
+ self.music_loaded = False
91
+ self.music_paused = False
92
+ self.sound_serial = session.engine.sound_serial
93
+ self.audio = False
94
+ self.music_enabled = self.sound_enabled = True
95
+ self.choice_hints = False
96
+ self.active = True
97
+ self.menu_open = False
98
+ if audio:
99
+ try:
100
+ pygame.mixer.init()
101
+ self.audio = True
102
+ except pygame.error as error:
103
+ logging.warning('Audio unavailable: %s', error)
104
+ self._attempt(session.advance)
105
+
106
+ def _attempt(self, operation):
107
+ try:
108
+ operation()
109
+ except (ContentError, VMError, SaveError, OSError, ValueError) as error:
110
+ self.error = str(error)
111
+ logging.error('Playback stopped: %s', error)
112
+
113
+ def _notice(self, message):
114
+ self.message, self.message_until = message, pygame.time.get_ticks() + 3500
115
+
116
+ def _image(self, asset_id):
117
+ if asset_id < 0:
118
+ return None
119
+ if asset_id not in self.images:
120
+ try:
121
+ data = self.session.resources.read_asset(asset_id)
122
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
123
+ self.images[asset_id] = pygame.image.load(BytesIO(data)).convert_alpha()
124
+ else:
125
+ pack = ImagePack.parse(data)
126
+ if len(pack.images) != 1 or pack.images[0].mode != 'RGBA':
127
+ raise ContentError(f'Resource {asset_id} is not a single RGBA image')
128
+ frame = pack.images[0]
129
+ self.images[asset_id] = pygame.image.frombytes(frame.pixels, (frame.width, frame.height), 'RGBA').convert_alpha()
130
+ except (ContentError, pygame.error, OSError) as error:
131
+ self.images[asset_id] = None
132
+ logging.warning('Image %s cannot be displayed: %s', asset_id, error)
133
+ self._notice(f'Image {asset_id} could not be displayed')
134
+ if len(self.images) > 64:
135
+ self.images.popitem(last=False)
136
+ self.images.move_to_end(asset_id)
137
+ return self.images[asset_id]
138
+
139
+ def _lines(self, text, font, width):
140
+ # Backticks delimit native emphasis. Font styling is currently simplified.
141
+ lines = []
142
+ for paragraph in text.replace('`', '').split('\n'):
143
+ line = ''
144
+ for word in paragraph.split():
145
+ proposed = f'{line} {word}' if line else word
146
+ if line and font.size(proposed)[0] > width:
147
+ lines.append(line)
148
+ line = ''
149
+ for char in word:
150
+ proposed = line + char
151
+ if line and font.size(proposed)[0] > width:
152
+ lines.append(line)
153
+ line = ''
154
+ line += char
155
+ line += ' '
156
+ lines.append(line.rstrip())
157
+ return lines
158
+
159
+ def _text(self, text, x, y, width, *, font=None, color=INK):
160
+ font = font or self.font
161
+ for line in self._lines(text, font, width):
162
+ self.canvas.blit(font.render(line, True, color), (x, y))
163
+ y += font.get_linesize() + 3
164
+ return y
165
+
166
+ def _button(self, label, rect, command, *, enabled=True, small=False):
167
+ pygame.draw.rect(self.canvas, (49, 62, 84) if enabled else (33, 37, 46), rect, border_radius=8)
168
+ pygame.draw.rect(self.canvas, (102, 115, 135), rect, 1, border_radius=8)
169
+ self._text(label, rect.x + 12, rect.y + 10, rect.width - 24,
170
+ font=self.small if small else self.font, color=INK if enabled else MUTED)
171
+ if enabled:
172
+ self.buttons.append((rect.clip(self.canvas.get_clip()), command))
173
+
174
+ def _portrait(self, character, expression, bottom):
175
+ variants = self.session.engine.character_art_variants.get(character)
176
+ if not variants:
177
+ return
178
+ # FUN_00095c60 takes expression modulo five; panel setup clamps
179
+ # negative expression overrides to zero.
180
+ portrait = self._image(variants[max(0, expression) % len(variants)])
181
+ if portrait is not None:
182
+ width, height = portrait.get_size()
183
+ scale = min(160 / width, 180 / height)
184
+ portrait = pygame.transform.smoothscale(portrait, (int(width * scale), int(height * scale)))
185
+ self.canvas.blit(portrait, (SIZE[0] - portrait.get_width() - 22, bottom - portrait.get_height()))
186
+
187
+ def _sync_music(self):
188
+ """Apply the requested cue and hold its playhead while play is inactive."""
189
+ if not self.audio:
190
+ return
191
+ engine = self.session.engine
192
+ token = (engine.music_id, engine.music_flag, self.music_enabled)
193
+ if token != self.music_token:
194
+ self.music_token = token
195
+ self.music_loaded = self.music_paused = False
196
+ try:
197
+ pygame.mixer.music.stop()
198
+ if engine.music_id >= 0 and self.music_enabled:
199
+ cue = music_cue(engine.music_id, game=game_id(self.session.resources))
200
+ pygame.mixer.music.load(BytesIO(self.session.resources.read_asset(cue.asset_id)))
201
+ # Both native iOS games pass service 80's flag to
202
+ # SHSSound.setLoop: (-1 repeats, 0 plays once).
203
+ pygame.mixer.music.play(loops=-1 if engine.music_flag else 0,
204
+ start=cue.start_ms / 1000)
205
+ self.music_loaded = True
206
+ except (ContentError, pygame.error) as error:
207
+ logging.warning('Music %s cannot be played: %s', engine.music_id, error)
208
+ paused = self.menu_open or not self.active
209
+ if self.music_loaded and paused != self.music_paused:
210
+ self.music_paused = paused
211
+ try:
212
+ if paused:
213
+ pygame.mixer.music.pause()
214
+ else:
215
+ pygame.mixer.music.unpause()
216
+ except pygame.error as error:
217
+ logging.warning('Music %s cannot change pause state: %s', engine.music_id, error)
218
+
219
+ def _sync_audio(self):
220
+ if not self.audio:
221
+ return
222
+ self._sync_music()
223
+ engine = self.session.engine
224
+ if engine.sound_serial != self.sound_serial:
225
+ self.sound_serial = engine.sound_serial
226
+ if not self.sound_enabled:
227
+ return
228
+ try:
229
+ for asset in engine.sound_ids or [engine.sound_id]:
230
+ pygame.mixer.Sound(file=BytesIO(self.session.resources.read_asset(asset))).play()
231
+ except (ContentError, pygame.error) as error:
232
+ logging.warning('Sound %s cannot be played: %s', engine.sound_id, error)
233
+
234
+ def _sync_text_input(self):
235
+ action = self.session.pending
236
+ if action is not None and action.name != 'text_input':
237
+ action = None
238
+ if action is not self.input_action:
239
+ self.input_action, self.input_error = action, None
240
+ self.input_keyboard = action is not None
241
+ if action is not None:
242
+ action.details.setdefault('draft', action.details['default'])
243
+ enabled = bool(action and self.input_keyboard and self.active and not self.menu_open
244
+ and not self.input_error and not self.error)
245
+ if enabled != self.text_input_started:
246
+ (pygame.key.start_text_input if enabled else pygame.key.stop_text_input)()
247
+ self.text_input_started = enabled
248
+ if enabled and hasattr(self, 'viewport'):
249
+ x, y, w, h = INPUT_RECT
250
+ pygame.key.set_text_input_rect(pygame.Rect(
251
+ self.viewport.x + x * self.viewport.width / 320,
252
+ self.viewport.y + y * self.viewport.height / 480,
253
+ w * self.viewport.width / 320, h * self.viewport.height / 480))
254
+
255
+ def _submit_input(self, action):
256
+ if self.input_error:
257
+ self.input_error, self.input_keyboard = None, True
258
+ return
259
+ value = action.details.get('draft', action.details['default'])
260
+ if not value:
261
+ self.input_keyboard = False # Empty Return hides the native keyboard.
262
+ return
263
+ try:
264
+ validate_name(value, **self.session.name_input_metrics())
265
+ except ValueError as error:
266
+ self.input_error, self.input_keyboard = str(error), False
267
+ return
268
+ self._attempt(lambda: self.session.answer(value))
269
+
270
+ def _menu_sound(self, *, opening=False):
271
+ library = self.session.resources.library
272
+ if not self.audio or not self.sound_enabled or not hasattr(library, 'read_asset'):
273
+ return
274
+ profile = GAMES[game_id(self.session.resources)]
275
+ asset = profile.PAUSE_SOUND if opening else profile.CLICK_SOUND
276
+ try:
277
+ if asset not in self.ui_sounds:
278
+ self.ui_sounds[asset] = pygame.mixer.Sound(file=BytesIO(library.read_asset(asset)))
279
+ self.ui_sounds[asset].play()
280
+ except (ContentError, pygame.error) as error:
281
+ logging.debug('Pause sound %s unavailable: %s', asset, error)
282
+
283
+ def render(self):
284
+ self.buttons = []
285
+ action = self.session.pending
286
+ self._sync_text_input()
287
+ token = self._screen_token()
288
+ self.press.validate(self._press_token())
289
+ self.dialogue_renderer.gear_pressed = self.press.pressed == ('menu',) and not self.menu_open
290
+ if token != self.screen_token:
291
+ self.screen_token, self.scroll = token, 0
292
+ self.canvas.fill((14, 20, 32))
293
+ details = action.details if action else {}
294
+ if action and action.name in ('word_grid', 'football', 'character_picker', 'loading', 'message_panel', 'survey_confirmation', 'presentation', 'text_input') and not self.error:
295
+ try:
296
+ renderer = {'word_grid': self.grid_renderer, 'football': self.football_renderer,
297
+ 'character_picker': self.picker_renderer, 'loading': self.loading_renderer,
298
+ 'message_panel': self.message_renderer, 'presentation': self.title_renderer,
299
+ 'survey_confirmation': self.message_renderer,
300
+ 'text_input': self.input_renderer}[action.name]
301
+ options = (dict(error=self.input_error,
302
+ cursor_visible=self.text_input_started and pygame.time.get_ticks() % 1000 < 500)
303
+ if action.name == 'text_input' else {})
304
+ buttons = renderer.draw(self.canvas, self.session, **options)
305
+ self.buttons = [(pygame.Rect(*(round(value * 1.5) for value in rect)), command)
306
+ for rect, command in buttons]
307
+ self.scroll = self.max_scroll = 0
308
+ self._present()
309
+ return
310
+ except (ContentError, pygame.error, OSError, ValueError) as error:
311
+ self.error = str(error)
312
+ logging.error('Panel rendering stopped: %s', error)
313
+ if (action and action.name in ('choice', 'word_game') and not self.error
314
+ and hasattr(self.session.resources, 'read_asset')):
315
+ try:
316
+ page = self.choice_renderer.page(self.session)
317
+ self.max_scroll = page.max_scroll * 1.5
318
+ self.scroll = min(self.scroll, self.max_scroll)
319
+ pointer = None
320
+ if hasattr(self, 'viewport') and self.press.origin is None and not self.menu_open:
321
+ x, y = pygame.mouse.get_pos()
322
+ if self.viewport.collidepoint(x, y):
323
+ pointer = ((x - self.viewport.x) * 320 / self.viewport.width,
324
+ (y - self.viewport.y) * 480 / self.viewport.height)
325
+ _, buttons = self.choice_renderer.draw(self.canvas, self.session,
326
+ scroll=self.scroll / 1.5, pointer=pointer,
327
+ pressed=self.press.pressed if not self.menu_open else None,
328
+ choice_hints=self.choice_hints)
329
+ self.buttons = [(pygame.Rect(*(round(value * 1.5) for value in rect)), command)
330
+ for rect, command in buttons]
331
+ if pygame.time.get_ticks() < self.message_until:
332
+ self._text(self.message, 90, 672, 375, font=self.small)
333
+ self._present()
334
+ return
335
+ except (ContentError, pygame.error, OSError, ValueError) as error:
336
+ self.error = str(error)
337
+ logging.error('Choice rendering stopped: %s', error)
338
+ if action and action.name == 'dialogue' and not self.error:
339
+ try:
340
+ self.dialogue_renderer.draw(self.canvas, self.session)
341
+ self.scroll = self.max_scroll = 0
342
+ draw_pause_gear(self.canvas, self.dialogue_renderer, scale=1.5)
343
+ self.buttons = [(pygame.Rect(0, 633, 90, 87), ('menu',)),
344
+ (pygame.Rect(0, 0, *SIZE), ('continue',))]
345
+ if pygame.time.get_ticks() < self.message_until:
346
+ self._text(self.message, 20, 701, 450, font=self.small, color=MUTED)
347
+ self._present()
348
+ return
349
+ except (ContentError, pygame.error, OSError, ValueError) as error:
350
+ self.error = str(error)
351
+ logging.error('Dialogue rendering stopped: %s', error)
352
+ background_id = details.get('asset_id', -1) if action and action.name == 'presentation' else self.session.engine.panel.background_id
353
+ background = self._image(background_id)
354
+ if background is not None:
355
+ self.canvas.blit(pygame.transform.smoothscale(background, SIZE), (0, 0))
356
+ bar = pygame.Surface((480, 38), pygame.SRCALPHA)
357
+ bar.fill((15, 20, 33, 230))
358
+ self.canvas.blit(bar, (0, 0))
359
+ title = self.episode_title
360
+ self.canvas.set_clip(pygame.Rect(12, 0, 456, 38))
361
+ self._text(title, 14, 10, 900, font=self.small)
362
+ self.canvas.set_clip(None)
363
+ panel_top = 420 if action and action.name == 'dialogue' else 320
364
+ if action and action.name in ('dialogue', 'choice') and not self.error:
365
+ character = details.get('character_id', -1)
366
+ expression = details.get('expression', self.session.engine.character_expressions.get(character, 0))
367
+ self._portrait(character, expression, panel_top + 2)
368
+ overlay = pygame.Surface((480, 720 - panel_top), pygame.SRCALPHA)
369
+ overlay.fill((*PANEL, 245))
370
+ self.canvas.blit(overlay, (0, panel_top))
371
+ pygame.draw.line(self.canvas, ACCENT, (0, panel_top), (480, panel_top), 2)
372
+ clip = pygame.Rect(0, panel_top + 2, 480, 638 - panel_top)
373
+ self.canvas.set_clip(clip)
374
+ y = panel_top + 18 - self.scroll
375
+ if self.error:
376
+ y = self._text('Playback paused', 24, y, 432, font=self.heading, color=ACCENT)
377
+ y = self._text(self.error, 24, y + 16, 432)
378
+ elif action is None:
379
+ y = self._text('Loading…', 24, y, 432)
380
+ elif action.name == 'presentation':
381
+ y = self._text(details['title'], 24, y, 432, font=self.heading, color=ACCENT)
382
+ y = self._text(details['subtitle'], 24, y + 18, 432)
383
+ elif action.name == 'choice':
384
+ y = self._text(details['title'], 24, y, 432, font=self.heading, color=ACCENT)
385
+ y = self._text(details['text'], 24, y + 8, 432) + 14
386
+ if self.session.remaining_ms is not None:
387
+ y = self._text(f'{self.session.remaining_ms / 1000:.1f}s remaining', 24, y, 432,
388
+ font=self.small, color=MUTED) + 6
389
+ for index, label in enumerate(details['options']):
390
+ label = f'{index + 1}. {label}'
391
+ height = max(48, len(self._lines(label, self.font, 404)) * (self.font.get_linesize() + 3) + 20)
392
+ rect = pygame.Rect(24, y, 432, height)
393
+ self._button(label, rect, ('choose', index), enabled=details['enabled'][index])
394
+ y += height + 10
395
+ elif action.name in ('finished', 'episode_exit'):
396
+ y = self._text('Episode complete', 24, y, 432, font=self.heading, color=ACCENT)
397
+ elif action.name == 'unhandled_yield':
398
+ y = self._text('This part is not supported yet', 24, y, 432, font=self.heading, color=ACCENT)
399
+ y = self._text('Your progress can be saved here. This episode needs an engine feature that is still being implemented.',
400
+ 24, y + 18, 432)
401
+ y = self._text(f'Scene {self.session.scene} · service {action.request.yield_id} · PC {action.request.pc}',
402
+ 24, y + 16, 432, font=self.small, color=MUTED)
403
+ if details.get('reason'):
404
+ y = self._text(details['reason'], 24, y + 10, 432, font=self.small, color=MUTED)
405
+ self.max_scroll = max(0, y + self.scroll - 630)
406
+ self.scroll = min(self.scroll, self.max_scroll)
407
+ self.canvas.set_clip(None)
408
+ pygame.draw.rect(self.canvas, PANEL, (0, 640, 480, 80))
409
+ if action and action.name in ('dialogue', 'presentation') and not self.error:
410
+ self._button('Continue ›', pygame.Rect(310, 650, 148, 44), ('continue',), small=True)
411
+ self._button('Save', pygame.Rect(22, 650, 83, 44), ('save',), small=True)
412
+ self._button('Load', pygame.Rect(115, 650, 83, 44), ('load',), small=True)
413
+ message = self.message if pygame.time.get_ticks() < self.message_until else 'F5 save · F9 load · Esc menu'
414
+ self._text(message, 22, 700, 450, font=self.small, color=MUTED)
415
+ self._present()
416
+
417
+ def _screen_token(self):
418
+ details = self.session.pending.details if self.session.pending else {}
419
+ game = self.session.engine.word_game
420
+ grid = self.session.engine.word_grid
421
+ football = self.session.engine.football
422
+ dialogue = self.session.engine.dialogue_animation
423
+ picker = self.session.engine.character_picker
424
+ title = self.session.engine.title_screen
425
+ return (id(self.session), self.session.scene, self.session.vm.steps_executed,
426
+ details.get('page_start', 0), game.round if game else None,
427
+ grid.round if grid else None, (football.round, football.phase) if football else None,
428
+ (dialogue.finish_requested, dialogue.complete) if dialogue else None,
429
+ tuple(picker.order) if picker else None,
430
+ (title.reveal_width == 320, title.ready) if title else None,
431
+ details.get('draft'), self.input_error, self.menu_open)
432
+
433
+ def _present(self):
434
+ engine = self.session.engine
435
+ if engine.scene_badge and not self.error:
436
+ try:
437
+ self._draw_scene_badge(engine.scene_badge)
438
+ except (ContentError, pygame.error, OSError, ValueError) as error:
439
+ self.error = str(error)
440
+ logging.error('Scene label rendering stopped: %s', error)
441
+ if self.menu_open:
442
+ self._draw_menu()
443
+ self._sync_audio()
444
+ width, height = self.window.get_size()
445
+ scale = min(width / SIZE[0], height / SIZE[1])
446
+ scaled = pygame.transform.smoothscale(self.canvas, (max(1, int(480 * scale)), max(1, int(720 * scale))))
447
+ self.viewport = scaled.get_rect(center=(width // 2, height // 2))
448
+ self.window.fill((8, 12, 20))
449
+ self.window.blit(scaled, self.viewport)
450
+ pygame.display.flip()
451
+
452
+ def _draw_scene_badge(self, badge):
453
+ layer = self.dialogue_renderer.frame(204 if badge.blue else 236, 13).copy()
454
+ icon = self._image(badge.asset_id)
455
+ if icon is not None:
456
+ layer.blit(icon, icon.get_rect(center=(38, 30))) # Layout 67 node 2.
457
+ color = (69, 107, 176) if badge.text == 'Free Time' else (223, 163, 52)
458
+ name = 'ArialRoundedMTBold16'
459
+ if getattr(self.story_text.font(name), 'cap_height', None) is not None:
460
+ name = 'ArialMT14'
461
+ region = self.session.resources.dialogue_layout().bank.rectangle(67, 3)
462
+ text, (x, y), scale = outline_badge_text(badge.text, self.story_text.font(name), region, color)
463
+ else:
464
+ text = self.story_text.layout(name, badge.text, 110, TextStyle(14, 1, color))
465
+ x, y, scale = badge.text_origin
466
+ self.story_text.draw_layout(layer, name, text, x, y, scale=scale)
467
+ # 200ms MoveTo from x=-width/2 to center x=100. Ad-free top inset=10.
468
+ left = -171 + (100 + 171 / 2) * badge.elapsed_ms / 200
469
+ self.canvas.blit(pygame.transform.smoothscale(layer, (257, 90)), (round(left * 1.5), 15))
470
+
471
+ def _draw_menu(self):
472
+ if self.pause_renderer is None:
473
+ self.pause_renderer = PauseRenderer(self.session.resources.library,
474
+ self.story_text, self.dialogue_renderer)
475
+ buttons = self.pause_renderer.draw(self.canvas, application=self.on_menu_page is not None,
476
+ main_menu=self.on_main_menu is not None,
477
+ pressed=self.press.pressed,
478
+ elapsed_ms=self.pause_elapsed_ms)
479
+ self.buttons = [(pygame.Rect(*(round(value * 1.5) for value in rect)), command)
480
+ for rect, command in buttons]
481
+ if pygame.time.get_ticks() < self.message_until:
482
+ self._text(self.message, 30, 640, 420, font=self.small)
483
+
484
+ def command(self, command):
485
+ self.press.cancel()
486
+ self.gear_pointer_down = False
487
+ self._sync_text_input()
488
+ kind = command[0]
489
+ if self.menu_open and kind in ('save', 'load'):
490
+ self._menu_sound()
491
+ if kind == 'menu':
492
+ if self.menu_open:
493
+ return
494
+ self.picker_pointer_down = False
495
+ if self.session.engine.word_grid:
496
+ self.session.grid_pointer('cancel')
497
+ self.menu_open = True
498
+ self.pause_elapsed_ms = 0
499
+ self._sync_music()
500
+ self._menu_sound(opening=True)
501
+ elif kind == 'resume':
502
+ self.menu_open = False
503
+ self._sync_music()
504
+ self._menu_sound()
505
+ elif kind == 'pause_page' and self.on_menu_page:
506
+ self.on_menu_page(command[1])
507
+ self._menu_sound()
508
+ elif kind == 'main_menu' and self.on_main_menu:
509
+ self.on_main_menu()
510
+ self._menu_sound()
511
+ elif kind == 'save':
512
+ try:
513
+ self.session.save()
514
+ self._notice('Progress saved')
515
+ except (OSError, ValueError) as error:
516
+ self._notice(f'Save failed: {error}')
517
+ elif kind == 'load':
518
+ try:
519
+ restored = Session.load(self.session.resources, self.session.save_path)
520
+ self.session = restored
521
+ self.error, self.music_token = None, None
522
+ self.sound_serial = restored.engine.sound_serial
523
+ self.menu_open = False
524
+ self._notice('Progress loaded')
525
+ except (SaveError, OSError) as error:
526
+ self._notice(str(error))
527
+ elif not self.error:
528
+ action = self.session.pending
529
+ if action and action.name == 'text_input' and not self.menu_open and self.active:
530
+ if kind in ('input_dismiss', 'input_focus'):
531
+ self.input_error, self.input_keyboard = None, True
532
+ elif kind == 'continue':
533
+ self._submit_input(action)
534
+ if action and action.name == 'character_picker' and kind in ('choose', 'continue'):
535
+ self._attempt(lambda: self.session.answer(command[1] if kind == 'choose' else None))
536
+ if kind == 'choose' and action and action.name in ('choice', 'word_game'):
537
+ self._attempt(lambda: self.session.answer(command[1]))
538
+ elif kind == 'continue' and action and action.name in ('presentation', 'dialogue', 'message_panel', 'survey_confirmation'):
539
+ self._attempt(self.session.answer)
540
+ elif action and action.name == 'football' and kind in ('continue', 'choose'):
541
+ self._attempt(lambda: self.session.answer(command[1] if kind == 'choose' else None))
542
+ self._sync_text_input()
543
+
544
+ def _press_token(self):
545
+ action = self.session.pending
546
+ game = self.session.engine.word_game
547
+ return (id(self.session), id(action), self.menu_open,
548
+ self.session.vm.steps_executed, game.round if game else None, self.scroll)
549
+
550
+ def cancel_pointer(self):
551
+ self.press.cancel()
552
+ self.gear_pointer_down = False
553
+ self.picker_pointer_down = False
554
+ if self.session.engine.word_grid:
555
+ self.session.grid_pointer('cancel')
556
+
557
+ def handle_event(self, event):
558
+ self._sync_text_input()
559
+ if event.type == pygame.QUIT:
560
+ return False
561
+ if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
562
+ if self.menu_open:
563
+ self.command(('resume',))
564
+ return True
565
+ self.command(('menu',))
566
+ return True
567
+ if event.type in (pygame.WINDOWFOCUSLOST, pygame.WINDOWFOCUSGAINED):
568
+ self.active = event.type == pygame.WINDOWFOCUSGAINED
569
+ self._sync_music()
570
+ self.cancel_pointer()
571
+ self._sync_text_input()
572
+ return True
573
+ action = self.session.pending
574
+ if action and action.name == 'text_input' and (not self.active or self.error):
575
+ return True
576
+ pointer = (event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION)
577
+ and (event.type == pygame.MOUSEMOTION or event.button == 1))
578
+ if self.menu_open and self.pause_elapsed_ms < PAUSE_OPEN_MS and pointer:
579
+ self.press.cancel()
580
+ return True
581
+ if pointer and not self.menu_open and hasattr(self, 'viewport'):
582
+ x = (event.pos[0] - self.viewport.x) * SIZE[0] / self.viewport.width
583
+ y = (event.pos[1] - self.viewport.y) * SIZE[1] / self.viewport.height
584
+ hit = (self.viewport.collidepoint(event.pos) and any(
585
+ command == ('menu',) and rect.collidepoint(x, y) for rect, command in self.buttons))
586
+ if event.type == pygame.MOUSEBUTTONDOWN and hit:
587
+ self.gear_pointer_down = True
588
+ self.press.update('down', ('menu',), self._press_token())
589
+ return True
590
+ if self.gear_pointer_down:
591
+ phase = 'up' if event.type == pygame.MOUSEBUTTONUP else 'move'
592
+ command = self.press.update(phase, ('menu',) if hit else None, self._press_token())
593
+ if phase == 'up':
594
+ self.gear_pointer_down = False
595
+ if command:
596
+ self.command(command)
597
+ return True
598
+ if (action and action.name == 'character_picker' and not self.menu_open and not self.error
599
+ and event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP) and event.button == 1):
600
+ x = (event.pos[0] - self.viewport.x) * 320 / self.viewport.width
601
+ y = (event.pos[1] - self.viewport.y) * 480 / self.viewport.height
602
+ if event.type == pygame.MOUSEBUTTONDOWN:
603
+ self.picker_pointer_down = 0 <= x <= 320 and 0 <= y <= 480
604
+ elif self.picker_pointer_down:
605
+ self.picker_pointer_down = False
606
+ if self.picker_renderer.confirm_hit((x, y)):
607
+ self.command(('continue',))
608
+ else:
609
+ index = self.session.engine.character_picker.hit((x, y))
610
+ if index is not None:
611
+ self.command(('choose', index))
612
+ return True
613
+ if (action and action.name == 'football' and not self.menu_open and not self.error
614
+ and event.type == pygame.MOUSEBUTTONDOWN and event.button == 1):
615
+ x = (event.pos[0] - self.viewport.x) * 320 / self.viewport.width
616
+ y = (event.pos[1] - self.viewport.y) * 480 / self.viewport.height
617
+ if 0 <= x <= 320 and 0 <= y <= 480:
618
+ self._attempt(lambda: self.session.answer(self.football_renderer.hit((x, y))))
619
+ return True
620
+ if (action and action.name == 'word_grid' and not self.menu_open and not self.error
621
+ and event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION)
622
+ and (event.type == pygame.MOUSEMOTION or event.button == 1)):
623
+ x = (event.pos[0] - self.viewport.x) * 320 / self.viewport.width
624
+ y = (event.pos[1] - self.viewport.y) * 480 / self.viewport.height
625
+ phase = {pygame.MOUSEBUTTONDOWN: 'down', pygame.MOUSEMOTION: 'move',
626
+ pygame.MOUSEBUTTONUP: 'up'}[event.type]
627
+ index = self.grid_renderer.hit((x, y), self.session.engine.word_grid)
628
+ self._attempt(lambda: self.session.grid_pointer(phase, index))
629
+ return True
630
+ if self.menu_open and event.type == pygame.KEYDOWN:
631
+ if event.key in (pygame.K_F5, pygame.K_F9):
632
+ self.command(('save' if event.key == pygame.K_F5 else 'load',))
633
+ return True
634
+ if self.menu_open and event.type in (pygame.MOUSEWHEEL, pygame.TEXTINPUT):
635
+ return True
636
+ # Native table rows remain selected under the finger and dispatch on
637
+ # release. Keep the originating panel/deal so expiry cannot answer a
638
+ # successor, and dragging off a row cannot activate a different one.
639
+ if ((self.menu_open or action and action.name in ('choice', 'word_game'))
640
+ and event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION)
641
+ and (event.type == pygame.MOUSEMOTION or event.button == 1)):
642
+ x = (event.pos[0] - self.viewport.x) * SIZE[0] / self.viewport.width
643
+ y = (event.pos[1] - self.viewport.y) * SIZE[1] / self.viewport.height
644
+ hit = (next((command for rect, command in self.buttons if rect.collidepoint(x, y)), None)
645
+ if self.viewport.collidepoint(event.pos) else None)
646
+ phase = {pygame.MOUSEBUTTONDOWN: 'down', pygame.MOUSEBUTTONUP: 'up',
647
+ pygame.MOUSEMOTION: 'move'}[event.type]
648
+ command = self.press.update(phase, hit, self._press_token())
649
+ if command is not None:
650
+ self.command(command)
651
+ return True
652
+ if event.type == pygame.KEYDOWN:
653
+ self.press.cancel()
654
+ if event.key in (pygame.K_F5, pygame.K_F9):
655
+ self.command(('save' if event.key == pygame.K_F5 else 'load',))
656
+ elif event.key == pygame.K_RETURN or (event.key == pygame.K_SPACE and action and action.name != 'text_input'):
657
+ self.command(('continue',))
658
+ elif action and action.name == 'character_picker' and pygame.K_1 <= event.key <= pygame.K_5:
659
+ if event.key - pygame.K_1 < len(self.session.engine.character_picker.characters):
660
+ self.command(('choose', event.key - pygame.K_1))
661
+ elif action and action.name in ('choice', 'word_game', 'football') and pygame.K_1 <= event.key <= pygame.K_9:
662
+ index = event.key - pygame.K_1
663
+ if action.name == 'football' or (action.name == 'word_game' and index < 4) or (action.name == 'choice'
664
+ and index < len(action.details['options']) and action.details['enabled'][index]):
665
+ self.command(('choose', index))
666
+ elif action and action.name == 'text_input' and event.key == pygame.K_BACKSPACE:
667
+ action.details['draft'] = action.details.get('draft', '')[:-1]
668
+ self.input_error, self.input_keyboard = None, True
669
+ elif event.key in (pygame.K_UP, pygame.K_DOWN, pygame.K_PAGEUP, pygame.K_PAGEDOWN):
670
+ delta = -60 if event.key in (pygame.K_UP, pygame.K_PAGEUP) else 60
671
+ self.scroll = min(self.max_scroll, max(0, self.scroll + delta))
672
+ elif event.type == pygame.TEXTINPUT and action and action.name == 'text_input':
673
+ if not self.input_error:
674
+ metrics = self.session.name_input_metrics()
675
+ for char in event.text:
676
+ draft, error = accept_name_character(action.details['draft'], char, **metrics)
677
+ action.details['draft'] = draft
678
+ if error:
679
+ self.input_error, self.input_keyboard = error, False
680
+ break # A modal alert also stops the rest of a paste.
681
+ self.input_keyboard = True
682
+ elif event.type == pygame.MOUSEWHEEL:
683
+ self.press.cancel()
684
+ self.scroll = min(self.max_scroll, max(0, self.scroll - event.y * 40))
685
+ elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
686
+ if self.viewport.collidepoint(event.pos):
687
+ x = (event.pos[0] - self.viewport.x) * SIZE[0] / self.viewport.width
688
+ y = (event.pos[1] - self.viewport.y) * SIZE[1] / self.viewport.height
689
+ for rect, command in self.buttons:
690
+ if rect.collidepoint(x, y):
691
+ self.command(command)
692
+ break
693
+ self._sync_text_input()
694
+ return True
695
+
696
+ def tick(self, elapsed_ms):
697
+ if self.active and self.menu_open:
698
+ self.pause_elapsed_ms = min(PAUSE_OPEN_MS, self.pause_elapsed_ms + elapsed_ms)
699
+ if self.active and not self.error and not self.menu_open:
700
+ self._attempt(lambda: self.session.tick(elapsed_ms))
701
+
702
+ def run(self):
703
+ clock = pygame.time.Clock()
704
+ running = True
705
+ try:
706
+ while running:
707
+ elapsed = clock.tick(60)
708
+ # Draw before hit-testing to keep button positions in sync after a load.
709
+ self.render()
710
+ for event in pygame.event.get():
711
+ running = self.handle_event(event)
712
+ if not running:
713
+ break
714
+ # One game input per frame prevents queued double-clicks
715
+ # from accidentally answering the next dialogue or choice.
716
+ if self.screen_token != self._screen_token():
717
+ break
718
+ if running:
719
+ self.tick(elapsed)
720
+ finally:
721
+ pygame.quit()
722
+
723
+
724
+ def play(resources, *, load_path=None, audio=True):
725
+ session = Session.load(resources, load_path) if load_path else Session(resources)
726
+ Desktop(session, audio=audio).run()