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,202 @@
1
+ """Original choice-panel artwork; selection values remain in the session."""
2
+ import math
3
+ from functools import lru_cache
4
+
5
+ from . import graphics as pygame
6
+ from .desktop_pause import draw_pause_gear
7
+
8
+ from .choice import BODY_FONT, FOOTER_FONT, ChoiceLayout
9
+ from .choice_hints import preview_choices
10
+ from .fonts import TextStyle
11
+ from .ui_assets import Rect
12
+
13
+
14
+ class ChoiceRenderer:
15
+ def __init__(self, resources, text_renderer, artwork):
16
+ self.resources, self.text, self.art = resources, text_renderer, artwork
17
+ self.layout = None
18
+ self.canvas = pygame.Surface((320, 480)).convert(32)
19
+ self.cached_key = self.cached_page = None
20
+ self.hint_key = None
21
+ self.hints = ()
22
+ self.viewport = pygame.Rect(0, 0, 320, 421)
23
+
24
+ def page(self, session):
25
+ details = session.pending.details
26
+ game = session.engine.word_game
27
+ key = id(session), session.scene, session.vm.steps_executed, game.round if game else None
28
+ if game:
29
+ details = dict(details, options=game.options, enabled=[True] * 4, minigame=True)
30
+ if key != self.cached_key:
31
+ if self.layout is None:
32
+ self.layout = ChoiceLayout(self.resources)
33
+ character = details['character_id']
34
+ theme = session.engine.numbers.get(session.engine.number_key(character, 651), 0)
35
+ variants = session.engine.character_art_variants.get(character, ())
36
+ self.cached_page = self.layout.page(details, theme=theme,
37
+ has_portrait=character >= 0 and bool(variants) and variants[0] > 0)
38
+ self.cached_key = key
39
+ return self.cached_page
40
+
41
+ @lru_cache(maxsize=8)
42
+ def timer_panel(self, theme, width, height):
43
+ layer = pygame.Surface((width, height), pygame.SRCALPHA).convert_alpha()
44
+ bank = self.layout.bank
45
+ hidden_until = 0
46
+ for number, (node, rect) in enumerate(bank.walk(22, Rect(0, 0, width, height)), 1):
47
+ if number <= hidden_until:
48
+ continue
49
+ # 000d9038 hides the score capsule (root 13) for ordinary choices.
50
+ visible = node.flags & 16 and number != 13
51
+ if node.kind == 7 and not visible:
52
+ hidden_until = number + sum(1 for _ in bank.walk(node.payload[0]))
53
+ continue
54
+ if node.kind == 1 and visible and rect.width > 0 and rect.height > 0:
55
+ slot, frame = node.payload
56
+ pack = {1: 204, 2: 220, 3: 252}.get(theme, 236)
57
+ image = self.art.frame({0: 126, 2: pack}[slot], frame)
58
+ layer.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
59
+ return layer
60
+
61
+ def draw_timer(self, position, elapsed_fraction):
62
+ """00082744: base 708, then 709 swept clockwise from twelve o'clock."""
63
+ fraction = min(1., max(0., elapsed_fraction))
64
+ if getattr(getattr(self.resources, 'library', None), 'kind', 'apk') == 'ipa':
65
+ # GameModel::init passes common frames 7..22 to the native iOS
66
+ # circle layer. Each wedge fades over its own 1/16 interval,
67
+ # holding opacity .8 for the first .4 of that interval.
68
+ for index in range(16):
69
+ progress = fraction * 16 - index
70
+ opacity = min(1., max(0., (1 - progress) / .6)) * .8
71
+ if opacity <= 0:
72
+ continue
73
+ image = self.art.frame(126, 7 + index).copy()
74
+ quadrant = index // 4
75
+ x = 25 - image.get_width() if quadrant >= 2 else 25
76
+ y = 25 - image.get_height() if quadrant in (0, 3) else 25
77
+ image.set_alpha(round(opacity * 255))
78
+ self.canvas.blit(image, (position[0] + 6 + x, position[1] + 5 + y))
79
+ return
80
+ self.canvas.blit(self.art.frame(708, 0), position)
81
+ if fraction <= 0:
82
+ return
83
+ timer = self.art.frame(709, 0).copy()
84
+ if fraction < 1:
85
+ mask = pygame.Surface(timer.get_size(), pygame.SRCALPHA)
86
+ cx, cy = timer.get_width() / 2, timer.get_height() / 2
87
+ radius = math.hypot(cx, cy) + 1
88
+ points = [(cx, cy)] + [(cx + radius * math.sin(t), cy - radius * math.cos(t))
89
+ for t in (fraction * 2 * math.pi * i / 120 for i in range(121))]
90
+ pygame.draw.polygon(mask, (255, 255, 255, 255), points)
91
+ timer.blit(mask, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
92
+ self.canvas.blit(timer, position)
93
+
94
+ def draw(self, target, session, *, scroll=0, pointer=None, pressed=None, choice_hints=False):
95
+ page = self.page(session)
96
+ if choice_hints and self.hint_key != self.cached_key:
97
+ self.hints = preview_choices(session)
98
+ self.hint_key = self.cached_key
99
+ scroll = min(page.max_scroll, max(0, scroll))
100
+ self.viewport.height = page.timer_panel.y if page.timer_panel else 421
101
+ self.canvas.set_clip(None)
102
+ self.canvas.fill((0, 0, 0))
103
+ if session.engine.word_game:
104
+ # The word game's separate background is centered at GL (160,240).
105
+ background = self.art.image(session.engine.panel.background_id)
106
+ if background is not None:
107
+ self.canvas.blit(background, background.get_rect(center=(160, 240)))
108
+ else:
109
+ self.art.draw_background(self.canvas, session.engine)
110
+ self.canvas.set_clip(self.viewport)
111
+ box = page.box
112
+ body_height = box.height - page.timer_panel.height + 10 if page.timer_panel else box.height
113
+ self.art.box(self.canvas, Rect(box.x + 10, box.y + 10 - scroll,
114
+ box.width - 20, body_height - 20), page.theme, alpha=243)
115
+ buttons = []
116
+ for row in page.rows:
117
+ rect = pygame.Rect(row.rect.x, row.rect.y - scroll, row.rect.width, row.rect.height)
118
+ hit = rect.clip(self.viewport)
119
+ hint = (self.hints[row.index].kind if choice_hints and row.enabled
120
+ and self.hint_key == self.cached_key and row.index < len(self.hints) else 'unknown')
121
+ color = {'gain': (189, 237, 199), 'loss': (251, 201, 199),
122
+ 'no_gain': (251, 201, 199), 'mixed': (251, 228, 175)}.get(hint)
123
+ if color is not None:
124
+ pygame.draw.rect(self.canvas, color, rect)
125
+ if row.enabled and (pressed == ('choose', row.index)
126
+ or pointer is not None and hit.collidepoint(pointer)):
127
+ color = (tuple(round(channel * .9) for channel in color) + (255,) if color else
128
+ (250, 214, 211, 180) if page.theme == 2 else (170, 225, 249, 180))
129
+ highlight = pygame.Surface(rect.size, pygame.SRCALPHA)
130
+ highlight.fill(color)
131
+ self.canvas.blit(highlight, rect)
132
+ pygame.draw.line(self.canvas, (178, 179, 179), rect.topleft, (rect.right - 1, rect.top))
133
+ game = session.engine.word_game
134
+ offset = 0
135
+ if game and game.animation_ms:
136
+ offset = (game.animation_ms / 200) * (-210 if row.index % 2 == 0 else 410)
137
+ self.canvas.set_clip(hit)
138
+ self.text.draw_layout(self.canvas, BODY_FONT, row.text, row.origin[0] + offset, row.origin[1] - scroll)
139
+ self.canvas.set_clip(self.viewport)
140
+ if row.enabled and hit.height:
141
+ buttons.append((hit, ('choose', row.index)))
142
+ if page.rows:
143
+ rect = page.rows[-1].rect
144
+ y = rect.y + rect.height - scroll
145
+ pygame.draw.line(self.canvas, (178, 179, 179), (rect.x, y), (rect.x + rect.width - 1, y))
146
+
147
+ if page.portrait is not None:
148
+ cx, cy = page.portrait.center
149
+ cy -= scroll
150
+ for index in (37, {1: 38, 2: 39}.get(page.theme, 40)):
151
+ image = self.art.frame(126, index)
152
+ self.canvas.blit(image, image.get_rect(center=(cx, cy)))
153
+ details = session.pending.details
154
+ asset = session.engine.character_art_variants[details['character_id']][0]
155
+ # FUN_000ae5d4 requests expression zero, independently of dialogue.
156
+ image = self.art.portrait(asset, details['portrait_mode'] == 2 and page.theme != 3)
157
+ if image is not None:
158
+ self.canvas.blit(image, self.art.portrait_rect(image, (cx, cy)))
159
+ self.text.draw_layout(self.canvas, page.title_font, page.title,
160
+ page.title_origin[0], page.title_origin[1] - scroll)
161
+ self.text.draw_layout(self.canvas, BODY_FONT, page.description,
162
+ page.description_origin[0], page.description_origin[1] - scroll)
163
+ self.canvas.set_clip(None)
164
+ if page.timer_panel is not None:
165
+ panel = page.timer_panel
166
+ self.canvas.blit(self.timer_panel(page.theme, panel.width, panel.height), (panel.x, panel.y))
167
+ clock = self.layout.bank.rectangle(22, 10, panel)
168
+ self.draw_timer((clock.x, clock.y),
169
+ 1 - session.remaining_ms / session.pending.details['timeout_ms'])
170
+ game = session.engine.word_game
171
+ if game:
172
+ pack = {1: 204, 2: 220, 3: 252}.get(page.theme, 236)
173
+ self.canvas.blit(self.art.frame(pack, 9), (130, 353))
174
+ self.canvas.blit(self.art.frame(pack, 10), (182, 375))
175
+ self.canvas.blit(pygame.transform.scale(self.art.frame(pack, 11), (12, 39)), (221, 375))
176
+ self.canvas.blit(self.art.frame(pack, 12), (233, 375))
177
+ self.draw_timer((130, 353), 1 - game.remaining_ms / game.duration_ms)
178
+ # Native score text is at (230,88) in GL coordinates (top y=392).
179
+ score = self.text.layout(BODY_FONT, str(game.score), 60, TextStyle(14, 0, (255, 255, 255)))
180
+ self.text.draw_layout(self.canvas, BODY_FONT, score, 230 - score.width / 2, 392)
181
+ if game.animation_ms and game.last_delta:
182
+ color = (37, 147, 56) if game.last_delta > 0 else (255, 0, 0)
183
+ delta = self.text.layout(BODY_FONT, f'{game.last_delta:+d}', 40, TextStyle(14, 0, color))
184
+ self.text.draw_layout(self.canvas, BODY_FONT, delta, 275, 365 + game.animation_ms / 10)
185
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
186
+ draw_pause_gear(self.canvas, self.art)
187
+ hint = 'Touch the best choice'
188
+ style = TextStyle(11, 0, (185, 185, 185))
189
+ footer = self.text.layout(FOOTER_FONT, hint, 246, style)
190
+ self.text.draw_layout(self.canvas, FOOTER_FONT, footer, 63 + (252 - footer.width) / 2, 460)
191
+ if page.max_scroll:
192
+ # A desktop affordance for unusually long options; no choices are
193
+ # discarded or mapped to a different script-visible index.
194
+ track = pygame.Rect(310, 40, 3, self.viewport.height - 43)
195
+ pygame.draw.rect(self.canvas, (100, 100, 100), track)
196
+ visible_height = self.viewport.height - 31
197
+ thumb = max(20, int(track.height * visible_height / (visible_height + page.max_scroll)))
198
+ y = track.y + int((track.height - thumb) * scroll / page.max_scroll)
199
+ pygame.draw.rect(self.canvas, (225, 225, 225), (track.x, y, track.width, thumb))
200
+ buttons.append((pygame.Rect(0, 422, 60, 58), ('menu',)))
201
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
202
+ return page, buttons
@@ -0,0 +1,271 @@
1
+ """Original dialogue artwork at native logical coordinates."""
2
+ from functools import lru_cache
3
+ from io import BytesIO
4
+
5
+ from . import graphics as pygame
6
+
7
+ from .content import ContentError
8
+ from .dialogue_notice import NOTICE_FONT, NOTICE_STYLE, NoticeMotion
9
+ from .fonts import TextStyle
10
+ from .menu import MenuStrings
11
+ from .ui_assets import ImagePack, Raster, UIAssetError, read_ui
12
+
13
+
14
+ class DialogueRenderer:
15
+ def __init__(self, resources, text_renderer, image_loader):
16
+ self.resources = resources
17
+ self.text = text_renderer
18
+ self.image = image_loader
19
+ self.canvas = pygame.Surface((320, 480)).convert(32)
20
+ self.cached_key = None
21
+ self.cached_page = None
22
+ self.name_layer = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
23
+
24
+ def draw_background(self, target, engine):
25
+ background = self.image(engine.panel.background_id)
26
+ if background is not None:
27
+ target.blit(background, engine.background_pan.origin(*background.get_size()))
28
+
29
+ @lru_cache(maxsize=8)
30
+ def pack(self, asset_id):
31
+ return ImagePack.parse(read_ui(self.resources, asset_id))
32
+
33
+ @lru_cache(maxsize=32)
34
+ def ui_image(self, role):
35
+ library = getattr(self.resources, 'library', self.resources)
36
+ if getattr(library, 'kind', 'apk') != 'ipa':
37
+ return self.image(role)
38
+ data = read_ui(self.resources, role)
39
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
40
+ return pygame.image.load(BytesIO(data)).convert_alpha()
41
+ frame = ImagePack.parse(data).images[0]
42
+ return pygame.image.frombytes(frame.pixels, (frame.width, frame.height), frame.mode).convert_alpha()
43
+
44
+ @lru_cache(maxsize=64)
45
+ def frame(self, asset_id, index):
46
+ frame = self.pack(asset_id).images[index]
47
+ return pygame.image.frombytes(frame.pixels, (frame.width, frame.height), frame.mode).convert_alpha()
48
+
49
+ @lru_cache(maxsize=32)
50
+ def portrait(self, asset_id, flipped):
51
+ """Native portrait texture: normalize, mask, remove 12 rows, then flip."""
52
+ image = self.image(asset_id)
53
+ if image is None:
54
+ return None
55
+ raster = Raster(*image.get_size(), pygame.image.tobytes(image, 'RGBA'))
56
+ mask = self.pack(268).images[0]
57
+ masked = raster.normalize_portrait(mask).portrait_mask(mask)
58
+ # FUN_0005afbc shortens the masked texture before FUN_0009c034
59
+ # aligns its bottom to the circle. Keeping the discarded rows lifts
60
+ # the visible character by 12 pixels inside its frame.
61
+ height = masked.height - 12
62
+ if height <= 0:
63
+ raise UIAssetError(f'Portrait height {masked.height} cannot accommodate the native 12-row crop')
64
+ pixels = masked.pixels[:masked.width * height * 4]
65
+ # Match the display format after creating the masked RGBA buffer.
66
+ # Cocoa's opaque BGRA canvas can otherwise copy transparent RGB
67
+ # instead of blending it, exposing white pixels and black mask corners.
68
+ image = pygame.image.frombytes(pixels, (masked.width, height), 'RGBA').convert_alpha()
69
+ return pygame.transform.flip(image, flipped, False) if flipped else image
70
+
71
+ def portrait_rect(self, image, center=(0, 0)):
72
+ """FUN_0009c034 positions the sprite center within the bubble."""
73
+ x, y = center
74
+ offset = self.frame(126, 39).get_height() // 2 - image.get_height() // 2
75
+ # The native routine halves each height separately. A midbottom
76
+ # anchor puts odd-height artwork one pixel above this center.
77
+ return image.get_rect(center=(x, y + offset))
78
+
79
+ def box(self, target, rect, theme, *, alpha=255):
80
+ """FUN_00081920 places the eight border pieces outside the center."""
81
+ pack = {1: 204, 2: 220, 3: 252}.get(theme, 236)
82
+ x, y, width, height = rect.x, rect.y, rect.width, rect.height
83
+ center = self.frame(126, 0).copy()
84
+ center.set_alpha(alpha)
85
+ target.blit(pygame.transform.scale(center, (width, height)), (x, y))
86
+ for index, column, row in ((6, 0, 0), (7, 1, 0), (8, 2, 0), (4, 0, 1),
87
+ (5, 2, 1), (0, 0, 2), (1, 1, 2), (2, 2, 2)):
88
+ image = self.frame(pack, index).copy()
89
+ image.set_alpha(alpha)
90
+ w, h = image.get_size()
91
+ size = (width if column == 1 else w, height if row == 1 else h)
92
+ position = (x - w if column == 0 else x + width if column == 2 else x,
93
+ y - h if row == 0 else y + height if row == 2 else y)
94
+ target.blit(pygame.transform.scale(image, size), position)
95
+
96
+ @lru_cache(maxsize=32)
97
+ def portrait_group(self, portrait, clip=None, old=None, old_alpha=0, alpha=255):
98
+ """The art and both rings are children of the native scaled node."""
99
+ rings = [self.frame(126, index)
100
+ for index in (37, {1: 38, 2: 39}.get(portrait.theme, 40))]
101
+ if clip is not None:
102
+ # drawInContext's even-odd path cuts only housing frame 37.
103
+ # Its polygon has 10px bevels at the two upper corners.
104
+ housing = rings[0].copy()
105
+ origin = housing.get_rect(center=(0, 0))
106
+ x, y, w, h = clip
107
+ x, y = x - origin.x, y - origin.y
108
+ pygame.draw.polygon(housing, (0, 0, 0, 0),
109
+ ((x, y + h), (x, y + 10), (x + 10, y),
110
+ (x + w - 10, y), (x + w, y + 10), (x + w, y + h)))
111
+ rings[0] = housing
112
+ pieces = [(ring, ring.get_rect(center=(0, 0))) for ring in rings]
113
+ for art, opacity in ((old, old_alpha), (portrait, alpha)):
114
+ if art is None or opacity <= 0:
115
+ continue
116
+ image = self.portrait(art.asset_id, art.mode == 2 and art.theme != 3)
117
+ if image is not None:
118
+ if opacity < 255:
119
+ image = image.copy()
120
+ image.set_alpha(opacity)
121
+ pieces.append((image, self.portrait_rect(image)))
122
+ bounds = pieces[0][1].unionall([rect for _, rect in pieces[1:]])
123
+ layer = pygame.Surface(bounds.size, pygame.SRCALPHA).convert_alpha()
124
+ for piece, rect in pieces:
125
+ layer.blit(piece, rect.move(-bounds.x, -bounds.y))
126
+ return layer, bounds.topleft
127
+
128
+ def draw_portrait(self, portrait, scale, *, box=None, old=None, alphas=(0, 255)):
129
+ if portrait is None or scale <= 0:
130
+ return
131
+ rect = self.resources.dialogue_layout().bank.rectangle(17, 0x30 if portrait.mode == 1 else 0x4e)
132
+ cx, cy = rect.center
133
+ cy -= 5 # FUN_000aaa40 uses 485-y when converting to GL.
134
+ clip = None if box is None else (box.x - 7 - cx, box.y - 7 - cy,
135
+ box.width + 14, box.height + 34)
136
+ layer, (x, y) = self.portrait_group(portrait, clip, old, *alphas)
137
+ if scale != 1:
138
+ size = max(1, round(layer.get_width() * scale)), max(1, round(layer.get_height() * scale))
139
+ layer = pygame.transform.smoothscale(layer, size)
140
+ self.canvas.blit(layer, (round(cx + x * scale), round(cy + y * scale)))
141
+
142
+ def draw_relationship(self, motion):
143
+ if motion.relationship is None or motion.portrait is None:
144
+ return
145
+ rect = self.resources.dialogue_layout().bank.rectangle(17, 0x30 if motion.portrait.mode == 1 else 0x4e)
146
+ cx, cy = rect.center
147
+ cy -= 5
148
+ # Indicator parents are siblings of the head's scaled node. They
149
+ # appear after its entrance and do not inherit the head-only flip.
150
+ for pose in motion.relationship.poses():
151
+ icon = self.ui_image(pose.asset_id)
152
+ if icon is None:
153
+ continue
154
+ image = pygame.transform.rotozoom(icon, -pose.rotation, 1)
155
+ center = round(cx + pose.x), round(cy + pose.y)
156
+ self.canvas.blit(image, image.get_rect(center=center))
157
+ if pose.flash_alpha:
158
+ flash = self.ui_image(pose.flash_asset)
159
+ if flash is not None:
160
+ flash = pygame.transform.rotozoom(flash, -pose.rotation, pose.flash_scale)
161
+ flash.set_alpha(pose.flash_alpha)
162
+ self.canvas.blit(flash, flash.get_rect(center=center))
163
+
164
+ def draw_notice(self, engine):
165
+ portrait = engine.dialogue_animation.portrait
166
+ # This label belongs to the current portrait parent, which the native
167
+ # panel hides for narration and dialogue without character artwork.
168
+ if not engine.notice_ms or portrait is None:
169
+ return
170
+ motion = NoticeMotion(engine.notice, engine.notice_ms)
171
+ rect = self.resources.dialogue_layout().bank.rectangle(17, 0x30 if portrait.mode == 1 else 0x4e)
172
+ x, y = motion.origin(rect, portrait.mode)
173
+ # Native alignment 5 is top-left, with no automatic wrapping. Preserve
174
+ # the font's baked-in red lettering and white outline by using white RGB.
175
+ layout = self.text.layout(NOTICE_FONT, engine.notice, 2**31, NOTICE_STYLE)
176
+ for index, glyph in enumerate(layout.glyphs):
177
+ image = self.text._glyph_image(NOTICE_FONT, glyph, motion.scale)
178
+ if motion.alpha < 255:
179
+ image = image.copy()
180
+ image.set_alpha(motion.alpha)
181
+ self.canvas.blit(image, (round(x + glyph.x * motion.scale),
182
+ round(y + glyph.y * motion.scale - motion.glyph_rise(index))))
183
+
184
+ @lru_cache(maxsize=4)
185
+ def continue_tab(self, theme):
186
+ # GameModel::setBubbleSex chooses the supplied skin's frames 3–6.
187
+ tab = self.frame(126, {1: 5, 2: 3, 3: 6}.get(theme, 4)).copy()
188
+ font = 'TrebuchetMS_Bold14'
189
+ metrics = self.text.font(font)
190
+ height = getattr(metrics, 'cap_height', 14)
191
+ label = self.text.layout(font, MenuStrings.load(self.resources)[29], tab.get_width(), TextStyle(height))
192
+ left, top, right, bottom = label.ink_bounds
193
+ self.text.draw_layout(tab, font, label, (tab.get_width() - right - left) / 2,
194
+ (tab.get_height() - bottom - top) / 2)
195
+ return tab
196
+
197
+ def draw_continue(self, box, theme, motion):
198
+ scale = motion.continue_scale
199
+ if scale <= 0:
200
+ return
201
+ tab = self.continue_tab(theme)
202
+ width, height = tab.get_size()
203
+ if scale < 1:
204
+ tab = pygame.transform.smoothscale(tab, (width, max(1, round(height * scale))))
205
+ # expandContinue: center = (bubble.width - 49.5, bubble.height + 8.5).
206
+ self.canvas.blit(tab, (box.x + box.width - width,
207
+ box.y + box.height + round((height - tab.get_height()) / 2)))
208
+
209
+ @lru_cache(maxsize=1)
210
+ def footer_hint(self):
211
+ # GameModel::init / layout 18 node 2: ArialMT 11, white alpha 0xb0,
212
+ # centered in the 252x18 tooltip region below the story panel.
213
+ strings = MenuStrings.load(getattr(self.resources, 'library', self.resources))
214
+ name = 'ArialMT11'
215
+ label = self.text.layout(name, strings[36], 252, TextStyle(11, 0))
216
+ layer = pygame.Surface((252, 18), pygame.SRCALPHA).convert_alpha()
217
+ left, top, right, bottom = label.ink_bounds
218
+ self.text.draw_layout(layer, name, label,
219
+ (252 - (right - left)) / 2 - left,
220
+ (18 - (bottom - top)) / 2 - top)
221
+ layer.set_alpha(176)
222
+ return layer
223
+
224
+ def draw(self, target, session):
225
+ details = session.pending.details
226
+ key = id(session), session.scene, session.vm.steps_executed, details['page_start']
227
+ if key != self.cached_key:
228
+ self.cached_page = session.dialogue_page()
229
+ self.cached_key = key
230
+ self.name_layer.fill((0, 0, 0, 0))
231
+ if self.cached_page is not None:
232
+ page = self.cached_page
233
+ self.text.draw_layout(self.name_layer, page.name_font, page.name,
234
+ *page.name_origin, scale=page.name_scale)
235
+ page = self.cached_page
236
+ if page is None:
237
+ raise ContentError('Dialogue requires the imported APK layout and fonts')
238
+ self.canvas.fill((0, 0, 0))
239
+ self.draw_background(self.canvas, session.engine)
240
+ motion = session.engine.dialogue_animation
241
+ angle = motion.box_rotation
242
+ if angle or motion.box_scale != 1:
243
+ from .ui_assets import Rect
244
+ box = page.box
245
+ layer = pygame.Surface((box.width + 20, box.height + 20), pygame.SRCALPHA).convert_alpha()
246
+ self.box(layer, Rect(10, 10, box.width, box.height), details['theme'])
247
+ layer = pygame.transform.rotozoom(layer, -angle, motion.box_scale)
248
+ layer.set_alpha(motion.box_alpha)
249
+ self.canvas.blit(layer, layer.get_rect(center=box.center))
250
+ else:
251
+ origin = page.portrait
252
+ if origin is None and motion.anchor_mode:
253
+ origin = self.resources.dialogue_layout().bank.rectangle(17, 0x30 if motion.anchor_mode == 1 else 0x4e)
254
+ self.box(self.canvas, motion.box_rect(page.box, origin), details['theme'], alpha=motion.box_alpha)
255
+ self.draw_portrait(motion.previous, motion.previous_scale)
256
+ self.draw_portrait(motion.portrait, motion.portrait_scale,
257
+ box=page.box if motion.housing_clipped else None,
258
+ old=motion.initial_portrait, alphas=motion.expression_alphas)
259
+ self.draw_relationship(motion)
260
+ self.draw_notice(session.engine)
261
+ self.text.draw_layout(self.canvas, page.body_font, page.body, *page.body_origin,
262
+ source_end=motion.revealed)
263
+ self.name_layer.set_alpha(motion.name_alpha)
264
+ self.canvas.blit(self.name_layer, (0, 0))
265
+ self.draw_continue(page.box, details['theme'], motion)
266
+ # Layout 18's footer and its original instruction, separate from the
267
+ # animated Continue tab attached to the dialogue box.
268
+ self.canvas.blit(self.frame(126, 47), (0, 431))
269
+ self.canvas.blit(self.footer_hint(), (63, 459))
270
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
271
+ return page
@@ -0,0 +1,176 @@
1
+ """Services 17/40's APK layouts, bitmap labels, cursor and input alert."""
2
+ from dataclasses import replace
3
+ from functools import lru_cache
4
+
5
+ from . import graphics as pygame
6
+
7
+ from .content import ContentError
8
+ from .fonts import TextStyle, layout_label
9
+ from .menu import MenuStrings
10
+ from .text_input import CURSOR_FONT, NAME_FONT
11
+ from .ui_assets import Rect, read_ui
12
+
13
+
14
+ BLUE = (41, 104, 221)
15
+ INPUT_RECT = (30, 177, 260, 40)
16
+ ALERT_RECT = (20, 160, 280, 160)
17
+
18
+
19
+ class InputRenderer:
20
+ def __init__(self, resources, text_renderer, artwork):
21
+ self.resources, self.text, self.art = resources, text_renderer, artwork
22
+ self.canvas = pygame.Surface((320, 480)).convert(32)
23
+
24
+ @lru_cache(maxsize=1)
25
+ def strings(self):
26
+ return MenuStrings.load(self.resources.library)
27
+
28
+ @lru_cache(maxsize=3)
29
+ def layout_image(self, index, width, height):
30
+ layer = pygame.Surface((width, height), pygame.SRCALPHA).convert_alpha()
31
+ bank = self.resources.dialogue_layout().bank
32
+ hidden_until = 0
33
+ for number, (node, rect) in enumerate(bank.walk(index, Rect(0, 0, width, height)), 1):
34
+ if number <= hidden_until:
35
+ continue
36
+ visible = node.flags & 16 and not (index == 46 and number in (1, 10))
37
+ # Visibility belongs to reference roots as well as images. The
38
+ # native layout-43 footer has a hidden button subtree, and
39
+ # 000d4658 explicitly hides layout-46 roots 1 and 10.
40
+ if node.kind == 7 and not visible:
41
+ hidden_until = number + sum(1 for _ in bank.walk(node.payload[0]))
42
+ continue
43
+ if node.kind == 1 and visible and rect.width > 0 and rect.height > 0:
44
+ slot, frame = node.payload
45
+ image = self.art.frame({0: 126, 2: 204, 3: 16}[slot], frame)
46
+ layer.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
47
+ return layer
48
+
49
+ @lru_cache(maxsize=32)
50
+ def title(self, text):
51
+ name = 'ArialRoundedMTBold30'
52
+ font = self.text.font(name)
53
+ # 00165c14 rebuilds this label with BMFont's cumulative kerning when
54
+ # 000d4658 changes its anchor to (1/2,1). Other labels use 0004dc20.
55
+ layout = self.text.layout(name, text, 2**31, TextStyle(font.line_height, 0, BLUE))
56
+ glyphs, widths = [], []
57
+ placed = {g.index: g for g in layout.glyphs}
58
+ for line in layout.lines:
59
+ previous, width = -1, 0
60
+ for i in range(line.start, line.end):
61
+ code = ord(text[i])
62
+ adjustment = font.kernings.get((previous & 0xffff, code), 0)
63
+ if i in placed:
64
+ g = placed[i]
65
+ glyphs.append(replace(g, x=width + adjustment + g.glyph.xoffset))
66
+ width += font.glyph(text[i]).advance + adjustment
67
+ previous = code if code < 128 else code - 256
68
+ widths.append(width)
69
+ max_width = self.resources.dialogue_layout().bank.rectangle(46, 18).width
70
+ scale = min(1., max_width / layout.width) if layout.width else 1.
71
+ return replace(layout, glyphs=tuple(glyphs)), max(widths, default=0), scale
72
+
73
+ def _image(self, asset):
74
+ image = self.art.ui_image(asset)
75
+ if image is None:
76
+ raise ContentError(f'Name-entry artwork {asset} is missing')
77
+ return image
78
+
79
+ def alert(self):
80
+ # 707 is the alert's clickable panel, not the ordinary input screen.
81
+ ios = getattr(self.resources.library, 'kind', 'apk') == 'ipa'
82
+ if ios:
83
+ # The IPA uses UIKit's UIAlertView, with no exported panel image.
84
+ # Use the runtime's existing message skin for the desktop modal.
85
+ self.art.box(self.canvas, Rect(30, 170, 260, 140), 1)
86
+ else:
87
+ self.canvas.blit(self._image(707), ALERT_RECT[:2])
88
+ strings = self.strings()
89
+ for caption, origin, color in ((strings[258], (130, 169), (255, 255, 255)),
90
+ (strings[164], (140, 275), (0, 0, 0))):
91
+ label = self.text.layout('ArialRoundedMTBold20', caption, 2**31, TextStyle(16, -16, BLUE if ios else color))
92
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold20', label, *origin)
93
+ label = layout_label(self.text.font('ArialRoundedMTBold16'), strings[260],
94
+ 280, 160, TextStyle(16, 2, BLUE if ios else (255, 255, 255)), 0x16)
95
+ # GL node (160,375), size (280,160), anchor (1/2,1/2).
96
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold16', label, 20, 185)
97
+ return [(pygame.Rect(ALERT_RECT), ('input_dismiss',))]
98
+
99
+ def draw_ipa_entry(self, details, cursor_visible):
100
+ """000a0238/000a0880: layout 46 supplies the entire entry panel."""
101
+ bank = self.resources.dialogue_layout().bank
102
+ prompt_rect = bank.rectangle(46, 23)
103
+ prompt = self.text.layout('ArialRoundedMTBold16', details['prompt'], prompt_rect.width,
104
+ TextStyle(16, 10, BLUE))
105
+ height = max(1, round(prompt.height))
106
+ bounds = Rect((320 - bank.layouts[46].width) // 2, 151 - height,
107
+ bank.layouts[46].width, bank.layouts[46].height + height - prompt_rect.height)
108
+ title_rect = bank.rectangle(46, 18, bounds)
109
+ title = self.text.layout('ArialRoundedMTBold30', details['title'], title_rect.width,
110
+ TextStyle(30, 0, BLUE))
111
+ active_header = 1 if title.height > title_rect.height else 10
112
+ hidden_until = 0
113
+ for number, (node, rect) in enumerate(bank.walk(46, bounds), 1):
114
+ if number <= hidden_until:
115
+ continue
116
+ visible = node.flags & 16 and (number not in (1, 10) or number == active_header)
117
+ if node.kind == 7 and not visible:
118
+ hidden_until = number + sum(1 for _ in bank.walk(node.payload[0]))
119
+ continue
120
+ if node.kind == 1 and visible and rect.width > 0 and rect.height > 0:
121
+ slot, frame = node.payload
122
+ image = self.art.frame({0: 126, 2: 204, 3: 16}[slot], frame)
123
+ self.canvas.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
124
+ title_y = bank.rectangle(46, 9 if active_header == 1 else 18, bounds).y
125
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold30', title,
126
+ title_rect.x + (title_rect.width - title.width) / 2, title_y)
127
+ prompt_rect = bank.rectangle(46, 23, bounds)
128
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold16', prompt, prompt_rect.x, prompt_rect.y)
129
+ entry = bank.rectangle(46, 44, bounds)
130
+ draft = details.get('draft', details['default'])
131
+ entered = self.text.layout('PajamaHip24', draft, 2**31, TextStyle(24))
132
+ self.text.draw_layout(self.canvas, 'PajamaHip24', entered, entry.x, entry.y)
133
+ if cursor_visible:
134
+ cursor = self.text.layout('PajamaHip24', '|', 2**31, TextStyle(24))
135
+ self.text.draw_layout(self.canvas, 'PajamaHip24', cursor, entry.x + entered.width, entry.y)
136
+ return [(pygame.Rect(entry.x, entry.y, entry.width, entry.height), ('input_focus',))]
137
+
138
+ def draw(self, target, session, *, error=None, cursor_visible=True):
139
+ self.canvas.fill((0, 0, 0))
140
+ self.art.draw_background(self.canvas, session.engine)
141
+ if error:
142
+ buttons = self.alert()
143
+ elif getattr(self.resources.library, 'kind', 'apk') == 'ipa':
144
+ buttons = self.draw_ipa_entry(session.pending.details, cursor_visible)
145
+ else:
146
+ bank = self.resources.dialogue_layout().bank
147
+ width = bank.layouts[46].width + 22
148
+ footer = bank.layouts[43]
149
+ # GL centers (160,415), (160,267), (160,340), in z order.
150
+ self.canvas.blit(self.layout_image(15, width, 50), (160 - width // 2, 40))
151
+ self.canvas.blit(self.layout_image(43, footer.width, footer.height),
152
+ (160 - footer.width // 2, 213 - footer.height // 2))
153
+ self.canvas.blit(self.layout_image(46, width, 156), (160 - width // 2, 62))
154
+ details = session.pending.details
155
+ title, title_width, scale = self.title(details['title'])
156
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold30', title,
157
+ 160 - title_width * scale / 2, 65, scale=scale)
158
+ prompt = self.text.layout('ArialRoundedMTBold16', details['prompt'], 2**31,
159
+ TextStyle(16, -16, BLUE))
160
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold16', prompt, 30, 120)
161
+ shadow = self._image(706)
162
+ self.canvas.blit(shadow, shadow.get_rect(center=(160, 197)))
163
+ draft = details.get('draft', details['default'])
164
+ entered = self.text.layout(NAME_FONT, draft, 2**31, TextStyle(26, -26, BLUE))
165
+ self.text.draw_layout(self.canvas, NAME_FONT, entered, 35, 190)
166
+ if cursor_visible:
167
+ cursor = self.text.layout(CURSOR_FONT, '|', 2**31, TextStyle(26, -26, BLUE))
168
+ # 000858cc creates '|' before setting nominal height 26.
169
+ # Unlike the name, the cursor is never assigned text again;
170
+ # its glyph keeps the initial zero-height line origin.
171
+ self.text.draw_layout(self.canvas, CURSOR_FONT, cursor, entered.width + 33, 214 - 26)
172
+ # Native confirmation is keyboard Return. No portrait-picker
173
+ # checkmark or common gear is attached by this panel's setup.
174
+ buttons = [(pygame.Rect(INPUT_RECT), ('input_focus',))]
175
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
176
+ return buttons