exp-runtime-engine 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/audio.py +13 -0
- exp_runtime/background.py +58 -0
- exp_runtime/character_picker.py +92 -0
- exp_runtime/choice.py +116 -0
- exp_runtime/choice_hints.py +153 -0
- exp_runtime/content.py +742 -0
- exp_runtime/dialogue.py +151 -0
- exp_runtime/dialogue_animation.py +345 -0
- exp_runtime/dialogue_notice.py +51 -0
- exp_runtime/engine.py +609 -0
- exp_runtime/episode_catalog.py +171 -0
- exp_runtime/game_state.py +9 -0
- exp_runtime/games.py +35 -0
- exp_runtime/ios_fonts.py +228 -0
- exp_runtime/launcher.py +92 -0
- exp_runtime/loading.py +37 -0
- exp_runtime/menu.py +177 -0
- exp_runtime/message_panel.py +36 -0
- exp_runtime/minigames.py +76 -0
- exp_runtime/randomness.py +61 -0
- exp_runtime/rar.py +145 -0
- exp_runtime/relationships.py +131 -0
- exp_runtime/runtime.py +879 -0
- exp_runtime/scene_badge.py +65 -0
- exp_runtime/speaker_names.py +258 -0
- exp_runtime/text_input.py +55 -0
- exp_runtime/title_screen.py +104 -0
- exp_runtime/trace.py +76 -0
- exp_runtime/vm.py +391 -0
- exp_runtime_engine-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_engine-0.5.0.dist-info/RECORD +34 -0
- exp_runtime_engine-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_engine-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_engine-0.5.0.dist-info/top_level.txt +1 -0
exp_runtime/dialogue.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Shared SHS/CoD dialogue skin in the original 320 by 480 coordinates.
|
|
2
|
+
|
|
3
|
+
The v0.1.3 SHS skin is shared by both games. Font adapters supply native
|
|
4
|
+
metrics for bitmap/outline text within this common layout and renderer.
|
|
5
|
+
"""
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
|
|
10
|
+
from .fonts import load_font, TextLayout, TextStyle, layout_text
|
|
11
|
+
from .games import GAMES, game_id
|
|
12
|
+
from .ui_assets import ImagePack, LayoutBank, Rect, read_ui
|
|
13
|
+
from .speaker_names import NAME_FONTS, SpeakerNames, fit_speaker_ink, preview_speaker, speaker_label
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BODY_FONT = 'ArialRoundedMTBold16'
|
|
17
|
+
NARRATOR_FONT = 'TrebuchetMS_Italic16'
|
|
18
|
+
BLUE = (41, 104, 221)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dialogue_styles(theme: int, *, narrator: bool = False, emphasis_theme: int = 1,
|
|
22
|
+
game: str = 'shs'):
|
|
23
|
+
if narrator:
|
|
24
|
+
body_font = NARRATOR_FONT
|
|
25
|
+
body = TextStyle(10, 7, (223, 163, 52), (('`', (125, 67, 0)),))
|
|
26
|
+
else:
|
|
27
|
+
body_font = BODY_FONT
|
|
28
|
+
colors = GAMES[game].DIALOGUE_COLORS
|
|
29
|
+
color = colors.get(theme, colors[-1])
|
|
30
|
+
emphasis = BLUE if emphasis_theme == 2 else (254, 53, 0)
|
|
31
|
+
body = TextStyle(14, 7, color, (('`', emphasis), (';', (1, 76, 215))))
|
|
32
|
+
return NAME_FONTS.get(theme, 'PajamaHip26'), TextStyle(16, -16), body_font, body
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DialoguePage:
|
|
37
|
+
box: Rect # The stretchable center; borders extend outside this rectangle.
|
|
38
|
+
body_origin: tuple[float, float]
|
|
39
|
+
body_font: str
|
|
40
|
+
body: TextLayout
|
|
41
|
+
portrait: Rect | None
|
|
42
|
+
name_font: str
|
|
43
|
+
name: TextLayout
|
|
44
|
+
name_origin: tuple[float, float]
|
|
45
|
+
name_scale: float
|
|
46
|
+
start: int
|
|
47
|
+
end: int
|
|
48
|
+
name_bounds: Rect | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DialogueLayout:
|
|
52
|
+
def __init__(self, resources):
|
|
53
|
+
self.resources = resources
|
|
54
|
+
self.bank = LayoutBank.parse(read_ui(resources, 14))
|
|
55
|
+
|
|
56
|
+
@lru_cache(maxsize=8)
|
|
57
|
+
def font(self, name):
|
|
58
|
+
return load_font(self.resources.library, name)
|
|
59
|
+
|
|
60
|
+
@lru_cache(maxsize=4)
|
|
61
|
+
def border_widths(self, theme):
|
|
62
|
+
skin = {1: 204, 2: 220, 3: 252}.get(theme, 236)
|
|
63
|
+
frames = ImagePack.parse(read_ui(self.resources, skin)).images
|
|
64
|
+
return frames[4].width, frames[5].width
|
|
65
|
+
|
|
66
|
+
def prepare_name(self, details, names: SpeakerNames):
|
|
67
|
+
names.basis = deepcopy(names.fonts)
|
|
68
|
+
label = speaker_label(details['speaker'], details['presentation_mode'], details['theme'],
|
|
69
|
+
self.bank, self.font, names.fonts)
|
|
70
|
+
return label.text
|
|
71
|
+
|
|
72
|
+
def page(self, details, start=0, *, names: SpeakerNames | None = None):
|
|
73
|
+
mode, theme = details['presentation_mode'], details['theme']
|
|
74
|
+
name_font, name_style, body_font, body_style = dialogue_styles(
|
|
75
|
+
theme, narrator=mode == 4, emphasis_theme=details['emphasis_theme'],
|
|
76
|
+
game=game_id(self.resources))
|
|
77
|
+
font = self.font(body_font)
|
|
78
|
+
text = details['text'][start:]
|
|
79
|
+
sizing_text = details['text'] # Page turns keep the original box size.
|
|
80
|
+
portrait = name_bounds = None
|
|
81
|
+
body_width = body_height = None
|
|
82
|
+
if mode == 4:
|
|
83
|
+
name = layout_text(self.font(name_font), '', 1, name_style)
|
|
84
|
+
name_origin, name_scale = (0, 0), .9
|
|
85
|
+
# FUN_000858cc configures state+0xd180 with registry font 5,
|
|
86
|
+
# height 16 and gap 9. Narrator sizing and drawing deliberately
|
|
87
|
+
# use different fonts. This measuring object has no color markers.
|
|
88
|
+
measured = layout_text(self.font(BODY_FONT), sizing_text, 220, TextStyle(16, 9))
|
|
89
|
+
width = max(120, (int(measured.width) + 2) // 2 * 2)
|
|
90
|
+
height = (int(min(200, measured.height)) + 5) // 2 * 2
|
|
91
|
+
box = Rect(25, 115, width + 10, height + 10)
|
|
92
|
+
origin = (30, 120)
|
|
93
|
+
else:
|
|
94
|
+
label = preview_speaker(details, self.bank, self.font, names)
|
|
95
|
+
name, name_origin, name_scale, extra = label.layout, label.origin, label.scale, label.extra
|
|
96
|
+
rect = self.bank.rectangle(17, 8)
|
|
97
|
+
cap_height = getattr(font, 'cap_height', None)
|
|
98
|
+
if cap_height is not None:
|
|
99
|
+
# CSFont uses the face's cap height, not the nominal Cocos
|
|
100
|
+
# label height. Both iOS games set ordinary line gap 10.
|
|
101
|
+
body_style = replace(body_style, height=cap_height, gap=10)
|
|
102
|
+
if mode in (1, 2):
|
|
103
|
+
portrait = self.bank.rectangle(17, 0x30 if mode == 1 else 0x4e)
|
|
104
|
+
insets = (80, 70, 0, 0) if cap_height is not None else (85, 75, 0, 0)
|
|
105
|
+
body_style = replace(body_style, indents=insets if mode == 1 else tuple(-n for n in insets))
|
|
106
|
+
measured = layout_text(font, sizing_text, rect.width, body_style)
|
|
107
|
+
capacity = int((rect.height + body_style.gap) / (body_style.height + body_style.gap))
|
|
108
|
+
count = min(capacity, max(3, len(measured.lines)))
|
|
109
|
+
height = int(count * (body_style.height + body_style.gap) - body_style.gap + 7) if count else 8
|
|
110
|
+
height += height % 2
|
|
111
|
+
box = Rect(rect.x - 7, rect.y - extra, rect.width + 14, height + extra)
|
|
112
|
+
origin = (rect.x - 7, rect.y - extra // 2)
|
|
113
|
+
if cap_height is not None:
|
|
114
|
+
# animateTextLayer (CoD 0003788a / SHS 0007d628) positions
|
|
115
|
+
# text at node 8, independently of the expanded frame. The
|
|
116
|
+
# title's extra 8/40 pixels are not additional body capacity.
|
|
117
|
+
origin = (rect.x, rect.y)
|
|
118
|
+
body_width = rect.width
|
|
119
|
+
body_height = height + int(font.descent) + 3
|
|
120
|
+
body_width = box.width if body_width is None else body_width
|
|
121
|
+
body_height = box.height if body_height is None else body_height
|
|
122
|
+
full = layout_text(font, text, body_width, body_style)
|
|
123
|
+
capacity = max(1, int((body_height + body_style.gap) / (body_style.height + body_style.gap)))
|
|
124
|
+
end = full.lines[capacity].start if len(full.lines) > capacity else len(text)
|
|
125
|
+
body = layout_text(font, text[:end], body_width, body_style)
|
|
126
|
+
if mode != 4:
|
|
127
|
+
# Allow for any glyph becoming the first line on a later page.
|
|
128
|
+
# The whole dialogue supplies one stable name position; neither
|
|
129
|
+
# the native body box nor its byte offsets change for this fit.
|
|
130
|
+
all_body = layout_text(font, sizing_text, body_width, body_style)
|
|
131
|
+
ink_tops = (body_style.height - font.line_height + g.glyph.yoffset
|
|
132
|
+
for g in all_body.glyphs)
|
|
133
|
+
body_top = origin[1] + min(ink_tops, default=0)
|
|
134
|
+
# Bound the visible title to the actual skin, not the viewport.
|
|
135
|
+
# The native title has normal and tall regions. A tall name may
|
|
136
|
+
# straddle the box border; forcing it wholly above the box shrinks
|
|
137
|
+
# multiline titles and discards the purpose of the taller header.
|
|
138
|
+
border_left, border_right = self.border_widths(theme)
|
|
139
|
+
left = max(0, box.x - border_left,
|
|
140
|
+
portrait.x + portrait.width if mode == 1 else 0)
|
|
141
|
+
right = min(320, box.x + box.width + border_right,
|
|
142
|
+
portrait.x if mode == 2 else 320)
|
|
143
|
+
header = label.header or self.bank.rectangle(
|
|
144
|
+
17, ({1: 0x25, 2: 0x43, 3: 0x61} if extra == 40 else
|
|
145
|
+
{1: 0x2e, 2: 0x4c, 3: 0x6a})[mode])
|
|
146
|
+
bottom = min(header.y + header.height, body_top)
|
|
147
|
+
top = max(0, min(header.y, bottom - 1))
|
|
148
|
+
name_bounds = Rect(left, top, right - left, bottom - top)
|
|
149
|
+
name, name_origin, name_scale = fit_speaker_ink(label, name_bounds)
|
|
150
|
+
return DialoguePage(box, origin, body_font, body, portrait, name_font,
|
|
151
|
+
name, name_origin, name_scale, start, start + end, name_bounds)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Saved dialogue presentation clocks, independent of the display.
|
|
2
|
+
|
|
3
|
+
Native evidence and the 30 Hz letter-scheduler policy are documented in
|
|
4
|
+
docs/UI_FIDELITY.md. Portrait/name tweens use elapsed time; drawing is pure.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from math import degrees
|
|
8
|
+
|
|
9
|
+
from .relationships import RelationshipAnimation, RelationshipChange
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LETTER_HZ = 30
|
|
13
|
+
PORTRAIT_MS = 300
|
|
14
|
+
NAME_FADE_MS = 300
|
|
15
|
+
CONTINUE_MS = 250
|
|
16
|
+
BOX_TRAVEL_MS = 120
|
|
17
|
+
BOX_OPEN_MS = 200
|
|
18
|
+
EXPRESSION_MS = 1000
|
|
19
|
+
EXPRESSION_END_MS = 2400
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DialoguePortrait:
|
|
24
|
+
character_id: int
|
|
25
|
+
asset_id: int
|
|
26
|
+
mode: int
|
|
27
|
+
theme: int
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_details(cls, details, variants):
|
|
31
|
+
character = details['visible_character_id']
|
|
32
|
+
if details['presentation_mode'] not in (1, 2) or character < 0:
|
|
33
|
+
return None
|
|
34
|
+
art = variants[character]
|
|
35
|
+
return cls(character, art[details['expression'] % len(art)],
|
|
36
|
+
details['presentation_mode'], details['theme'])
|
|
37
|
+
|
|
38
|
+
def validate(self):
|
|
39
|
+
if self.character_id < 0 or self.asset_id < 0 or self.mode not in (1, 2):
|
|
40
|
+
raise ValueError('Invalid dialogue portrait')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def progress(elapsed, duration):
|
|
44
|
+
return min(1.0, max(0.0, elapsed / duration))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DialogueHistory:
|
|
49
|
+
"""Hidden native portrait position and the separate last-expression bytes."""
|
|
50
|
+
anchor_mode: int = 0
|
|
51
|
+
expressions: dict[int, int] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def validate(self):
|
|
54
|
+
if self.anchor_mode not in (0, 1, 2) or any(
|
|
55
|
+
not 0 <= character <= 32767 or not -128 <= expression <= 127
|
|
56
|
+
for character, expression in self.expressions.items()):
|
|
57
|
+
raise ValueError('Invalid dialogue portrait history')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DialogueAnimation:
|
|
62
|
+
text_length: int
|
|
63
|
+
portrait: DialoguePortrait | None = None
|
|
64
|
+
previous: DialoguePortrait | None = None
|
|
65
|
+
changed: bool = True
|
|
66
|
+
page_turn: bool = False
|
|
67
|
+
elapsed_ms: int = 0
|
|
68
|
+
revealed: int = 0
|
|
69
|
+
complete: bool = False
|
|
70
|
+
finish_requested: bool = False
|
|
71
|
+
finish_step: int = 0
|
|
72
|
+
wobble_direction: int = 0
|
|
73
|
+
relationship: RelationshipAnimation | None = None
|
|
74
|
+
continue_ms: int = 0
|
|
75
|
+
box_grow: bool = False
|
|
76
|
+
native_lifecycle: bool = True
|
|
77
|
+
anchor_mode: int = 0
|
|
78
|
+
initial_portrait: DialoguePortrait | None = None
|
|
79
|
+
initial_expression: int = 0
|
|
80
|
+
expression: int = 0
|
|
81
|
+
presentation_ms: int = 0
|
|
82
|
+
visuals_finished: bool = False
|
|
83
|
+
housing_clipped: bool = False
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def start(cls, details, variants, previous=None, history=None):
|
|
87
|
+
portrait = DialoguePortrait.from_details(details, variants)
|
|
88
|
+
old = previous.displayed_portrait if previous else None
|
|
89
|
+
changed = previous is None or ((old.character_id if old else -1) !=
|
|
90
|
+
(portrait.character_id if portrait else -1))
|
|
91
|
+
motion = cls(len(details['text']) - details['page_start'], portrait,
|
|
92
|
+
old if changed else None, changed,
|
|
93
|
+
wobble_direction=(20 if details['presentation_mode'] == 2 else -20)
|
|
94
|
+
if details.get('box_wobble', False) else 0)
|
|
95
|
+
motion.box_grow = bool(changed and not motion.wobble_direction)
|
|
96
|
+
motion.anchor_mode = portrait.mode if portrait else history.anchor_mode if history else (
|
|
97
|
+
previous.anchor_mode if previous else 0)
|
|
98
|
+
motion.expression = details['expression']
|
|
99
|
+
motion.initial_expression = details.get('initial_expression', details['expression'])
|
|
100
|
+
motion.initial_portrait = DialoguePortrait.from_details(
|
|
101
|
+
dict(details, expression=motion.initial_expression), variants)
|
|
102
|
+
motion.housing_clipped = not motion.box_grow and not motion.wobble_direction
|
|
103
|
+
if history is not None and portrait is not None:
|
|
104
|
+
history.anchor_mode = portrait.mode
|
|
105
|
+
history.expressions[portrait.character_id] = motion.initial_expression
|
|
106
|
+
if details.get('relationship') is not None:
|
|
107
|
+
change = RelationshipChange(**details['relationship'])
|
|
108
|
+
change.validate()
|
|
109
|
+
motion.relationship = RelationshipAnimation(change, motion.portrait_delay_ms + PORTRAIT_MS if changed else 0)
|
|
110
|
+
return motion
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def portrait_delay_ms(self):
|
|
114
|
+
# FUN_000aaa40 waits for the outgoing portrait only on the same side.
|
|
115
|
+
same_side = self.previous and self.portrait and self.previous.mode == self.portrait.mode
|
|
116
|
+
return 250 + (PORTRAIT_MS if same_side else 0)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def name_delay_ms(self):
|
|
120
|
+
duration = PORTRAIT_MS if self.changed and self.portrait else 0
|
|
121
|
+
same_side = self.previous and self.portrait and self.previous.mode == self.portrait.mode
|
|
122
|
+
return 250 + (PORTRAIT_MS if same_side else 0) + duration + 120 + self.relationship_delay_ms
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def relationship_delay_ms(self):
|
|
126
|
+
return self.relationship.change.extra_delay_ms if self.relationship and not self.page_turn else 0
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def text_delay_ms(self):
|
|
130
|
+
if self.page_turn:
|
|
131
|
+
return 350 # FUN_000a9868, next substring.
|
|
132
|
+
delay = self.name_delay_ms - (120 if self.wobble_direction else 0) if self.changed else 250 + self.relationship_delay_ms
|
|
133
|
+
return delay + (400 if self.native_lifecycle and self.wobble_direction else 0)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def shake_start_ms(self):
|
|
137
|
+
# GameModel::shakeDialog: hold opacity at zero for the old 100ms
|
|
138
|
+
# fade interval plus 600ms when the speaker changes.
|
|
139
|
+
return 100 + (600 if self.changed else 0)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def box_alpha(self):
|
|
143
|
+
return 0 if (self.native_lifecycle and self.wobble_direction and not self.visuals_finished
|
|
144
|
+
and self.elapsed_ms < self.shake_start_ms) else 255
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def housing_clip_ms(self):
|
|
148
|
+
return self.shake_start_ms + 250 if self.wobble_direction else self.name_delay_ms + BOX_OPEN_MS
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def box_scale(self):
|
|
152
|
+
if not self.native_lifecycle or not self.wobble_direction or self.visuals_finished:
|
|
153
|
+
return 1.0
|
|
154
|
+
return .5 + .5 * progress(self.elapsed_ms - self.shake_start_ms, 125)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def box_rotation(self):
|
|
158
|
+
if self.visuals_finished:
|
|
159
|
+
return 0.0
|
|
160
|
+
if self.native_lifecycle:
|
|
161
|
+
if not self.wobble_direction:
|
|
162
|
+
return 0.0
|
|
163
|
+
t = progress(self.elapsed_ms - self.shake_start_ms, 250)
|
|
164
|
+
angle = -.25 if self.wobble_direction == 20 else .25
|
|
165
|
+
return degrees(angle * (1 - 2 * t) if t < .75 else -angle / 2 * (1 - t) / .25)
|
|
166
|
+
# FUN_0007c9e8: rotate to the opposite 20-degree angle in 70ms,
|
|
167
|
+
# delay 2ms (right) / 5ms (other), then rotate to zero in 70ms.
|
|
168
|
+
t, angle = self.elapsed_ms, self.wobble_direction
|
|
169
|
+
pause = 2 if angle == 20 else 5
|
|
170
|
+
if t < 70:
|
|
171
|
+
return angle * (1 - 2 * t / 70)
|
|
172
|
+
return -angle * (1 - progress(t - 70 - pause, 70))
|
|
173
|
+
|
|
174
|
+
def box_rect(self, box, portrait):
|
|
175
|
+
"""Native iOS bubble keyframes; layout and text regions stay final-sized.
|
|
176
|
+
|
|
177
|
+
setupAnimationsForTextLayer: 1x1 at the portrait, travel to a
|
|
178
|
+
full-width 20px strip in 120ms, then grow to final height in 200ms.
|
|
179
|
+
The nine border pieces keep their own dimensions throughout.
|
|
180
|
+
"""
|
|
181
|
+
if not self.box_grow or self.page_turn or self.visuals_finished:
|
|
182
|
+
return box
|
|
183
|
+
from .ui_assets import Rect
|
|
184
|
+
elapsed = self.elapsed_ms - (self.name_delay_ms - BOX_TRAVEL_MS)
|
|
185
|
+
if elapsed < BOX_TRAVEL_MS:
|
|
186
|
+
x, y = portrait.center if portrait is not None else (0, 0)
|
|
187
|
+
if portrait is not None:
|
|
188
|
+
y -= 5 # Same shared logical origin as the portrait group.
|
|
189
|
+
t = progress(elapsed, BOX_TRAVEL_MS)
|
|
190
|
+
return Rect(round(x + (box.x - x) * t), round(y + (box.y - y) * t),
|
|
191
|
+
max(1, round(1 + (box.width - 1) * t)), round(1 + 19 * t))
|
|
192
|
+
t = progress(elapsed - BOX_TRAVEL_MS, BOX_OPEN_MS)
|
|
193
|
+
return Rect(box.x, box.y, box.width, round(20 + (box.height - 20) * t))
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def duration_ms(self):
|
|
197
|
+
return max(self.name_delay_ms + NAME_FADE_MS,
|
|
198
|
+
self.shake_start_ms + 250 if self.native_lifecycle and self.wobble_direction else 0,
|
|
199
|
+
self.text_delay_ms + ((self.text_length + 2) * 1000 + LETTER_HZ - 1) // LETTER_HZ)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def portrait_scale(self):
|
|
203
|
+
return progress(self.elapsed_ms - self.portrait_delay_ms, PORTRAIT_MS) if self.changed else 1.0
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def previous_scale(self):
|
|
207
|
+
return 1.0 - progress(self.elapsed_ms, PORTRAIT_MS)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def name_alpha(self):
|
|
211
|
+
if self.visuals_finished:
|
|
212
|
+
return 255
|
|
213
|
+
return int(255 * progress(self.elapsed_ms - self.name_delay_ms, NAME_FADE_MS)) if self.changed else 255
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def displayed_portrait(self):
|
|
217
|
+
# The native cache changes at the tick boundary, before the art fades.
|
|
218
|
+
return self.portrait if self.presentation_ms >= EXPRESSION_MS else self.initial_portrait
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def expression_alphas(self):
|
|
222
|
+
if self.initial_portrait == self.portrait or not self.native_lifecycle:
|
|
223
|
+
return 0, 255
|
|
224
|
+
# changeExpression creates two independent, fill-both opacity tracks:
|
|
225
|
+
# new art 0->1 at +400..600ms, old art 1->0 at +600..1400ms.
|
|
226
|
+
elapsed = self.presentation_ms - EXPRESSION_MS
|
|
227
|
+
return (round(255 * (1 - progress(elapsed - 600, 800))),
|
|
228
|
+
round(255 * progress(elapsed - 400, 200)))
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def continue_scale(self):
|
|
232
|
+
return progress(self.continue_ms, CONTINUE_MS) if self.complete else 0.0
|
|
233
|
+
|
|
234
|
+
def _callbacks(self, elapsed_ms):
|
|
235
|
+
# The native 3 ms selector reveals ONE source index per scheduler
|
|
236
|
+
# update, discarding excess dt. Use its configured 30 fps cadence for
|
|
237
|
+
# letters while allowing smooth portrait motion at any display rate.
|
|
238
|
+
first = max(1, (self.text_delay_ms * LETTER_HZ + 999) // 1000)
|
|
239
|
+
return max(0, elapsed_ms * LETTER_HZ // 1000 - first + 1)
|
|
240
|
+
|
|
241
|
+
def tick(self, elapsed_ms):
|
|
242
|
+
self.presentation_ms = min(EXPRESSION_END_MS, self.presentation_ms + elapsed_ms)
|
|
243
|
+
if (self.native_lifecycle and (self.box_grow or self.wobble_direction)
|
|
244
|
+
and not self.page_turn and not self.visuals_finished and
|
|
245
|
+
self.elapsed_ms + elapsed_ms >= self.housing_clip_ms):
|
|
246
|
+
self.housing_clipped = True
|
|
247
|
+
if self.relationship:
|
|
248
|
+
self.relationship.tick(elapsed_ms)
|
|
249
|
+
end = self.elapsed_ms + elapsed_ms
|
|
250
|
+
old = self._callbacks(self.elapsed_ms)
|
|
251
|
+
self.elapsed_ms = min(self.duration_ms, self.elapsed_ms + elapsed_ms)
|
|
252
|
+
due = self._callbacks(self.elapsed_ms) - old
|
|
253
|
+
if self.complete:
|
|
254
|
+
self.continue_ms = min(CONTINUE_MS, self.continue_ms + elapsed_ms)
|
|
255
|
+
return
|
|
256
|
+
if due <= 0:
|
|
257
|
+
return
|
|
258
|
+
needed = self.text_length + 1 - old
|
|
259
|
+
if self.finish_requested:
|
|
260
|
+
# Native input sets a flag. The next selector visits one index,
|
|
261
|
+
# then a following callback unhides the whole label and completes.
|
|
262
|
+
needed = 1 if self.revealed == self.text_length else 2 - self.finish_step
|
|
263
|
+
if due >= needed:
|
|
264
|
+
self.revealed, self.complete = self.text_length, True
|
|
265
|
+
self.finish_step = 0
|
|
266
|
+
else:
|
|
267
|
+
self.revealed = min(self.text_length, self.revealed + 1)
|
|
268
|
+
self.finish_step = 1
|
|
269
|
+
else:
|
|
270
|
+
self.complete = self.revealed + due > self.text_length
|
|
271
|
+
self.revealed = min(self.text_length, self.revealed + due)
|
|
272
|
+
if self.complete:
|
|
273
|
+
# iOS expandContinue waits for the end of typing, then expands
|
|
274
|
+
# vertically for 250 ms. Account for completion within this tick.
|
|
275
|
+
first = max(1, (self.text_delay_ms * LETTER_HZ + 999) // 1000)
|
|
276
|
+
finished = ((first - 1 + old + needed) * 1000 + LETTER_HZ - 1) // LETTER_HZ
|
|
277
|
+
self.continue_ms = min(CONTINUE_MS, max(0, end - finished))
|
|
278
|
+
|
|
279
|
+
def finish(self):
|
|
280
|
+
self.finish_requested = True
|
|
281
|
+
# touchesBegan stops the bubble and title, but leaves portrait and
|
|
282
|
+
# expression tracks running. Cancelled grow callbacks do not clip.
|
|
283
|
+
if self.native_lifecycle:
|
|
284
|
+
self.visuals_finished = True
|
|
285
|
+
|
|
286
|
+
def next_page(self, length):
|
|
287
|
+
self.wobble_direction = 0
|
|
288
|
+
self.box_grow = False
|
|
289
|
+
self.text_length = length
|
|
290
|
+
self.previous = None
|
|
291
|
+
self.changed, self.page_turn = False, True
|
|
292
|
+
self.elapsed_ms = self.revealed = self.finish_step = 0
|
|
293
|
+
self.continue_ms = 0
|
|
294
|
+
self.complete = self.finish_requested = False
|
|
295
|
+
self.visuals_finished = False
|
|
296
|
+
|
|
297
|
+
def settle(self):
|
|
298
|
+
"""Old saves showed all text; migrate without replaying the entrance."""
|
|
299
|
+
self.elapsed_ms = self.duration_ms
|
|
300
|
+
self.revealed, self.complete = self.text_length, True
|
|
301
|
+
self.continue_ms = CONTINUE_MS
|
|
302
|
+
self.presentation_ms = EXPRESSION_END_MS
|
|
303
|
+
self.housing_clipped = self.native_lifecycle
|
|
304
|
+
if self.relationship:
|
|
305
|
+
self.relationship.settle()
|
|
306
|
+
|
|
307
|
+
def validate(self):
|
|
308
|
+
for portrait in (self.portrait, self.previous, self.initial_portrait):
|
|
309
|
+
if portrait:
|
|
310
|
+
portrait.validate()
|
|
311
|
+
if self.relationship:
|
|
312
|
+
self.relationship.validate()
|
|
313
|
+
if self.portrait is None or self.relationship.change.character_id != self.portrait.character_id:
|
|
314
|
+
raise ValueError('Relationship indicators do not match the portrait')
|
|
315
|
+
callbacks = self._callbacks(self.elapsed_ms)
|
|
316
|
+
if (type(self.box_grow) is not bool
|
|
317
|
+
or (self.box_grow and (not self.changed or self.wobble_direction))
|
|
318
|
+
or self.anchor_mode not in (0, 1, 2)
|
|
319
|
+
or not 0 <= self.presentation_ms <= EXPRESSION_END_MS
|
|
320
|
+
or not -128 <= self.initial_expression <= 32767
|
|
321
|
+
or not 0 <= self.expression <= 32767
|
|
322
|
+
or (self.visuals_finished and not self.finish_requested)
|
|
323
|
+
or (self.native_lifecycle and self.housing_clipped and not self.page_turn
|
|
324
|
+
and (self.box_grow or self.wobble_direction) and self.elapsed_ms < self.housing_clip_ms)
|
|
325
|
+
or ((self.initial_portrait is None) != (self.portrait is None))
|
|
326
|
+
or (self.portrait is not None and (
|
|
327
|
+
self.anchor_mode != self.portrait.mode
|
|
328
|
+
or (self.initial_portrait.character_id, self.initial_portrait.mode, self.initial_portrait.theme) !=
|
|
329
|
+
(self.portrait.character_id, self.portrait.mode, self.portrait.theme)))
|
|
330
|
+
or self.wobble_direction not in (-20, 0, 20)
|
|
331
|
+
or self.text_length < 0 or not 0 <= self.elapsed_ms <= self.duration_ms
|
|
332
|
+
or not 0 <= self.continue_ms <= CONTINUE_MS
|
|
333
|
+
or (not self.complete and self.continue_ms)
|
|
334
|
+
or not 0 <= self.revealed <= self.text_length
|
|
335
|
+
or self.finish_step not in (0, 1)
|
|
336
|
+
or (self.finish_step and not self.finish_requested)
|
|
337
|
+
or (self.complete and self.finish_step)
|
|
338
|
+
or (self.complete and self.revealed != self.text_length)
|
|
339
|
+
or (not self.changed and self.previous is not None)
|
|
340
|
+
or (self.page_turn and self.changed)
|
|
341
|
+
or (callbacks == 0 and (self.revealed or self.complete))
|
|
342
|
+
or (not self.finish_requested and
|
|
343
|
+
(self.revealed != min(self.text_length, callbacks)
|
|
344
|
+
or self.complete != (callbacks > self.text_length)))):
|
|
345
|
+
raise ValueError('Invalid dialogue animation state')
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared service-88 notification motion, in native dialogue coordinates."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from .fonts import TextStyle
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
NOTICE_FONT = 'PajamaHipS26'
|
|
8
|
+
NOTICE_STYLE = TextStyle(16, 5)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def notice_lifetime(text: str) -> int:
|
|
12
|
+
# 0009c814 starts at .03 seconds and increments after every source byte,
|
|
13
|
+
# including spaces. The exit callback then runs a 300 ms fade and move.
|
|
14
|
+
return (len(text) + 1) * 165 + 300 if text else 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class NoticeMotion:
|
|
19
|
+
text: str
|
|
20
|
+
remaining_ms: int
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def elapsed_ms(self):
|
|
24
|
+
return max(0, notice_lifetime(self.text) - self.remaining_ms)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def scale(self):
|
|
28
|
+
return 1.0 if len(self.text) < 19 else .88
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def exit_progress(self):
|
|
32
|
+
return min(1.0, max(0.0, 1 - self.remaining_ms / 300))
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def alpha(self):
|
|
36
|
+
return int(255 * (1 - self.exit_progress)) if self.remaining_ms > 0 else 0
|
|
37
|
+
|
|
38
|
+
def glyph_rise(self, index: int):
|
|
39
|
+
# Child tags count drawn letters, excluding spaces and newlines. All
|
|
40
|
+
# MoveBy actions start together, with progressively longer durations.
|
|
41
|
+
entry = min(1.0, self.elapsed_ms / (30 * (index + 1)))
|
|
42
|
+
# The letter moves inside the scaled label; the exiting label moves
|
|
43
|
+
# inside the unscaled portrait parent (0009c750).
|
|
44
|
+
return 40 * self.scale * entry + 50 * self.exit_progress
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def origin(portrait_rect, mode: int):
|
|
48
|
+
cx, cy = portrait_rect.center
|
|
49
|
+
# 000aaa40 places the portrait at 485-y in GL coordinates. 0009c814
|
|
50
|
+
# attaches the label at (-60,50) on the left, (-260,50) on the right.
|
|
51
|
+
return cx + (-60 if mode == 1 else -260), cy - 5 - 50
|