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.
@@ -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