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,45 @@
1
+ """Service 91's original loading panels, strings, fonts, and five-frame art."""
2
+ from functools import lru_cache
3
+
4
+ from . import graphics as pygame
5
+
6
+ from .fonts import TextStyle
7
+ from .menu import MenuStrings
8
+ from .ui_assets import Rect, read_ui
9
+
10
+
11
+ class LoadingRenderer:
12
+ def __init__(self, resources, text_renderer, artwork):
13
+ self.resources, self.text, self.art = resources, text_renderer, artwork
14
+ self.canvas = pygame.Surface((320, 480)).convert(32)
15
+
16
+ @lru_cache(maxsize=1)
17
+ def panel(self):
18
+ layer = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
19
+ bank = self.resources.dialogue_layout().bank
20
+ # FUN_0008d43c: top panel at GL (0,160), bottom at (-20,133).
21
+ # A zero height argument preserves layout 22's native height of 60.
22
+ for index, root in ((50, Rect(0, 170, 320, 150)), (22, Rect(-20, 287, 350, 60))):
23
+ for node_id, (node, rect) in enumerate(bank.walk(index, root), 1):
24
+ # Layout 22 node 13 (the timer label) and its subtree are hidden.
25
+ if index == 22 and 13 <= node_id <= 17:
26
+ continue
27
+ if node.kind == 1 and node.flags & 16 and rect.width > 0 and rect.height > 0:
28
+ slot, frame = node.payload
29
+ image = self.art.frame({0: 126, 2: 204}[slot], frame)
30
+ layer.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
31
+ strings = MenuStrings.load(self.resources.library)
32
+ title = self.text.layout('ArialRoundedMTBold20', strings[31], 320, TextStyle(20, 0, (41, 104, 221)))
33
+ message = self.text.layout('ArialRoundedMTBold16', strings[35], 300, TextStyle(15, 0, (41, 104, 221)))
34
+ self.text.draw_layout(layer, 'ArialRoundedMTBold20', title, 160 - title.width / 2, 170 + title.height)
35
+ self.text.draw_layout(layer, 'ArialRoundedMTBold16', message, 160 - message.width / 2, 245)
36
+ return layer
37
+
38
+ def draw(self, target, session):
39
+ self.canvas.fill((0, 0, 0))
40
+ self.art.draw_background(self.canvas, session.engine)
41
+ self.canvas.blit(self.panel(), (0, 0))
42
+ icon = self.art.frame(126, session.engine.loading.frame)
43
+ self.canvas.blit(icon, icon.get_rect(center=(155, 317)))
44
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
45
+ return [] # Completion comes from the active timer, never a click.
@@ -0,0 +1,448 @@
1
+ """Original menu artwork and native geometry, plus desktop content setup."""
2
+ from functools import lru_cache
3
+ from collections import Counter
4
+ from io import BytesIO
5
+ import math
6
+
7
+ from . import graphics as pygame
8
+
9
+ from .app_icon import icon_surface
10
+ from .app_info import runtime_version
11
+ from .content import is_bundled
12
+ from .desktop_dialogue import DialogueRenderer
13
+ from .desktop_text import BitmapTextRenderer
14
+ from .fonts import TextStyle
15
+ from .languages import TITLE_LANGUAGES
16
+ from .games import GAMES
17
+ from .menu import MenuFont, MenuStrings, main_button_rects
18
+ from .ui_assets import ImagePack, LayoutBank, Rect, read_ui
19
+ from .updates import UPDATE_MESSAGE
20
+
21
+
22
+ BLUE = (41, 104, 221)
23
+ GRAY = (105, 105, 105)
24
+
25
+
26
+ class MenuRenderer:
27
+ def __init__(self, library=None):
28
+ self.library = library
29
+ self.canvas = pygame.Surface((320, 480)).convert(32)
30
+ self.buttons = []
31
+ self.pressed = None
32
+ self.fallback = pygame.font.Font(None, 20)
33
+ self.small = pygame.font.Font(None, 17)
34
+ if library:
35
+ self.bank = LayoutBank.parse(read_ui(library, 14))
36
+ self.strings = MenuStrings.load(library)
37
+ self.text = BitmapTextRenderer(library)
38
+ self.art = DialogueRenderer(library, self.text, self.image)
39
+ self.menu_fonts = [MenuFont.parse(read_ui(library, asset)) for asset in (532, 533)]
40
+
41
+ @lru_cache(maxsize=16)
42
+ def image(self, asset):
43
+ data = read_ui(self.library, asset)
44
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
45
+ return pygame.image.load(BytesIO(data)).convert_alpha()
46
+ r = ImagePack.parse(data).images[0]
47
+ return pygame.image.frombytes(r.pixels, (r.width, r.height), r.mode).convert_alpha()
48
+
49
+ @lru_cache(maxsize=48)
50
+ def menu_label(self, text, variant=0):
51
+ f = self.menu_fonts[variant]
52
+ widths = [f.space if c == ' ' else f.glyph(c).width + f.tracking if f.glyph(c) else f.space
53
+ for c in text]
54
+ surface = pygame.Surface((max(1, sum(widths) - f.tracking), f.height), pygame.SRCALPHA).convert_alpha()
55
+ x = 0
56
+ for c, width in zip(text, widths):
57
+ r = f.glyph(c)
58
+ if c != ' ' and r:
59
+ surface.blit(pygame.image.frombytes(r.pixels, (r.width, r.height), 'RGBA').convert_alpha(), (x, 0))
60
+ x += width
61
+ return surface
62
+
63
+ def label(self, text, rect, *, size=14, color=BLUE, center=False, title=False):
64
+ rect = pygame.Rect(rect)
65
+ clip = self.canvas.get_clip()
66
+ self.canvas.set_clip(rect.clip(clip))
67
+ native = False
68
+ if self.library:
69
+ name = 'PajamaHip26' if title else 'ArialRoundedMTBold16'
70
+ font = self.text.font(name)
71
+ native = all(c == '\n' or ord(c) in font.glyphs for c in text)
72
+ if native:
73
+ layout = self.text.layout(name, text, rect.width, TextStyle(size, 3, color))
74
+ x = rect.x + (rect.width - layout.width) / 2 if center else rect.x
75
+ self.text.draw_layout(self.canvas, name, layout, x, rect.y)
76
+ else:
77
+ # Desktop metadata is UTF-8, but Android's supplied bitmap fonts
78
+ # only cover ASCII. Keep translated titles/language names intact;
79
+ # this menu-only fallback never changes story layout or saves.
80
+ font = self.unicode_font(size) if self.library else self.small if size < 14 else self.fallback
81
+ y, line = rect.y, ''
82
+ for word in (text + ' \n').split(' '):
83
+ if word == '\n' or (line and font.size(line + word)[0] > rect.width):
84
+ image = font.render(line.rstrip(), True, color)
85
+ self.canvas.blit(image, (rect.centerx - image.get_width() / 2 if center else rect.x, y))
86
+ y += font.get_linesize() + 3
87
+ line = ''
88
+ line += word + ' '
89
+ self.canvas.set_clip(clip)
90
+
91
+ @lru_cache(maxsize=8)
92
+ def unicode_font(self, size):
93
+ # Prefer the original installed UI face; pygame supplies its default
94
+ # when the system has no matching font. No font data are imported.
95
+ path = pygame.font.match_font('Arial Rounded MT Bold,Arial Rounded', bold=True)
96
+ return pygame.font.Font(path, round(size))
97
+
98
+ def button(self, label, rect, command, *, pressed=None, enabled=True, selected=False):
99
+ rect = pygame.Rect(rect)
100
+ pressed = (self.pressed if pressed is None else pressed) if enabled else None
101
+ if self.library:
102
+ # Native list buttons: layouts 70/71 (blue), 72/73 (orange).
103
+ layout = (72 if selected else 70) + int(pressed == command)
104
+ self.layout(layout, Rect(*rect))
105
+ else:
106
+ pygame.draw.rect(self.canvas, (223, 230, 238) if pressed != command else (185, 204, 227), rect, border_radius=5)
107
+ self.label(label, rect.inflate(-8, -8).move(0, -1), size=14, center=True,
108
+ color=(255, 255, 255) if enabled and self.library else BLUE if enabled else GRAY)
109
+ if enabled:
110
+ self.buttons.append((rect, command))
111
+
112
+ def layout(self, index, rect):
113
+ for node, bounds in self.bank.walk(index, rect):
114
+ if node.kind == 1:
115
+ slot, frame = node.payload
116
+ pack = {0: 126, 3: 16, 5: 272}[slot]
117
+ image = self.art.frame(pack, frame)
118
+ if bounds.width > 0 and bounds.height > 0:
119
+ self.canvas.blit(pygame.transform.scale(image, (bounds.width, bounds.height)), (bounds.x, bounds.y))
120
+
121
+ def backdrop(self, *, age=None):
122
+ if not self.library:
123
+ self.canvas.fill((243, 244, 247))
124
+ return
125
+ self.canvas.blit(self.image(6), (0, 0))
126
+ photo = self.image(9)
127
+ if age is not None and age < 1000:
128
+ scale = 1 + 5 * (1 - age / 1000)
129
+ photo = pygame.transform.smoothscale(photo, (round(photo.get_width() * scale), round(photo.get_height() * scale)))
130
+ self.canvas.blit(photo, photo.get_rect(center=(160, 240)))
131
+ if age is None or age >= 1100:
132
+ ribbon = self.image(8)
133
+ height = 1 if age is not None and age < 1100 else min(1, (age - 1100) / 400) if age is not None else 1
134
+ ribbon = pygame.transform.smoothscale(ribbon, (336, max(1, round(69 * (.1 + .9 * height)))))
135
+ ribbon = pygame.transform.rotate(ribbon, 5)
136
+ self.canvas.blit(ribbon, ribbon.get_rect(center=(160, 96)))
137
+ self.canvas.blit(self.image(7), self.image(7).get_rect(center=(160, 96)))
138
+
139
+ def main(self, app):
140
+ age = app.menu_age
141
+ self.backdrop(age=age if app.intro else None)
142
+ start = 1600 if app.intro else 0
143
+ progress = min(1, max(0, (age - start) / 500))
144
+ shift = round(-275 * (1 - progress))
145
+ self.layout(79, Rect(shift, 353, 275, 109))
146
+ labels = [self.strings[126 if app.can_resume() else 125], self.strings[127],
147
+ self.strings[91], 'Switch Game']
148
+ commands = [('episodes', 'play'), ('episodes', 'weekly'), ('episodes', 'all'), ('switch_games',)]
149
+ for i, (rect, label, command) in enumerate(zip(main_button_rects(self.bank), labels, commands)):
150
+ t = min(1, max(0, (age - start - 400) / ((i + 1) * 250))) if app.intro else progress
151
+ # Exponential slide-in; native actions use a rate-10 ease.
152
+ t = 1 if t == 1 else 1 - 2 ** (-10 * t) if t > 0 else 0
153
+ x = round(rect.x - (rect.x + rect.width) * (1 - t))
154
+ bounds = Rect(x, rect.y, rect.width, rect.height)
155
+ self.layout(77 if app.pressed == command else 75, bounds)
156
+ image = self.menu_label(label, int(i == 3))
157
+ self.canvas.blit(image, image.get_rect(center=(x + rect.width // 2, rect.y + rect.height // 2 - 1)))
158
+ if app.ready:
159
+ self.buttons.append((pygame.Rect(rect.x, rect.y, rect.width, rect.height), command))
160
+ if progress == 1 and (not app.intro or age >= 3000):
161
+ gear = Rect(180, 443, 39, 28)
162
+ self.layout(77 if app.pressed == ('options',) else 75, gear)
163
+ image = self.art.frame(272, 13)
164
+ self.canvas.blit(image, image.get_rect(center=(200, 457)))
165
+ image = self.art.frame(272, 15 if app.pressed == ('help',) else 14)
166
+ self.canvas.blit(image, (278, 437))
167
+ self.buttons.extend([(pygame.Rect(180, 440, 41, 35), ('options',)),
168
+ (pygame.Rect(278, 437, 42, 41), ('help',))])
169
+
170
+ def panel(self, title, *, back=True):
171
+ self.backdrop()
172
+ if self.library:
173
+ self.art.box(self.canvas, Rect(19, 88, 282, 324), 1, alpha=248)
174
+ else:
175
+ pygame.draw.rect(self.canvas, (255, 255, 255), (9, 65, 302, 357), border_radius=8)
176
+ self.label(title, (24, 69, 278, 46), size=26, title=True)
177
+ if back:
178
+ self.button('Back', (12, 436, 78, 29), ('back',))
179
+
180
+ def draw(self, app):
181
+ self.buttons = []
182
+ self.pressed = app.pressed
183
+ screen = app.screen
184
+ if screen == 'main':
185
+ self.main(app)
186
+ elif screen == 'games':
187
+ self.panel('EXP Runtime', back=False)
188
+ self.canvas.blit(icon_surface(64), (128, 0))
189
+ self.label('Choose a game', (25, 128, 270, 30), size=20, center=True)
190
+ for index, (key, game) in enumerate(GAMES.items()):
191
+ y = 193 + 99 * index
192
+ self.button(game.TITLE, (25, y, 270, 36), ('choose_game', key), pressed=app.pressed)
193
+ ready = (app.library_path(key) / 'library.json').is_file()
194
+ self.label('Open your library' if ready else 'Import your game assets to play',
195
+ (25, y + 44, 270, 32), color=GRAY, center=True)
196
+ self.label('Each game uses its own assets, episodes and saves.',
197
+ (30, 377, 260, 45), color=GRAY, center=True)
198
+ self.button('Options', (165, 436, 143, 29), ('options',))
199
+ elif screen == 'setup':
200
+ self.panel(app.game_title)
201
+ self.label('Bring your game', (25, 123, 270, 30), size=20)
202
+ self.label(f'Choose your {GAMES[app.selected_game].SOURCES}. It includes the base assets and bundled episodes.',
203
+ (25, 169, 265, 95), color=GRAY)
204
+ self.label('You can add EXP files or ZIP/RAR archives afterward. Choose your files below.' if app.file_picker else
205
+ 'Add EXP files, ZIP/RAR archives or folders afterward. Drag files here, or browse below.',
206
+ (25, 265, 265, 85), color=GRAY)
207
+ self.button('Choose Game', (30, 362, 124, 32), ('browse', 'apk'))
208
+ self.button('Open Library', (166, 362, 124, 32), ('browse', 'library'))
209
+ elif screen == 'episodes':
210
+ title = {'play': 'Play / Resume', 'weekly': self.strings[127], 'all': self.strings[91]}[app.scope]
211
+ self.panel(title)
212
+ query = app.query or 'Search episodes...'
213
+ pygame.draw.rect(self.canvas, (230, 236, 244), (19, 113, 170, 29), border_radius=4)
214
+ self.label(query, (26, 119, 156, 21), color=BLUE if app.query else GRAY)
215
+ self.buttons.append((pygame.Rect(19, 113, 170, 29), ('search',)))
216
+ self.button('By Number' if app.state.order == 'episode' else 'By Title',
217
+ (196, 113, 105, 29), ('order',), pressed=app.pressed)
218
+ clip = pygame.Rect(9, 150, 302, 252)
219
+ self.canvas.set_clip(clip)
220
+ duplicates = Counter(app.state.title(e) for e in app.library.episodes)
221
+ rows, total = app.episode_rows()
222
+ app.scroll = min(app.scroll, max(0, total - clip.height))
223
+ index = 0
224
+ for top, height, section, record in rows:
225
+ y = clip.top + top - app.scroll
226
+ if record is not None:
227
+ index += 1
228
+ if y + height <= clip.top or y >= clip.bottom:
229
+ continue
230
+ if record is None:
231
+ # Native category strip: pack 16 frame 76, 296 x 12,
232
+ # with registry font 2. Padding makes the fold target
233
+ # usable with a mouse while retaining the original art.
234
+ self.canvas.blit(self.art.frame(16, 76), (12, y + 6))
235
+ expanded = bool(app.query) or section.key in app.expanded_groups
236
+ points = ((18, y + 9), (24, y + 9), (21, y + 15)) if expanded else (
237
+ (18, y + 8), (18, y + 16), (24, y + 12))
238
+ pygame.draw.polygon(self.canvas, (255, 255, 255), points)
239
+ label = self.text.layout('ArialRoundedMTBold11', section.title, 243,
240
+ TextStyle(11, 0, (255, 255, 255)))
241
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold11', label,
242
+ 29, y + (height - label.height) / 2)
243
+ count = self.text.layout('ArialRoundedMTBold11', str(len(section.episodes)), 25,
244
+ TextStyle(11, 0, (255, 255, 255)))
245
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold11', count,
246
+ 300 - count.width, y + (height - count.height) / 2)
247
+ if not app.query:
248
+ self.buttons.append((pygame.Rect(9, y, 302, height).clip(clip), ('episode_group', section.key)))
249
+ continue
250
+ rect = pygame.Rect(12, y, 296, height)
251
+ selected = app.pressed == ('episode', record['id'])
252
+ image = self.art.frame(16, 84 if selected else 83 if index % 2 == 0 else 82)
253
+ self.canvas.blit(image, rect)
254
+ title = app.state.title(record)
255
+ duplicate = duplicates[title] > 1
256
+ self.label(title, (27, y + (2 if duplicate else 7), 224, 21 if duplicate else 31), size=14)
257
+ if duplicate:
258
+ self.label('Bundled version' if is_bundled(record) else 'Imported version',
259
+ (27, y + 25, 224, 16), size=11, color=GRAY)
260
+ saved = record['id'] in app.saved
261
+ icon = self.art.frame(16, 96 if saved else 101)
262
+ self.canvas.blit(icon, icon.get_rect(center=(277, y + 21)))
263
+ self.buttons.append((rect.clip(clip), ('episode', record['id'])))
264
+ self.canvas.set_clip(None)
265
+ maximum = max(0, total - clip.height)
266
+ if maximum:
267
+ pygame.draw.rect(self.canvas, (208, 219, 231), (305, clip.y, 3, clip.height))
268
+ thumb = max(18, round(clip.height * clip.height / (maximum + clip.height)))
269
+ y = clip.y + round((clip.height - thumb) * app.scroll / maximum)
270
+ pygame.draw.rect(self.canvas, BLUE, (305, y, 3, thumb))
271
+ if not app.visible_episodes():
272
+ self.label('No episodes found. Add EXP files or ZIP/RAR archives below.', (30, 191, 260, 85), color=GRAY)
273
+ sections = app.episode_sections()
274
+ group_count = sum(section.key != 'saved' for section in sections)
275
+ count = self.text.layout('ArialRoundedMTBold11',
276
+ f'{len(app.visible_episodes())} episodes in {group_count} groups',
277
+ 280, TextStyle(11, 0, GRAY))
278
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold11', count, 160 - count.width / 2, 403)
279
+ if app.query:
280
+ self.button('Clear Search', (99, 436, 121, 29), ('clear_search',))
281
+ else:
282
+ expanded = all(section.key in app.expanded_groups for section in sections)
283
+ self.button('Collapse All' if expanded else 'Expand All', (99, 436, 121, 29), ('episode_groups',))
284
+ self.button('Add', (230, 436, 78, 29), ('browse', 'episodes'))
285
+ elif screen in ('episode', 'restart'):
286
+ record = app.library.select(app.selected)
287
+ self.panel('Restart Episode?' if screen == 'restart' else 'Play Episode')
288
+ self.label(app.state.title(record), (29, 127, 262, 80), size=22, center=True)
289
+ if screen == 'restart':
290
+ self.label('Start from the beginning? Your next automatic checkpoint will replace the previous one. Manual saves are kept.',
291
+ (30, 224, 260, 110), color=GRAY)
292
+ self.button('Restart', (99, 349, 122, 32), ('start', False))
293
+ else:
294
+ self.label('Bundled with your game' if is_bundled(record) else 'Imported episode',
295
+ (30, 231, 260, 35), color=GRAY, center=True)
296
+ can_resume = app.can_resume(record['id'])
297
+ self.button('Resume' if can_resume else 'Play', (83, 298, 154, 33), ('start', True))
298
+ if can_resume:
299
+ self.button('New Game', (83, 350, 154, 33), ('restart',))
300
+ elif screen == 'options':
301
+ self.panel('Options')
302
+ if app.state is not None:
303
+ for y, label, value, setting in ((120, 'Music', app.state.music, 'music'), (164, 'Sound', app.state.sound, 'sound')):
304
+ self.label(label, (31, y + 5, 147, 30), size=20)
305
+ self.button('On' if value else 'Off', (213, y, 78, 29), ('toggle', setting))
306
+ y = 208 if app.state is not None else 120
307
+ self.label('Update checks', (31, y + 5, 173, 30), size=16)
308
+ self.button('On' if app.settings.check_for_updates else 'Off', (213, y, 78, 29), ('toggle_updates',))
309
+ if app.state is None:
310
+ self.label('Check for newer releases when EXP Runtime starts. This setting applies to both games.',
311
+ (31, 175, 258, 100), color=GRAY)
312
+ elif app.paused_menu:
313
+ self.button('Cheats', (30, 257, 260, 34), ('cheats',))
314
+ self.label('Runtime save slot', (31, 298, 258, 25), color=GRAY)
315
+ self.button('Save Progress', (30, 329, 260, 34), ('save',))
316
+ self.button('Load Progress', (30, 377, 260, 34), ('load',))
317
+ else:
318
+ self.label('Episode title language', (31, 246, 258, 25))
319
+ language = self.strings[118 + TITLE_LANGUAGES.index(app.state.title_language)]
320
+ self.button(language, (30, 273, 260, 29), ('title_languages',))
321
+ self.button('Add Episodes', (30, 310, 260, 29), ('browse', 'episodes'))
322
+ self.button('Content Library', (30, 347, 260, 29), ('library',))
323
+ self.button('Cheats', (30, 384, 260, 29), ('cheats',))
324
+ self.button('Switch Game', (165, 436, 143, 29), ('switch_games',))
325
+ elif screen == 'cheats':
326
+ self.panel('Cheats')
327
+ self.label('Choice hints', (31, 126, 170, 30), size=20)
328
+ self.button('On' if app.state.choice_hints else 'Off', (213, 124, 78, 29),
329
+ ('toggle', 'choice_hints'))
330
+ self.label('Green: gain or correct answer.\nRed: loss, wrong answer, or no gain where another answer gains.\nAmber: mixed effects.',
331
+ (30, 184, 260, 120), color=GRAY)
332
+ self.label('Includes classroom and word quizzes, and short sequences with delayed rewards. Uncertain outcomes stay unmarked.',
333
+ (30, 321, 260, 95), color=GRAY)
334
+ elif screen == 'title_languages':
335
+ self.panel('Episode Titles')
336
+ self.label('Choose episode names where translations are available. Story text stays in the language of each supplied episode.',
337
+ (30, 120, 260, 80), color=GRAY)
338
+ for index, language in enumerate(TITLE_LANGUAGES):
339
+ selected = language == app.state.title_language
340
+ label = self.strings[118 + index] + (' (selected)' if selected else '')
341
+ self.button(label, (30, 210 + index * 39, 260, 32), ('title_language', language),
342
+ pressed=app.pressed, selected=selected)
343
+ elif screen == 'library':
344
+ self.panel('Content Library')
345
+ self.label(f'{len(app.library.episodes)} imported episodes', (29, 129, 262, 45), size=20)
346
+ if (app.library.game_id, app.library.kind) == ('shs', 'ipa'):
347
+ count = len(app.library.missing_music_ids)
348
+ caption = (f'{count} music tracks are missing. Optionally add your Android APK to supply them.'
349
+ if count else 'All separately downloaded music tracks are available.')
350
+ self.label(caption, (29, 191, 262, 95), color=GRAY)
351
+ self.button('Add Episodes', (30, 299, 260, 32), ('browse', 'episodes'))
352
+ if count:
353
+ self.button('Add APK Music', (30, 343, 260, 32), ('browse', 'music_apk'))
354
+ self.button('Open Another Library', (30, 387, 260, 32), ('browse', 'library'))
355
+ else:
356
+ self.label(f'Your {app.library.kind.upper()} supplies the {app.game_title} assets. Episode files supply their own stories and artwork. Your originals are kept intact.',
357
+ (29, 197, 262, 95), color=GRAY)
358
+ self.button('Add Episodes', (30, 315, 260, 34), ('browse', 'episodes'))
359
+ self.button('Open Another Library', (30, 369, 260, 34), ('browse', 'library'))
360
+ elif screen == 'help':
361
+ self.panel('Help / About')
362
+ self.label('Tap to reveal text and continue. The lower-left button opens Pause. Save and load progress under Options.' if app.file_picker else
363
+ 'Click or press Space to reveal text and continue. Escape opens the pause menu. F5 saves; F9 loads your manual save.',
364
+ (29, 125, 262, 100), color=GRAY)
365
+ self.label('Add EXP files or ZIP/RAR archives in Options using Files. Play/Resume lists your installed episodes.' if app.file_picker else
366
+ 'Add EXP files or ZIP/RAR archives in Options, or drop files and folders onto the menu. Play/Resume lists your episodes.',
367
+ (29, 235, 262, 100), color=GRAY)
368
+ self.label(f'EXP Runtime v{runtime_version()}', (29, 334, 262, 25), color=GRAY)
369
+ self.label('Independent game engine.', (29, 361, 262, 22), size=11, color=GRAY)
370
+ self.button('GitHub Project', (30, 390, 260, 29), ('project',), pressed=app.pressed)
371
+ elif screen == 'browser':
372
+ self.panel({'apk': 'Choose Game', 'music_apk': 'Add APK Music',
373
+ 'episodes': 'Add Episodes', 'library': 'Open Library'}[app.browser_kind])
374
+ pygame.draw.rect(self.canvas, (230, 236, 244), (19, 110, 282, 37), border_radius=4)
375
+ self.label(app.path_text, (24, 114, 272, 30), size=11)
376
+ self.buttons.append((pygame.Rect(19, 110, 282, 37), ('path',)))
377
+ self.button('Up', (20, 155, 60, 27), ('up',))
378
+ self.label('Click a folder to open it', (91, 161, 210, 23), size=11, color=GRAY)
379
+ clip = pygame.Rect(19, 190, 282, 210)
380
+ self.canvas.set_clip(clip)
381
+ for i, path in enumerate(app.files):
382
+ rect = pygame.Rect(19, 190 + i * 35 - app.scroll, 282, 35)
383
+ if not rect.colliderect(clip):
384
+ continue
385
+ color = ((170, 225, 249) if app.pressed == ('file', path) else
386
+ (240, 242, 246) if i % 2 else (255, 255, 255))
387
+ pygame.draw.rect(self.canvas, color, rect)
388
+ self.label(('[+] ' if path.is_dir() else '') + path.name, rect.inflate(-10, -8), size=14)
389
+ self.buttons.append((rect.clip(clip), ('file', path)))
390
+ self.canvas.set_clip(None)
391
+ if not app.files:
392
+ self.label('No matching files in this folder.', (30, 230, 260, 70), color=GRAY)
393
+ if app.browser_kind in ('episodes', 'library'):
394
+ self.button('Use This Folder', (162, 436, 146, 29), ('folder',))
395
+ if app.busy:
396
+ self.overlay('Importing content', 'Checking and copying your files...')
397
+ self.buttons = []
398
+ # Small animated progress indicator; work runs outside the UI thread.
399
+ x = 55 + round((math.sin(pygame.time.get_ticks() / 250) + 1) * 90)
400
+ pygame.draw.circle(self.canvas, BLUE, (x, 290), 5)
401
+ elif app.update_prompt is not None:
402
+ self.confirmation(UPDATE_MESSAGE)
403
+ elif app.message:
404
+ self.overlay(app.game_title, app.message)
405
+ self.buttons = []
406
+ self.button('OK', (121, 355, 78, 29), ('dismiss',))
407
+ return self.canvas
408
+
409
+ def confirmation(self, message):
410
+ """Original two-button menu skin, with No on the left and Yes right.
411
+
412
+ SHSWidgetMenu::generateNewMenu/finalizeMenu select header 33,
413
+ footer 39 and orange normal/held buttons 72/73 for this menu style.
414
+ The new app message uses that skin without invoking a story service.
415
+ """
416
+ self.buttons = []
417
+ if self.library:
418
+ shade = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
419
+ shade.fill((0, 0, 0, 150))
420
+ self.canvas.blit(shade, (0, 0))
421
+ pygame.draw.rect(self.canvas, (239, 239, 239), (9, 155, 302, 169))
422
+ self.layout(33, Rect(9, 155, 302, 56))
423
+ footer = Rect(9, 281, 302, 43)
424
+ self.layout(39, footer)
425
+ self.label('EXP Runtime', (24, 166, 272, 36), size=20, center=True)
426
+ self.label(message, (24, 219, 266, 54), size=16, color=GRAY, center=True)
427
+ # LayoutBank uses flattened one-based IDs: nodes 1/2 are the
428
+ # footer art and separator, and nodes 3/4 are its button regions.
429
+ for index, role, command in ((3, 26, 'update_no'), (4, 25, 'update_yes')):
430
+ rect = self.bank.rectangle(39, index, footer)
431
+ self.button(self.strings[role], (rect.x, rect.y, rect.width, rect.height),
432
+ (command,), selected=True)
433
+ else:
434
+ # No game files exist yet; retain the launcher's authored fallback.
435
+ self.overlay('EXP Runtime', message)
436
+ self.button('No', (34, 355, 78, 29), ('update_no',))
437
+ self.button('Yes', (208, 355, 78, 29), ('update_yes',))
438
+
439
+ def overlay(self, title, text):
440
+ shade = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
441
+ shade.fill((0, 0, 0, 150))
442
+ self.canvas.blit(shade, (0, 0))
443
+ if self.library:
444
+ self.art.box(self.canvas, Rect(29, 143, 262, 244), 1)
445
+ else:
446
+ pygame.draw.rect(self.canvas, (255, 255, 255), (19, 133, 282, 264), border_radius=8)
447
+ self.label(title, (35, 146, 250, 41), size=20, center=True)
448
+ self.label(text, (35, 195, 250, 145), size=14, color=GRAY)
@@ -0,0 +1,123 @@
1
+ """Service 33's original blue message panel, bitmap text and reading gate."""
2
+ from dataclasses import replace
3
+ from functools import lru_cache
4
+
5
+ from . import graphics as pygame
6
+ from .desktop_pause import draw_pause_gear
7
+
8
+ from .fonts import TextStyle
9
+ from .menu import MenuStrings
10
+ from .ui_assets import Rect, read_ui
11
+
12
+
13
+ BLUE = (41, 104, 221)
14
+
15
+
16
+ class MessageRenderer:
17
+ def __init__(self, resources, text_renderer, artwork):
18
+ self.resources, self.text, self.art = resources, text_renderer, artwork
19
+ self.canvas = pygame.Surface((320, 480)).convert(32)
20
+
21
+ @lru_cache(maxsize=1)
22
+ def strings(self):
23
+ return MenuStrings.load(self.resources.library)
24
+
25
+ @lru_cache(maxsize=8)
26
+ def layout_image(self, index, width, height):
27
+ layer = pygame.Surface((width, height), pygame.SRCALPHA).convert_alpha()
28
+ bank = self.resources.dialogue_layout().bank
29
+ for number, (node, rect) in enumerate(bank.walk(index, Rect(0, 0, width, height)), 1):
30
+ # Layout 24's headers/footer lie outside the body texture. They
31
+ # are rendered as separate sprites by 000abd0c, not stretched in.
32
+ if index == 24 and number > 5:
33
+ continue
34
+ if node.kind == 1 and node.flags & 16 and rect.width > 0 and rect.height > 0:
35
+ slot, frame = node.payload
36
+ image = self.art.frame({0: 126, 2: 204}[slot], frame)
37
+ layer.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
38
+ return layer
39
+
40
+ @lru_cache(maxsize=16)
41
+ def panel(self, title, text):
42
+ bank = self.resources.dialogue_layout().bank
43
+ root = bank.layouts[24]
44
+ builtin = self.resources.record['episode_id'] == 0
45
+ if builtin:
46
+ # 000abd0c explicitly uses width-40 for episode ID zero,
47
+ # including the built-in Football Star / Football Season story.
48
+ height = root.width - 40
49
+ else:
50
+ body_rect = bank.rectangle(24, 24)
51
+ measure = self.text.layout('ArialRoundedMTBold14', text, body_rect.width,
52
+ TextStyle(14, 5, BLUE))
53
+ # 00058a54 expands the root to fit node 24, with its original
54
+ # minimum. The unused legacy-font padding read is unresolved;
55
+ # zero padding is used here (see STORY_SERVICES.md).
56
+ requested = int(measure.height + 1)
57
+ requested += requested % 2
58
+ height = max(root.height, requested + root.height - body_rect.height)
59
+ width = root.width
60
+ top = 240 - height / 2
61
+ layer = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
62
+ layer.blit(self.layout_image(24, width, height), (160 - width // 2, round(top)))
63
+ header = bank.layouts[15]
64
+ header_top = top - header.height + 2
65
+ layer.blit(self.layout_image(15, header.width, header.height),
66
+ (160 - header.width // 2, round(header_top)))
67
+ footer = bank.layouts[25]
68
+ footer_top = 240 + height / 2 - 2
69
+ layer.blit(pygame.transform.flip(self.layout_image(25, footer.width, footer.height), False, True),
70
+ (160 - footer.width // 2, round(footer_top)))
71
+
72
+ heading = self.text.layout('ArialRoundedMTBold20', title, 2**31, TextStyle(10, 7, BLUE))
73
+ scale = 1 if len(title) < 31 else .88
74
+ # Alignment 10: each line centered; preserve the BM node's downward
75
+ # line origin and atlas overhang rather than centering the ink bounds.
76
+ shifts = {i: (heading.width - line.width) / 2 for line in heading.lines
77
+ for i in range(line.start, line.end)}
78
+ heading = replace(heading, glyphs=tuple(replace(g, x=g.x + shifts[g.index]) for g in heading.glyphs))
79
+ self.text.draw_layout(layer, 'ArialRoundedMTBold20', heading,
80
+ 160 - heading.width * scale / 2,
81
+ header_top + header.height / 2 + heading.height * scale / 2, scale=scale)
82
+ body = self.text.layout('ArialRoundedMTBold16', text, root.width - 25, TextStyle(10, 3, BLUE))
83
+ # Alignment 21, child anchor (1/2,1/2). Both native episode-ID paths
84
+ # use a label height equal to layout 24's original height, even when
85
+ # the surrounding sprite has expanded.
86
+ body_y = top + 15 if builtin else height + root.height / 2
87
+ self.text.draw_layout(layer, 'ArialRoundedMTBold16', body,
88
+ 160 - (root.width - 25) / 2, body_y)
89
+ return layer, footer_top + footer.height
90
+
91
+ def draw(self, target, session):
92
+ panel = session.engine.message_panel
93
+ self.canvas.fill((0, 0, 0))
94
+ background = self.art.image(session.engine.panel.background_id)
95
+ if background is not None:
96
+ # 000ac4dc places this panel's background at GL (160,240).
97
+ self.canvas.blit(background, background.get_rect(center=(160, 240)))
98
+ layer, button_y = self.panel(panel.title, panel.text)
99
+ self.canvas.blit(layer, (0, 0))
100
+ strings = self.strings()
101
+ caption = strings[29] if panel.ready else f'{strings[30]} ({panel.countdown})'
102
+ label = self.text.layout('TrebuchetMS_Bold14', caption, 2**31,
103
+ TextStyle(16, 5, (255, 255, 255)))
104
+ # 000acc28 grows the shared continuation node over 250 ms. Its
105
+ # anchor is zero; 00098940 attaches the artwork centered at (0,0).
106
+ # The text child has position (-6,8), size (40,0), anchor (1/2,1/2).
107
+ control = pygame.Surface((140, 60), pygame.SRCALPHA).convert_alpha()
108
+ image = self.art.frame(126, 5)
109
+ control.blit(image, image.get_rect(center=(60, 30)))
110
+ self.text.draw_layout(control, 'TrebuchetMS_Bold14', label, 34, 22)
111
+ scale = max(.001, min(1., panel.elapsed_ms / 250))
112
+ self.canvas.blit(pygame.transform.smoothscale(control, (max(1, round(140 * scale)),
113
+ max(1, round(60 * scale)))),
114
+ (round(245 - 60 * scale), round(button_y - 30 * scale)))
115
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
116
+ draw_pause_gear(self.canvas, self.art)
117
+ hint = self.text.layout('ArialRoundedMTBold11', strings[36 if panel.ready else 39],
118
+ 247, TextStyle(11, 0, (185, 185, 185)))
119
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold11', hint,
120
+ 63 + (252 - hint.width) / 2, 460)
121
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
122
+ return [(pygame.Rect(0, 422, 60, 58), ('menu',)),
123
+ (pygame.Rect(0, 0, 320, 480), ('continue',))]