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