exp-runtime-player 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exp_runtime/app_icon.py +12 -0
- exp_runtime/app_info.py +16 -0
- exp_runtime/application.py +825 -0
- exp_runtime/assets/kiwi.svg +49 -0
- exp_runtime/desktop.py +726 -0
- exp_runtime/desktop_choice.py +202 -0
- exp_runtime/desktop_dialogue.py +271 -0
- exp_runtime/desktop_input.py +176 -0
- exp_runtime/desktop_loading.py +45 -0
- exp_runtime/desktop_menu.py +448 -0
- exp_runtime/desktop_message.py +123 -0
- exp_runtime/desktop_pause.py +105 -0
- exp_runtime/desktop_picker.py +68 -0
- exp_runtime/desktop_text.py +88 -0
- exp_runtime/desktop_title.py +82 -0
- exp_runtime/graphics.py +79 -0
- exp_runtime/platforms/__init__.py +1 -0
- exp_runtime/platforms/drawing.py +612 -0
- exp_runtime/platforms/ios.py +225 -0
- exp_runtime/pointer.py +25 -0
- exp_runtime/projection.py +68 -0
- exp_runtime/settings.py +46 -0
- exp_runtime/updates.py +144 -0
- exp_runtime_player-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_player-0.5.0.dist-info/RECORD +29 -0
- exp_runtime_player-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_player-0.5.0.dist-info/entry_points.txt +3 -0
- exp_runtime_player-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_player-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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',))]
|