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,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
|