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,105 @@
1
+ """Shared native system-menu skin for an episode's pause menu."""
2
+ from . import graphics as pygame
3
+ from .fonts import TextStyle
4
+ from .menu import MenuStrings
5
+ from .ui_assets import LayoutBank, Rect, read_ui
6
+
7
+
8
+ PAUSE_OPEN_MS = 400
9
+
10
+
11
+ def pause_scale(elapsed_ms):
12
+ """Native system-menu zoom: 0.001 -> 1, UIKit ease-in/out, 400ms."""
13
+ progress = max(0, min(1, elapsed_ms / PAUSE_OPEN_MS))
14
+ if progress in (0, 1):
15
+ return .001 if progress == 0 else 1.
16
+ # Invert the time axis of cubic-bezier(.42, 0, .58, 1).
17
+ low, high = 0., 1.
18
+ for _ in range(20):
19
+ t = (low + high) / 2
20
+ x = 3 * (1 - t)**2 * t * .42 + 3 * (1 - t) * t*t * .58 + t**3
21
+ if x < progress:
22
+ low = t
23
+ else:
24
+ high = t
25
+ eased = 3 * (1 - t) * t*t + t**3
26
+ return .001 + .999 * eased
27
+
28
+
29
+ def draw_pause_gear(target, art, *, scale=1):
30
+ image = art.frame(126, 50 if getattr(art, 'gear_pressed', False) else 49)
31
+ if scale != 1:
32
+ image = pygame.transform.smoothscale(image, (round(image.get_width() * scale),
33
+ round(image.get_height() * scale)))
34
+ target.blit(image, (0, round(408 * scale)))
35
+
36
+
37
+ class PauseRenderer:
38
+ def __init__(self, library, text, art):
39
+ self.bank = LayoutBank.parse(read_ui(library, 14))
40
+ self.strings = MenuStrings.load(library)
41
+ self.text, self.art = text, art
42
+ self.canvas = pygame.Surface((320, 480), pygame.SRCALPHA).convert_alpha()
43
+ self.dimmer = self.canvas.copy()
44
+ self.dimmer.fill((0, 0, 0, 140))
45
+
46
+ def label(self, text, rect, name, size, *, center=False):
47
+ layout = self.text.layout(name, text, rect.width, TextStyle(size, 0, (41, 104, 221)))
48
+ left, top, right, bottom = layout.ink_bounds
49
+ x = rect.x + (rect.width - (right - left)) / 2 - left if center else rect.x
50
+ self.text.draw_layout(self.canvas, name, layout, x,
51
+ rect.y + (rect.height - (bottom - top)) / 2 - top)
52
+
53
+ def layout(self, index, bounds, *, frames=None):
54
+ for node, rect in self.bank.walk(index, bounds):
55
+ if node.kind != 1 or rect.width <= 0 or rect.height <= 0:
56
+ continue
57
+ slot, frame = node.payload
58
+ if frames is not None and frame not in frames:
59
+ continue
60
+ image = self.art.frame({0: 126, 3: 16}[slot], frame)
61
+ self.canvas.blit(pygame.transform.scale(image, (rect.width, rect.height)), (rect.x, rect.y))
62
+
63
+ def draw(self, target, *, application=True, main_menu=True, pressed=None,
64
+ elapsed_ms=PAUSE_OPEN_MS):
65
+ entries = [(self.strings[21], ('resume',))]
66
+ if application:
67
+ entries += [(self.strings[11], ('pause_page', 'options')),
68
+ (self.strings[128], ('pause_page', 'help'))]
69
+ else:
70
+ # The standalone story viewer has no application settings pages.
71
+ entries += [('Save', ('save',)), ('Load', ('load',))]
72
+ if main_menu:
73
+ entries.append((self.strings[10], ('main_menu',)))
74
+ # SHSWidgetMenu::solveMetrics centers the system menu in 480 pixels;
75
+ # UIKit heightForRowAtIndexPath returns 44 for these ordinary rows.
76
+ header, footer = self.bank.layouts[33].height, self.bank.layouts[36].height
77
+ top = (480 - header - 44 * len(entries) - footer) // 2
78
+ self.canvas.fill((0, 0, 0, 0))
79
+ self.layout(33, Rect(9, top, 302, header))
80
+ title = self.bank.rectangle(33, 2, Rect(9, top, 302, header))
81
+ self.label(self.strings[9], title, 'ArialRoundedMTBold28', 28, center=True)
82
+ buttons = []
83
+ for index, (label, command) in enumerate(entries):
84
+ y = top + header + index * 44
85
+ row = Rect(9, y, 302, 44)
86
+ # Native table cells occupy the 296px interior. Stretching their
87
+ # art to the 302px outer box painted over its three-pixel borders.
88
+ color = ((170, 225, 249, 243) if pressed == command else
89
+ (255, 242, 255, 239) if index % 2 else (239, 239, 249, 239))
90
+ pygame.draw.rect(self.canvas, color, (12, y, 296, 44))
91
+ edge = self.art.frame(16, 74)
92
+ for x, source in ((9, 0), (308, 299)):
93
+ border = edge.subsurface((source, 0, 3, 1))
94
+ self.canvas.blit(pygame.transform.scale(border, (3, 44)), (x, y))
95
+ image = self.art.frame(16, 75)
96
+ self.canvas.blit(pygame.transform.scale(image, (296, 1)), (12, y))
97
+ self.label(label, Rect(27, y + 3, 266, 38), 'ArialRoundedMTBold14', 14)
98
+ buttons.append((pygame.Rect(row.x, row.y, row.width, row.height), command))
99
+ self.layout(36, Rect(9, top + header + 44 * len(entries), 302, footer), frames={74, 79})
100
+ target.blit(pygame.transform.smoothscale(self.dimmer, target.get_size()), (0, 0))
101
+ scale = pause_scale(elapsed_ms)
102
+ size = tuple(max(1, round(length * scale)) for length in target.get_size())
103
+ image = pygame.transform.smoothscale(self.canvas, size)
104
+ target.blit(image, image.get_rect(center=target.get_rect().center))
105
+ return buttons if elapsed_ms >= PAUSE_OPEN_MS else []
@@ -0,0 +1,68 @@
1
+ """Original service-78 portrait layout, artwork, and confirmation control."""
2
+ from functools import lru_cache
3
+
4
+ from . import graphics as pygame
5
+ from .desktop_pause import draw_pause_gear
6
+
7
+ from .fonts import TextStyle
8
+ from .menu import MenuStrings
9
+ from .ui_assets import Rect, read_ui
10
+
11
+
12
+ class CharacterPickerRenderer:
13
+ def __init__(self, resources, text_renderer, artwork):
14
+ self.resources, self.text, self.art = resources, text_renderer, artwork
15
+ self.canvas = pygame.Surface((320, 480)).convert(32)
16
+ self.hint = None
17
+
18
+ @lru_cache(maxsize=32)
19
+ def portrait(self, asset, theme):
20
+ # The selector passes the fifth portrait flag as 1: FUN_0009bd40
21
+ # uses the combined selection ring (43/44), rather than dialogue rings.
22
+ ring = self.art.frame(126, {1: 43, 2: 44}.get(theme, 0))
23
+ pieces = [(ring, ring.get_rect(center=(0, 0)))]
24
+ image = self.art.portrait(asset, False) # Expression zero, portrait mode 1.
25
+ if image is not None:
26
+ pieces.append((image, self.art.portrait_rect(image)))
27
+ bounds = pieces[0][1].unionall([rect for _, rect in pieces[1:]])
28
+ layer = pygame.Surface(bounds.size, pygame.SRCALPHA).convert_alpha()
29
+ for piece, rect in pieces:
30
+ layer.blit(piece, rect.move(-bounds.x, -bounds.y))
31
+ return layer, bounds.topleft
32
+
33
+ def draw(self, target, session):
34
+ picker, engine = session.engine.character_picker, session.engine
35
+ self.canvas.fill((0, 0, 0))
36
+ self.art.draw_background(self.canvas, engine)
37
+ theme = engine.numbers.get(engine.number_key(picker.characters[0], 651), 0)
38
+ # FUN_000d2238/FUN_000d2558 override layout 47's root to this rectangle.
39
+ self.art.box(self.canvas, Rect(0, 120, 320, 240), theme)
40
+ for index, character in enumerate(picker.characters):
41
+ variants = engine.character_art_variants.get(character, [-1])
42
+ color = engine.numbers.get(engine.number_key(character, 651), 0)
43
+ layer, (ox, oy) = self.portrait(variants[0], color)
44
+ motion = picker.portraits[index]
45
+ x, y, scale = motion.pose
46
+ layer = pygame.transform.smoothscale(layer, (max(1, round(layer.get_width() * scale)),
47
+ max(1, round(layer.get_height() * scale))))
48
+ layer.set_alpha(motion.opacity)
49
+ self.canvas.blit(layer, (round(x + ox * scale), round(y + oy * scale)))
50
+ title = self.text.layout('PajamaHip26', session.pending.details['text'], 300,
51
+ TextStyle(16, -16, (255, 255, 255)))
52
+ self.text.draw_layout(self.canvas, 'PajamaHip26', title, 30, 67.5)
53
+ self.canvas.blit(self.art.frame(126, 41), (205, 347))
54
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
55
+ draw_pause_gear(self.canvas, self.art)
56
+ if self.hint is None:
57
+ self.hint = MenuStrings.load(self.resources.library)[37]
58
+ hint = self.text.layout('ArialRoundedMTBold11', self.hint,
59
+ 247, TextStyle(11, 0, (185, 185, 185)))
60
+ self.text.draw_layout(self.canvas, 'ArialRoundedMTBold11', hint, 63 + (252 - hint.width) / 2, 460)
61
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
62
+ return [(pygame.Rect(0, 422, 60, 58), ('menu',))]
63
+
64
+ @staticmethod
65
+ def confirm_hit(point):
66
+ x, y = point
67
+ # FUN_000d2008: the explicit 50x50 area plus the flipped sprite rectangle.
68
+ return (210 <= x <= 260 and 330 <= y <= 380) or (205 <= x <= 275 and 344 <= y <= 389)
@@ -0,0 +1,88 @@
1
+ """Shared atlas drawing for the presentation-independent text layout."""
2
+ from collections import OrderedDict
3
+ from functools import lru_cache
4
+ from io import BytesIO
5
+ from math import floor
6
+
7
+ from . import graphics as pygame
8
+
9
+ from .fonts import FontError, layout_text, load_font
10
+
11
+
12
+ class BitmapTextRenderer:
13
+ def __init__(self, library):
14
+ self.library = library
15
+ self.fonts = {}
16
+ self.glyph_images = OrderedDict()
17
+
18
+ def font(self, name):
19
+ if name not in self.fonts:
20
+ font = load_font(self.library, name)
21
+ try:
22
+ if getattr(self.library, 'kind', 'apk') == 'ipa':
23
+ from .ios_fonts import render_font
24
+ image = render_font(self.library, name)[1]
25
+ atlas = pygame.image.frombytes(image.tobytes(), image.size, 'RGBA')
26
+ else:
27
+ atlas = pygame.image.load(BytesIO(self.library.read_ui_asset('fonts/' + font.page)))
28
+ except pygame.error as error:
29
+ raise FontError(f'Cannot decode font atlas {font.page}: {error}') from error
30
+ width, height = atlas.get_size()
31
+ for glyph in font.glyphs.values():
32
+ rect = (glyph.x, glyph.y, glyph.width, glyph.height)
33
+ if any(v != int(v) for v in rect):
34
+ raise FontError('Fractional font atlas rectangles are not supported yet')
35
+ if glyph.x + glyph.width > width or glyph.y + glyph.height > height:
36
+ raise FontError(f'Glyph {glyph.code} exceeds actual atlas {font.page}')
37
+ self.fonts[name] = font, atlas.convert_alpha()
38
+ return self.fonts[name][0]
39
+
40
+ @lru_cache(maxsize=128)
41
+ def layout(self, name, text, width, style):
42
+ return layout_text(self.font(name), text, width, style)
43
+
44
+ def _glyph_image(self, name, placed, scale):
45
+ glyph = placed.glyph
46
+ key = name, glyph.code, placed.color, scale
47
+ if key not in self.glyph_images:
48
+ atlas = self.fonts[name][1]
49
+ image = atlas.subsurface(pygame.Rect(glyph.x, glyph.y, glyph.width, glyph.height)).copy()
50
+ # Modulate original RGB, retaining colored atlas variants and alpha.
51
+ image.fill((*placed.color, 255), special_flags=pygame.BLEND_RGBA_MULT)
52
+ size = max(1, round(glyph.width * scale)), max(1, round(glyph.height * scale))
53
+ if image.get_size() != size:
54
+ image = pygame.transform.smoothscale(image, size)
55
+ self.glyph_images[key] = image
56
+ if len(self.glyph_images) > 512:
57
+ self.glyph_images.popitem(last=False)
58
+ self.glyph_images.move_to_end(key)
59
+ return self.glyph_images[key]
60
+
61
+ def draw(self, surface, name, text, x, y, width, style, *, scale=1):
62
+ """Draw using the current canvas placement; return the next free Y.
63
+
64
+ Normalize only the block's top ink overhang for the provisional desktop
65
+ panel. Native line positions, offsets, advances and colors stay in the
66
+ pure layout. Complete native panel anchoring is still being recovered.
67
+ """
68
+ if scale <= 0:
69
+ raise FontError('Font display scale must be positive')
70
+ layout = self.layout(name, text, width / scale, style)
71
+ _, top, _, bottom = layout.ink_bounds
72
+ top = min(0, top)
73
+ for placed in layout.glyphs:
74
+ surface.blit(self._glyph_image(name, placed, scale),
75
+ (floor(x + placed.x * scale + .5), floor(y + (placed.y - top) * scale + .5)))
76
+ return y + (max(layout.height, bottom) - top) * scale
77
+
78
+ def draw_layout(self, surface, name, layout, x, y, *, scale=1, source_end=None):
79
+ """Draw a native layout origin without moving its ink overhang."""
80
+ self.font(name)
81
+ for placed in layout.glyphs:
82
+ if source_end is not None and placed.index >= source_end:
83
+ continue
84
+ # Half-to-even rounding snaps adjacent glyph bearings in opposite
85
+ # directions at a half-pixel line origin (notably Continue's i).
86
+ # Use one translation-invariant pixel rule for the whole line.
87
+ surface.blit(self._glyph_image(name, placed, scale),
88
+ (floor(x + placed.x * scale + .5), floor(y + placed.y * scale + .5)))
@@ -0,0 +1,82 @@
1
+ """Original episode intros and week cards, drawn from the player's APK."""
2
+ from functools import lru_cache
3
+
4
+ from . import graphics as pygame
5
+ from .desktop_pause import draw_pause_gear
6
+
7
+ from .atlas import AtlasFont
8
+ from .content import ContentError
9
+ from .fonts import TextStyle, layout_label
10
+ from .menu import MenuStrings
11
+ from .title_screen import title_labels
12
+ from .ui_assets import Rect, read_ui
13
+
14
+
15
+ class TitleRenderer:
16
+ def __init__(self, resources, text_renderer, artwork):
17
+ self.resources, self.text, self.art = resources, text_renderer, artwork
18
+ self.canvas = pygame.Surface((320, 480)).convert(32)
19
+
20
+ @lru_cache(maxsize=2)
21
+ def font(self, asset):
22
+ image = getattr(self.art, 'ui_image', self.art.image)(asset)
23
+ if image is None:
24
+ raise ContentError(f'Title font atlas {asset} is missing')
25
+ return AtlasFont.parse(read_ui(self.resources, asset + 1), Rect(0, 0, *image.get_size()))
26
+
27
+ @lru_cache(maxsize=64)
28
+ def labels(self, title, subtitle):
29
+ return title_labels(self.resources.dialogue_layout().bank, self.font(528), self.font(530),
30
+ title, subtitle)
31
+
32
+ @lru_cache(maxsize=128)
33
+ def label_image(self, label):
34
+ font = self.font(label.asset_id)
35
+ atlas = getattr(self.art, 'ui_image', self.art.image)(label.asset_id)
36
+ if not label.glyphs:
37
+ return None, (0, 0)
38
+ left, top = min(g.x for g in label.glyphs), min(g.y for g in label.glyphs)
39
+ right = max(g.x + font.glyph(g.character)[2] for g in label.glyphs)
40
+ bottom = max(g.y + font.height for g in label.glyphs)
41
+ image = pygame.Surface((max(1, right - left), max(1, bottom - top)), pygame.SRCALPHA).convert_alpha()
42
+ for g in label.glyphs:
43
+ x, y, width = font.glyph(g.character)
44
+ image.blit(atlas, (g.x - left, g.y - top), (x, y, width, font.height))
45
+ return image, (left, top)
46
+
47
+ def draw_label(self, label, scale_y=1.):
48
+ image, (left, top) = self.label_image(label)
49
+ if image is None:
50
+ return
51
+ sx, sy = label.scale, label.scale * scale_y
52
+ size = max(1, round(image.get_width() * sx)), max(1, round(image.get_height() * sy))
53
+ self.canvas.blit(pygame.transform.smoothscale(image, size),
54
+ (round(label.origin[0] + left * sx), round(label.origin[1] + top * sy)))
55
+
56
+ @lru_cache(maxsize=1)
57
+ def hint(self):
58
+ strings = MenuStrings.load(self.resources.library)
59
+ # 0007cbbc / 0007d4c8: font registry 11, zero content size,
60
+ # center/center alignment, GL position (180,25), white text.
61
+ font = self.text.font('ArialMT14')
62
+ return layout_label(font, strings[36], 0, 0, TextStyle(14, 8), 0x0a)
63
+
64
+ def draw(self, target, session):
65
+ details, motion = session.pending.details, session.engine.title_screen
66
+ self.canvas.fill((0, 0, 0))
67
+ background = self.art.image(details['asset_id'])
68
+ if background is not None and motion.background_alpha > 0:
69
+ background = pygame.transform.smoothscale(background, (320, 480))
70
+ background.set_alpha(round(255 * motion.background_alpha))
71
+ self.canvas.blit(background, (0, 0))
72
+ title, subtitle = self.labels(details['title'], details['subtitle'])
73
+ self.canvas.set_clip((0, 0, motion.reveal_width, 480))
74
+ self.draw_label(title)
75
+ self.canvas.set_clip(None)
76
+ self.draw_label(subtitle, motion.subtitle_scale_y)
77
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
78
+ draw_pause_gear(self.canvas, self.art)
79
+ self.text.draw_layout(self.canvas, 'ArialMT14', self.hint(), 180, 455)
80
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
81
+ return [(pygame.Rect(0, 422, 60, 58), ('menu',)),
82
+ (pygame.Rect(0, 0, 320, 480), ('continue',))]
@@ -0,0 +1,79 @@
1
+ """Drawing/platform primitives for the shared presentation code.
2
+
3
+ Desktop uses pygame directly. A mobile host installs its backend before it
4
+ imports the application; menus, panel geometry and event handling stay shared.
5
+ """
6
+ _backend = None
7
+
8
+
9
+ class _MenuMusic:
10
+ """An SDL channel independent of the paused story's streaming music."""
11
+ def __init__(self, mixer):
12
+ self.mixer = mixer
13
+ self.sound = self.channel = None
14
+
15
+ def load(self, stream):
16
+ self.sound = self.mixer.Sound(file=stream)
17
+
18
+ def play(self):
19
+ self.channel = self.sound.play()
20
+ if self.channel is None:
21
+ raise ValueError('No audio channel is available for menu music')
22
+
23
+ def stop(self):
24
+ if self.channel is not None:
25
+ self.channel.stop()
26
+ self.sound = self.channel = None
27
+
28
+ def pause(self):
29
+ if self.channel is not None:
30
+ self.channel.pause()
31
+
32
+ def unpause(self):
33
+ if self.channel is not None:
34
+ self.channel.unpause()
35
+
36
+
37
+ def create_menu_music():
38
+ mixer = __getattr__('mixer')
39
+ return mixer.menu_music if hasattr(mixer, 'menu_music') else _MenuMusic(mixer)
40
+
41
+
42
+ def project_quad(target, image, points):
43
+ """Project a sprite with the shared scanline rule, batching on native hosts.
44
+
45
+ Board-plane quads have horizontal top/bottom edges. Geometry and rounding
46
+ stay here; the platform only draws the resulting textured rows.
47
+ """
48
+ tl, tr, br, bl = points
49
+ top, bottom = round(tl[1]), round(bl[1])
50
+ height = max(1, bottom - top)
51
+ rows = []
52
+ for y in range(height):
53
+ t = (y + .5) / height
54
+ left = tl[0] + (bl[0] - tl[0]) * t
55
+ right = tr[0] + (br[0] - tr[0]) * t
56
+ rows.append((round(left), max(1, round(right - left))))
57
+ batch = getattr(__getattr__('draw'), 'textured_rows', None)
58
+ if batch is not None:
59
+ batch(target, image, top, rows)
60
+ return
61
+ transform = __getattr__('transform')
62
+ source = transform.smoothscale(image, (image.get_width(), height))
63
+ for y, (left, width) in enumerate(rows):
64
+ row = source.subsurface((0, y, source.get_width(), 1))
65
+ target.blit(transform.smoothscale(row, (width, 1)), (left, top + y))
66
+
67
+
68
+ def install(backend):
69
+ global _backend
70
+ previous, _backend = _backend, backend
71
+ return previous
72
+
73
+
74
+ def __getattr__(name):
75
+ global _backend
76
+ if _backend is None:
77
+ import pygame
78
+ _backend = pygame
79
+ return getattr(_backend, name)
@@ -0,0 +1 @@
1
+ """Host adapters. Game behavior and layout live in the shared runtime."""