objectionengine2 1.0.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,1718 @@
1
+ from math import exp
2
+ from string import punctuation
3
+ from collections import Counter
4
+ from random import choice
5
+ from timeit import default_timer as timer
6
+ from os.path import join, exists
7
+ from os import environ, getenv
8
+
9
+ from objectionengine2.gavel_slam import GavelSlamObject
10
+ from objectionengine2.testimony_indicator import TestimonyIndicatorTextObject
11
+
12
+
13
+ from objectionengine2.loading import ASSETS_FOLDER, CHARACTERS_FOLDER
14
+ from objectionengine2.loading import load_character_data, load_music_data
15
+ from objectionengine2.beans.comment import Comment
16
+
17
+ from .MovieKit import (
18
+ Scene,
19
+ SceneObject,
20
+ ImageObject,
21
+ MoveSceneObjectAction,
22
+ SimpleTextObject,
23
+ Director,
24
+ )
25
+
26
+ from .judge_verdict import JudgeVerdictTextObject
27
+
28
+ from .math_helpers import ease_in_out_cubic, ease_in_out_sine, lerp, remap
29
+ from PIL import Image, ImageDraw, ImageFont
30
+ from .parse_tags import (
31
+ BaseDialogueItem,
32
+ DialoguePage,
33
+ DialogueTextChunk,
34
+ DialogueAction,
35
+ DialogueTextLineBreak,
36
+ )
37
+ from .font_tools import get_best_font, get_text_width, split_sentences
38
+ from .font_constants import TEXT_COLORS, FONT_ARRAY, NAMETAG_FONT_ARRAY, TextType
39
+ from typing import Callable, Optional
40
+ from os.path import exists, join
41
+ from shlex import split
42
+ from math import cos, sin, pi
43
+ from random import random
44
+ from pathlib import Path
45
+
46
+ from .cache import models_path
47
+
48
+ from .utils import ensure_assets_are_available
49
+
50
+ try:
51
+ from rich import print
52
+ except:
53
+ pass
54
+
55
+
56
+ ### PARALLAX EFFECT
57
+ # Distance between the benches and witness stand.
58
+ FG_PARALLAX = 155
59
+
60
+ # Width of the foreground determined by the parallax amount.
61
+ # DO NOT MODIFY!
62
+ FG_WIDTH = 256 + FG_PARALLAX + 192 + FG_PARALLAX + 256
63
+
64
+ # Left anchored, so left X should always just be zero
65
+ # DO NOT MODIFY!
66
+ FG_LEFT_X = 0
67
+
68
+ # For right bench, the X should be offset by the width (so that the right-hand
69
+ # side is aligned to the right of the background)
70
+ # DO NOT MODIFY!
71
+ FG_RIGHT_X = 1296 - (FG_WIDTH)
72
+
73
+ # For center, start at the middle then move half the width of the foreground
74
+ # DO NOT MODIFY!
75
+ FG_CENTER_X = (1296 / 2) - (FG_WIDTH / 2)
76
+
77
+ ### PAN PROBABILITY
78
+ PAN_PROBABILITY_STEEPNESS = 10.0
79
+
80
+
81
+ def pan_probability(x: float) -> float:
82
+ return 1.0 / (
83
+ 1 + exp(-PAN_PROBABILITY_STEEPNESS * x + PAN_PROBABILITY_STEEPNESS / 2.0)
84
+ )
85
+
86
+
87
+ SENTIMENT_MODEL_PATH = "cardiffnlp/twitter-roberta-base-sentiment-latest"
88
+
89
+ # Maximum allowed width of a line of text
90
+ MAX_WIDTH = 220
91
+
92
+ # Thanks so much to Smash Highlights on Discord for generating this LUT!
93
+ # I gave it a good few tries, but the results kept being weird...
94
+ # This one looks like it fits footage from the game perfectly, though!
95
+ PAN_LUT = [
96
+ (0.0, 0),
97
+ (0.06666666666666667, 0.01546750087),
98
+ (0.13333333333333333, 0.03049086626),
99
+ (0.2, 0.04597767736),
100
+ (0.26666666666666666, 0.1001428958),
101
+ (0.3333333333333333, 0.1920789403),
102
+ (0.4, 0.2840149847),
103
+ (0.4666666666666667, 0.3841578805),
104
+ (0.5333333333333333, 0.4843007763),
105
+ (0.6, 0.5839802263),
106
+ (0.6666666666666666, 0.6841231221),
107
+ (0.7333333333333333, 0.7842467076),
108
+ (0.8, 0.8766461978),
109
+ (0.8666666666666667, 0.9686015525),
110
+ (0.9333333333333333, 0.9840690534),
111
+ (1.0, 1),
112
+ ]
113
+
114
+
115
+ def courtroom_pan_lut_ease(t: float) -> float:
116
+ v = 0
117
+ if t < 0:
118
+ return 0
119
+ elif t >= 1:
120
+ return 1
121
+ else:
122
+ for i in range(0, len(PAN_LUT) - 1):
123
+ if PAN_LUT[i][0] <= t <= PAN_LUT[i + 1][0]:
124
+ a, b = PAN_LUT[i][1], PAN_LUT[i + 1][1]
125
+ v = remap(PAN_LUT[i][0], PAN_LUT[i + 1][0], a, b, t)
126
+ return v
127
+
128
+
129
+ class NameBox(SceneObject):
130
+ def __init__(self, parent: SceneObject, pos: tuple[int, int, int]):
131
+ super().__init__(parent, "Name Box", pos)
132
+ self.namebox_l = ImageObject(
133
+ parent=self,
134
+ name="Name Box Left",
135
+ pos=(0, 0, 11),
136
+ filepath=join(ASSETS_FOLDER, "textbox", "nametag_left.png"),
137
+ )
138
+ self.namebox_c = ImageObject(
139
+ parent=self,
140
+ name="Name Box Center",
141
+ pos=(1, 0, 11),
142
+ filepath=join(ASSETS_FOLDER, "textbox", "nametag_center.png"),
143
+ )
144
+ self.namebox_r = ImageObject(
145
+ parent=self,
146
+ name="Name Box Right",
147
+ pos=(2, 0, 11),
148
+ filepath=join(ASSETS_FOLDER, "textbox", "nametag_right.png"),
149
+ )
150
+ self.namebox_text = SimpleTextObject(
151
+ parent=self, name="Name Box Text", pos=(4, 0, 12)
152
+ )
153
+ self.set_text("")
154
+ self.use_rtl = False
155
+
156
+ def set_text(self, text: str):
157
+ self.text = text
158
+ self.namebox_text.text = self.text
159
+ font_stuff = get_best_font(text, NAMETAG_FONT_ARRAY)
160
+ self.font = ImageFont.truetype(
161
+ font_stuff["path"], size=font_stuff.get("size", 12)
162
+ )
163
+
164
+ text_offset = font_stuff.get("offset", {}).get(TextType.NAME, (0, 0))
165
+ self.namebox_text.x = 4 + text_offset[0]
166
+ self.namebox_text.y = text_offset[1]
167
+ self.use_rtl = font_stuff.get("rtl", False)
168
+ self.namebox_text.font = self.font
169
+
170
+ def update(self, delta):
171
+ length = int(self.font.getlength(self.text))
172
+ self.namebox_c.width = length + 4
173
+ self.namebox_r.x = 1 + length + 4
174
+
175
+ # For RTL, move the box to the right side
176
+ # 2 for left side of textbox
177
+ # Width of the namebox
178
+ # 2 for right side of namebox
179
+ if self.use_rtl:
180
+ self.x = 256 - (2 + self.namebox_c.width + 2)
181
+ else:
182
+ self.x = 0
183
+
184
+
185
+ class DialogueBox(SceneObject):
186
+ time: float = 0
187
+
188
+ chars_visible: int = 0
189
+ current_char_time: float = 0.0
190
+ max_current_char_time: float = 1 / 30
191
+
192
+ current_wait_time: float = 0.0
193
+ max_wait_time: float = 0.0
194
+
195
+ def __init__(self, parent: SceneObject, director: "AceAttorneyDirector"):
196
+ super().__init__(parent=parent, name="Dialogue Box", pos=(0, 128, 12))
197
+ self.director = director
198
+ self.bg = ImageObject(
199
+ parent=self,
200
+ name="Dialogue Box Background",
201
+ pos=(0, 0, 10),
202
+ filepath=join(ASSETS_FOLDER, "textbox", "mainbox.png"),
203
+ )
204
+ self.namebox = NameBox(parent=self, pos=(1, -11, 0))
205
+ self.arrow = ImageObject(
206
+ parent=self,
207
+ name="Dialogue Box Arrow",
208
+ pos=(256 - 15 - 5, 64 - 15 - 5, 11),
209
+ filepath=join(ASSETS_FOLDER, "textbox", "arrow.gif"),
210
+ )
211
+ self.arrow.visible = False
212
+
213
+ self.page: DialoguePage = None
214
+
215
+ self._font_data = {}
216
+ self.font: ImageFont.ImageFont = None
217
+ self.use_rtl = False
218
+ self.font_size = 16
219
+
220
+ self.chars_visible = 0
221
+ self.current_char_time = 0
222
+
223
+ self.on_complete: Callable[[], None] = None
224
+
225
+ @property
226
+ def font_data(self) -> dict:
227
+ return self._font_data
228
+
229
+ @font_data.setter
230
+ def font_data(self, value: dict):
231
+ self._font_data = value
232
+ self.font = ImageFont.truetype(self._font_data["path"], 16)
233
+ self.use_rtl = self._font_data.get("rtl", False)
234
+
235
+ if self.use_rtl:
236
+ self.arrow.x = 7
237
+ self.arrow.flip_x = True
238
+ else:
239
+ self.arrow.x = 236
240
+ self.arrow.flip_x = False
241
+
242
+ def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
243
+ if self.page is None:
244
+ return
245
+
246
+ x, y = self.x, self.y
247
+ line_no = 0
248
+ x_offset = 234 if self.use_rtl else 0
249
+ for command in self.page.commands:
250
+ if isinstance(command, DialogueTextLineBreak):
251
+ line_no += 1
252
+ x_offset = 234 if self.use_rtl else 0
253
+ elif isinstance(command, DialogueTextChunk):
254
+ text_str = command.text[: command.position]
255
+ drawing_args = {
256
+ "xy": (
257
+ 10 + x + x_offset,
258
+ 5 + y + (self.font_size) * line_no,
259
+ ),
260
+ "text": text_str,
261
+ "fill": (255, 0, 255),
262
+ "anchor": ("r" if self.use_rtl else "l") + "a",
263
+ }
264
+
265
+ if len(command.tags) == 0:
266
+ drawing_args["fill"] = (255, 255, 255)
267
+ else:
268
+ drawing_args["fill"] = TEXT_COLORS.get(
269
+ command.tags[-1], (255, 255, 255)
270
+ )
271
+
272
+ if self.font is not None:
273
+ drawing_args["font"] = self.font
274
+
275
+ ctx.text(**drawing_args)
276
+
277
+ try:
278
+ # TODO: I think the Pillow docs say this isn't actually
279
+ # how you should get the true text length due to kerning,
280
+ # but I'm too tired right now to do it the "right" way
281
+ # and so far it doesn't seem to have broken significantly.
282
+ add_to_x_offset = ctx.textlength(text_str, font=self.font)
283
+ except UnicodeEncodeError:
284
+ add_to_x_offset = self.font.getsize(text_str)[0]
285
+
286
+ x_offset += (add_to_x_offset * -1) if self.use_rtl else add_to_x_offset
287
+
288
+
289
+ class ExclamationObject(ImageObject):
290
+ def __init__(self, parent: SceneObject, director: "AceAttorneyDirector"):
291
+ super().__init__(parent=parent, name="Exclamation Image", pos=(0, 0, 20))
292
+ self.director = director
293
+
294
+ def get_exclamation_path(self, type: str, speaker: str):
295
+ base_name = join(ASSETS_FOLDER, CHARACTERS_FOLDER, speaker, type)
296
+ if exists(f"{base_name}.mp3"):
297
+ return f"{base_name}.mp3"
298
+ elif exists(f"{base_name}.wav"):
299
+ return f"{base_name}.wav"
300
+ return join(ASSETS_FOLDER, "sound", "objection-generic.wav")
301
+
302
+ def play_objection(self, speaker: str):
303
+ self.play_exclamation("objection", speaker)
304
+
305
+ def play_holdit(self, speaker: str):
306
+ self.play_exclamation("holdit", speaker)
307
+
308
+ def play_takethat(self, speaker: str):
309
+ self.play_exclamation("takethat", speaker)
310
+
311
+ def play_exclamation(self, type: str, speaker: str):
312
+ self.set_filepath(
313
+ join(ASSETS_FOLDER, "exclamations", f"{type}.gif"),
314
+ {0.7: lambda: self.set_filepath(None)},
315
+ )
316
+
317
+ audio_path = self.get_exclamation_path(type, speaker)
318
+
319
+ self.director.audio_commands.append(
320
+ {"type": "audio", "path": audio_path, "offset": self.director.time}
321
+ )
322
+
323
+
324
+ class EvidenceObject(ImageObject):
325
+ EVIDENCE_POS_R = 173 # X coord of evidence box on the right
326
+ EVIDENCE_POS_L = 13 # X coord of evidence box on the left
327
+
328
+ def __init__(self, parent: SceneObject, director: "AceAttorneyDirector"):
329
+ super().__init__(parent=parent, name="Evidence Image", pos=(0, 0, 18))
330
+ self.director = director
331
+ self.evidence_bg = ImageObject(
332
+ parent=self,
333
+ name="Evidence BG",
334
+ pos=(self.EVIDENCE_POS_R, 13, 18),
335
+ filepath=join(ASSETS_FOLDER, "evidence", "evidence-bg.png"),
336
+ )
337
+ self.evidence_bg.visible = False
338
+
339
+ self.evidence_container = ImageObject(
340
+ parent=self.evidence_bg,
341
+ name="Evidence BG",
342
+ pos=(3, 3, 19),
343
+ width=64,
344
+ height=64,
345
+ )
346
+
347
+ def make_media_visible(self, side: str, media_path: str):
348
+ if side == "right":
349
+ self.evidence_bg.x = self.EVIDENCE_POS_R
350
+ elif side == "left":
351
+ self.evidence_bg.x = self.EVIDENCE_POS_L
352
+
353
+ self.evidence_bg.visible = True
354
+ self.evidence_container.set_filepath(media_path)
355
+ self.set_filepath(None)
356
+
357
+ def hide_evidence(self):
358
+ self.evidence_bg.visible = False
359
+
360
+ def display_evidence(self, side: str, media_path: str):
361
+ self.evidence_bg.visible = False
362
+ self.set_filepath(
363
+ join(ASSETS_FOLDER, "evidence", f"evidence-in-{side}.gif"),
364
+ {0.3: lambda: self.make_media_visible(side, media_path)},
365
+ )
366
+
367
+ self.director.audio_commands.append(
368
+ {
369
+ "type": "audio",
370
+ "path": join(ASSETS_FOLDER, "sound", "sfx-evidenceshoop.wav"),
371
+ "offset": self.director.time,
372
+ }
373
+ )
374
+
375
+
376
+ class ShakerObject(SceneObject):
377
+ magnitude: float = 0.0
378
+ remaining: float = 0.0
379
+
380
+ def start_shaking(self, magnitude, duration):
381
+ self.magnitude = magnitude
382
+ self.remaining = duration
383
+
384
+ def update(self, delta):
385
+ self.remaining -= delta
386
+ if self.remaining > 0:
387
+ angle = random() * 2 * pi
388
+ x_offset = int(cos(angle) * self.magnitude)
389
+ y_offset = int(sin(angle) * self.magnitude)
390
+ self.set_x(x_offset)
391
+ self.set_y(y_offset)
392
+ else:
393
+ self.remaining = 0
394
+ self.magnitude = 0
395
+ self.set_x(0)
396
+ self.set_y(0)
397
+
398
+
399
+ class ColorOverlayObject(SceneObject):
400
+ color: tuple[int, int, int] = (0, 0, 0)
401
+ remaining: float = 0.0
402
+
403
+ def start_color(self, color, duration):
404
+ self.color = color
405
+ self.remaining = duration
406
+
407
+ def update(self, delta):
408
+ self.remaining -= delta
409
+ if self.remaining < 0:
410
+ self.remaining = 0
411
+
412
+ def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
413
+ if self.remaining > 0:
414
+ ctx.rectangle(xy=(0, 0, img.width, img.height), fill=self.color)
415
+
416
+
417
+ class ActionLinesObject(ImageObject):
418
+ def __init__(
419
+ self,
420
+ parent: "SceneObject" = None,
421
+ name: str = "",
422
+ pos: tuple[int, int, int] = ...,
423
+ width: int = None,
424
+ height: int = None,
425
+ filepath: str = None,
426
+ ):
427
+ super().__init__(
428
+ parent=parent,
429
+ name=name,
430
+ pos=pos,
431
+ width=width,
432
+ height=height,
433
+ filepath=filepath,
434
+ )
435
+ self.move_left = True
436
+
437
+ def update(self, delta):
438
+ super().update(delta)
439
+ distance_to_move = 16
440
+
441
+ if self.move_left:
442
+ self.x -= distance_to_move
443
+ while self.x < -256:
444
+ self.x += 256
445
+ else:
446
+ self.x += distance_to_move
447
+ while self.x > -256:
448
+ self.x -= 256
449
+
450
+
451
+ class AceAttorneyDirector(Director):
452
+ def __init__(self, callbacks: dict = None, fps: float = 30):
453
+ super().__init__(None, fps)
454
+ ensure_assets_are_available()
455
+ self.callbacks = {} if callbacks is None else callbacks
456
+
457
+ self.root = SceneObject(name="Root")
458
+
459
+ self.white_flash = ColorOverlayObject(
460
+ parent=self.root, name="White Flash Overlay", pos=(0, 0, 30)
461
+ )
462
+ self.white_flash.color = (255, 255, 255)
463
+
464
+ self.world_root = SceneObject(
465
+ parent=self.root, name="World Root", pos=(0, 0, 0)
466
+ )
467
+
468
+ self.world_shaker = ShakerObject(
469
+ parent=self.world_root, name="World Shaker", pos=(0, 0, 0)
470
+ )
471
+
472
+ self.judge_shot = ImageObject(
473
+ parent=self.world_shaker,
474
+ name="Judge Background",
475
+ pos=(0, 256, 0),
476
+ filepath=join(ASSETS_FOLDER, "bg", "bg_judge.png"),
477
+ )
478
+
479
+ self.judge = ImageObject(
480
+ parent=self.judge_shot,
481
+ name="Judge",
482
+ pos=(0, 0, 1),
483
+ width=256,
484
+ height=192,
485
+ )
486
+
487
+ self.phoenix_action_lines_shot = SceneObject(
488
+ parent=self.world_shaker,
489
+ name="Phoenix Action Lines Container",
490
+ pos=(0, 512, 0),
491
+ )
492
+
493
+ self.phoenix_action_lines_animator = ActionLinesObject(
494
+ parent=self.phoenix_action_lines_shot,
495
+ name="Phoenix Action Lines",
496
+ pos=(0, 0, 0),
497
+ filepath=join(ASSETS_FOLDER, "bg", "bg_action.png"),
498
+ )
499
+
500
+ self.phoenix_action_lines_character = ImageObject(
501
+ parent=self.phoenix_action_lines_shot,
502
+ name="Phoenix Action Lines Character",
503
+ pos=(0, 0, 1),
504
+ width=256,
505
+ height=192,
506
+ )
507
+
508
+ self.edgeworth_action_lines_shot = SceneObject(
509
+ parent=self.world_shaker,
510
+ name="Edgeworth Action Lines Container",
511
+ pos=(0, 768, 0),
512
+ )
513
+
514
+ self.edgeworth_action_lines_animator = ActionLinesObject(
515
+ parent=self.edgeworth_action_lines_shot,
516
+ name="Edgeworth Action Lines",
517
+ pos=(0, 0, 0),
518
+ filepath=join(ASSETS_FOLDER, "bg", "bg_action.png"),
519
+ )
520
+ self.edgeworth_action_lines_animator.move_left = False
521
+
522
+ self.edgeworth_action_lines_character = ImageObject(
523
+ parent=self.edgeworth_action_lines_shot,
524
+ name="Edgeworth Action Lines Character",
525
+ pos=(0, 0, 1),
526
+ width=256,
527
+ height=192,
528
+ )
529
+
530
+ self.wide_courtroom = ImageObject(
531
+ parent=self.world_shaker,
532
+ name="Background",
533
+ pos=(0, 0, 0),
534
+ filepath=join(ASSETS_FOLDER, "bg", "bg_main.png"),
535
+ )
536
+
537
+ self.foreground = SceneObject(
538
+ parent=self.world_shaker,
539
+ name="Foreground Elements",
540
+ )
541
+
542
+ self.left_bench = ImageObject(
543
+ parent=self.foreground,
544
+ name="Left Bench",
545
+ pos=(0, 0, 2),
546
+ filepath=join(ASSETS_FOLDER, "fg", "pr_bench.png"),
547
+ )
548
+
549
+ self.witness_stand = ImageObject(
550
+ parent=self.foreground,
551
+ name="Witness Stand",
552
+ pos=(256 + FG_PARALLAX, 0, 2),
553
+ width=192,
554
+ height=192,
555
+ filepath=join(ASSETS_FOLDER, "fg", "witness_stand.png"),
556
+ )
557
+
558
+ self.right_bench = ImageObject(
559
+ parent=self.foreground,
560
+ name="Right Bench",
561
+ pos=(256 + FG_PARALLAX + 192 + FG_PARALLAX, 0, 2),
562
+ flip_x=True,
563
+ filepath=join(ASSETS_FOLDER, "fg", "pr_bench.png"),
564
+ )
565
+
566
+ self.phoenix = ImageObject(
567
+ parent=self.left_bench,
568
+ name="Left Character",
569
+ pos=(0, 0, 1),
570
+ width=256,
571
+ height=192,
572
+ )
573
+
574
+ self.edgeworth = ImageObject(
575
+ parent=self.right_bench,
576
+ name="Right Character",
577
+ pos=(0, 0, 1),
578
+ width=256,
579
+ height=192,
580
+ )
581
+
582
+ self.witness = ImageObject(
583
+ parent=self.witness_stand,
584
+ name="Witness",
585
+ pos=(-31, 0, 1),
586
+ width=256,
587
+ height=192,
588
+ )
589
+
590
+ self.textbox_shaker = ShakerObject(
591
+ parent=self.root, name="Text Box Shaker", pos=(0, 0, 0)
592
+ )
593
+
594
+ self.exclamation = ExclamationObject(parent=self.root, director=self)
595
+
596
+ self.textbox = DialogueBox(parent=self.textbox_shaker, director=self)
597
+
598
+ self.evidence = EvidenceObject(parent=self.textbox_shaker, director=self)
599
+
600
+ self.judge_verdict = JudgeVerdictTextObject(
601
+ parent=self.root, name="Judge Verdict"
602
+ )
603
+
604
+ self.testimony_indicator = TestimonyIndicatorTextObject(
605
+ parent=self.root, name="Testimony Indicator"
606
+ )
607
+
608
+ self.gavel_slam = GavelSlamObject(
609
+ parent=self.world_shaker,
610
+ name="Gavel Slam",
611
+ pos=(560, 256, 0)
612
+ )
613
+
614
+ self.scene = Scene(width=256, height=192, root=self.root)
615
+
616
+ if "on_director_initialized" in self.callbacks:
617
+ self.callbacks["on_director_initialized"]()
618
+
619
+ def set_current_pages(self, pages: list[DialoguePage]):
620
+ self.pages = pages
621
+ self.page_index = 0
622
+ self.page_start_time = timer()
623
+ self.time_on_this_page = 0
624
+ self.local_time = 0
625
+ self.cur_time_for_char = 0.0
626
+
627
+ self.current_page = self.pages[self.page_index]
628
+ self.textbox.page = self.current_page
629
+ self.textbox.font_data = get_best_font(
630
+ self.current_page.get_raw_text(), FONT_ARRAY
631
+ )
632
+
633
+ cur_time_for_char: float = 0.0
634
+ max_time_for_char: float = 0.03
635
+
636
+ def update(self, delta: float):
637
+ # Within that page, get the current object
638
+ self.time_on_this_page += delta
639
+
640
+ while True:
641
+ current_dialogue_obj = self.current_page.get_current_item()
642
+ if isinstance(current_dialogue_obj, DialogueTextChunk):
643
+ self.cur_time_for_char += delta
644
+ while self.cur_time_for_char > self.max_time_for_char:
645
+ # Increment the progress through the current dialogue object
646
+ # by one. This will make it render more characters in render()
647
+ current_dialogue_obj.position += 1
648
+ self.cur_time_for_char -= self.max_time_for_char
649
+ if current_dialogue_obj.position >= len(current_dialogue_obj.text):
650
+ current_dialogue_obj.completed = True
651
+ break
652
+
653
+ elif isinstance(current_dialogue_obj, DialogueAction):
654
+ # Handle actions
655
+ # Most actions can be taken care of instantly and then marked complete
656
+ # Wait actions need the timer to fill up before they can be marked complete
657
+ action_split = split(current_dialogue_obj.name)
658
+
659
+ try:
660
+ c = action_split[0]
661
+ if c == "startblip":
662
+ voice_type = action_split[1]
663
+ self.start_voice_blips(voice_type)
664
+ current_dialogue_obj.completed = True
665
+
666
+ elif c == "stopblip":
667
+ self.end_voice_blips()
668
+ current_dialogue_obj.completed = True
669
+
670
+ elif c == "sprite":
671
+ position = action_split[1]
672
+ path = " ".join(action_split[2:])
673
+
674
+ if not exists(path):
675
+ print(f"Error in sprite command \"{action_split}\": sprite \"{path}\" doesn't exist")
676
+ if position == "left":
677
+ self.phoenix.set_filepath(path)
678
+ elif position == "right":
679
+ self.edgeworth.set_filepath(path)
680
+ elif position == "center":
681
+ self.witness.set_filepath(path)
682
+ elif position == "judge":
683
+ self.judge.set_filepath(path)
684
+ elif position == "leftzoom":
685
+ self.phoenix_action_lines_character.set_filepath(path)
686
+ elif position == "rightzoom":
687
+ self.edgeworth_action_lines_character.set_filepath(path)
688
+ else:
689
+ print(
690
+ f'Error in sprite command "{action_split}": unknown position "{position}"'
691
+ )
692
+ current_dialogue_obj.completed = True
693
+
694
+ elif c == "wait":
695
+ duration_str = action_split[1]
696
+ self.cur_time_for_char += delta
697
+ if self.cur_time_for_char >= float(duration_str):
698
+ current_dialogue_obj.completed = True
699
+ self.cur_time_for_char = 0.0
700
+ else:
701
+ break
702
+
703
+ elif c == "bubble":
704
+ exclamation_type = action_split[1]
705
+ character = action_split[2]
706
+ self.exclamation.play_exclamation(exclamation_type, character)
707
+ current_dialogue_obj.completed = True
708
+
709
+ elif c == "deskslam":
710
+ character = action_split[1]
711
+ if character == "phoenix":
712
+ self.play_phoenix_desk_slam()
713
+ elif character == "edgeworth":
714
+ self.play_edgeworth_desk_slam()
715
+ current_dialogue_obj.completed = True
716
+
717
+ elif c == "showarrow":
718
+ self.textbox.arrow.visible = True
719
+ current_dialogue_obj.completed = True
720
+
721
+ elif c == "hidearrow":
722
+ self.textbox.arrow.visible = False
723
+ current_dialogue_obj.completed = True
724
+
725
+ elif c == "showbox":
726
+ self.textbox.show()
727
+ current_dialogue_obj.completed = True
728
+
729
+ elif c == "hidebox":
730
+ self.textbox.hide()
731
+ current_dialogue_obj.completed = True
732
+
733
+ elif c == "nametag":
734
+ name = action_split[1]
735
+ self.textbox.namebox.set_text(name)
736
+ current_dialogue_obj.completed = True
737
+
738
+ elif c == "evidence":
739
+ side = action_split[1] # left or right or clear
740
+ if side == "clear":
741
+ self.evidence.hide_evidence()
742
+ else:
743
+ media_path = action_split[2]
744
+ self.evidence.display_evidence(side, media_path)
745
+ current_dialogue_obj.completed = True
746
+
747
+ elif c == "sound":
748
+ sound_path = action_split[1]
749
+ self.audio_commands.append(
750
+ {
751
+ "type": "audio",
752
+ "path": join(
753
+ ASSETS_FOLDER, "sound", f"sfx-{sound_path}.wav"
754
+ ),
755
+ "offset": self.time,
756
+ }
757
+ )
758
+ current_dialogue_obj.completed = True
759
+
760
+ elif c == "shake":
761
+ magnitude_str = action_split[1]
762
+ duration_str = action_split[2]
763
+
764
+ magnitude = float(magnitude_str)
765
+ duration = float(duration_str)
766
+ self.world_shaker.start_shaking(magnitude, duration)
767
+ self.textbox_shaker.start_shaking(magnitude, duration)
768
+ current_dialogue_obj.completed = True
769
+
770
+ elif c == "flash":
771
+ duration_str = action_split[1]
772
+
773
+ duration = float(duration_str)
774
+ self.white_flash.start_color((255, 255, 255), duration)
775
+ current_dialogue_obj.completed = True
776
+
777
+ elif c == "music":
778
+ music_command = action_split[1]
779
+
780
+ if music_command == "start":
781
+ track_name = action_split[2]
782
+ self.start_music_track(track_name)
783
+ elif music_command == "stop":
784
+ self.end_music_track()
785
+ else:
786
+ print(f"ERROR for command \"{current_dialogue_obj.name}\" - second argument should be \"start\" or \"stop\"")
787
+ current_dialogue_obj.completed = True
788
+
789
+ elif c == "cut":
790
+ position = action_split[1]
791
+ if position == "left":
792
+ self.cut_to_left()
793
+ elif position == "right":
794
+ self.cut_to_right()
795
+ elif position == "center":
796
+ self.cut_to_center()
797
+ elif position == "judge":
798
+ self.cut_to_judge()
799
+ elif position == "leftzoom":
800
+ self.cut_to_phoenix_action()
801
+ elif position == "rightzoom":
802
+ self.cut_to_edgeworth_action()
803
+ elif position == "gavel":
804
+ self.cut_to_gavel()
805
+ current_dialogue_obj.completed = True
806
+
807
+ elif c == "pan":
808
+ position = action_split[1]
809
+ if position == "left":
810
+ self.pan_to_left()
811
+ elif position == "right":
812
+ self.pan_to_right()
813
+ elif position == "center":
814
+ self.pan_to_center()
815
+ current_dialogue_obj.completed = True
816
+
817
+ elif c == "show":
818
+ position = action_split[1]
819
+ if position == "left":
820
+ self.left_bench.visible = True
821
+ elif position == "right":
822
+ self.right_bench.visible = True
823
+ elif position == "center":
824
+ self.witness_stand.visible = True
825
+ current_dialogue_obj.completed = True
826
+
827
+ elif c == "hide":
828
+ position = action_split[1]
829
+ if position == "left":
830
+ self.left_bench.visible = False
831
+ elif position == "right":
832
+ self.right_bench.visible = False
833
+ elif position == "center":
834
+ self.witness_stand.visible = False
835
+ current_dialogue_obj.completed = True
836
+
837
+ elif c == "verdict":
838
+ command = action_split[1]
839
+ if command == "set":
840
+ new_text = action_split[2]
841
+ text_color = action_split[3]
842
+ self.judge_verdict.set_text(new_text, text_color)
843
+
844
+ elif command == "show":
845
+ index = int(action_split[2])
846
+ self.judge_verdict.show_index(index)
847
+
848
+ elif command == "clear":
849
+ self.judge_verdict.clear()
850
+
851
+ current_dialogue_obj.completed = True
852
+
853
+ elif c == "gavel":
854
+ self.gavel_slam.set_gavel_frame(int(action_split[1]))
855
+ current_dialogue_obj.completed = True
856
+
857
+ elif c == "testimony":
858
+ command = action_split[1]
859
+ if command == "set":
860
+ new_text = action_split[2]
861
+ self.testimony_indicator.set_text(new_text)
862
+ elif command == "fillcolor":
863
+ if len(action_split) == 3 and action_split[2] == "default":
864
+ self.testimony_indicator.set_fill_color(None)
865
+ else:
866
+ r = int(action_split[2])
867
+ g = int(action_split[3])
868
+ b = int(action_split[4])
869
+ self.testimony_indicator.set_fill_color((r, g, b))
870
+ elif command == "strokecolor":
871
+ if len(action_split) == 3 and action_split[2] == "default":
872
+ self.testimony_indicator.set_stroke_color(None)
873
+ else:
874
+ r = int(action_split[2])
875
+ g = int(action_split[3])
876
+ b = int(action_split[4])
877
+ self.testimony_indicator.set_stroke_color((r, g, b))
878
+ elif command == "show":
879
+ self.testimony_indicator.make_visible()
880
+ elif command == "hide":
881
+ self.testimony_indicator.make_invisible()
882
+
883
+ current_dialogue_obj.completed = True
884
+
885
+ elif c == "nop":
886
+ current_dialogue_obj.completed = True
887
+
888
+ else:
889
+ print(
890
+ f'ERROR - Unknown action encountered: "{current_dialogue_obj.name}"'
891
+ )
892
+ current_dialogue_obj.completed = True
893
+ except Exception as e:
894
+ print(f"ERROR for command \"{current_dialogue_obj.name}\" - {repr(e)}")
895
+ current_dialogue_obj.completed = True
896
+
897
+
898
+ elif isinstance(current_dialogue_obj, DialogueTextLineBreak):
899
+ # Does anything need to be done here? I think this can be handled
900
+ # entirely in render()
901
+ current_dialogue_obj.completed = True
902
+
903
+ elif current_dialogue_obj is None:
904
+ # Done with the current page - let's try to get the next page!
905
+ page_end_time = timer()
906
+ page_duration = page_end_time - self.page_start_time
907
+ if "on_page_completed" in self.callbacks:
908
+ self.callbacks["on_page_completed"](
909
+ self.page_index,
910
+ len(self.pages),
911
+ self.current_page,
912
+ page_duration,
913
+ self.time_on_this_page,
914
+ )
915
+
916
+ self.page_index += 1
917
+
918
+ # If the current page index is greater than the number of pages, then we've
919
+ # used all the pages - in other words, we're done.
920
+ if self.page_index >= len(self.pages):
921
+ self.end_music_track()
922
+ self.end_voice_blips()
923
+ self.is_done = True
924
+
925
+ if "on_all_pages_completed" in self.callbacks:
926
+ self.callbacks["on_all_pages_completed"]()
927
+
928
+ return
929
+
930
+ # Update to the next page
931
+ self.current_page = self.pages[self.page_index]
932
+ self.textbox.page = self.current_page
933
+ self.textbox.font_data = get_best_font(
934
+ self.current_page.get_raw_text(), FONT_ARRAY
935
+ )
936
+ self.page_start_time = timer()
937
+ self.time_on_this_page = 0
938
+
939
+ def pan_to_right(self):
940
+ self.sequencer.run_action(
941
+ MoveSceneObjectAction(
942
+ target_value=(-1296 + 256, 0),
943
+ duration=0.5,
944
+ scene_object=self.world_root,
945
+ ease_function=courtroom_pan_lut_ease,
946
+ )
947
+ )
948
+
949
+ self.sequencer.run_action(
950
+ MoveSceneObjectAction(
951
+ target_value=(FG_RIGHT_X, 0),
952
+ duration=0.5,
953
+ scene_object=self.foreground,
954
+ ease_function=courtroom_pan_lut_ease,
955
+ )
956
+ )
957
+
958
+ def pan_to_left(self):
959
+ self.sequencer.run_action(
960
+ MoveSceneObjectAction(
961
+ target_value=(0, 0),
962
+ duration=0.5,
963
+ scene_object=self.world_root,
964
+ ease_function=courtroom_pan_lut_ease,
965
+ )
966
+ )
967
+
968
+ self.sequencer.run_action(
969
+ MoveSceneObjectAction(
970
+ target_value=(FG_LEFT_X, 0),
971
+ duration=0.5,
972
+ scene_object=self.foreground,
973
+ ease_function=courtroom_pan_lut_ease,
974
+ )
975
+ )
976
+
977
+ def pan_to_center(self):
978
+ self.sequencer.run_action(
979
+ MoveSceneObjectAction(
980
+ target_value=(-(1296 / 2) + (256 / 2), 0),
981
+ duration=0.5,
982
+ scene_object=self.world_root,
983
+ ease_function=courtroom_pan_lut_ease,
984
+ )
985
+ )
986
+
987
+ self.sequencer.run_action(
988
+ MoveSceneObjectAction(
989
+ target_value=(FG_CENTER_X, 0),
990
+ duration=0.5,
991
+ scene_object=self.foreground,
992
+ ease_function=courtroom_pan_lut_ease,
993
+ )
994
+ )
995
+
996
+ def cut_to_left(self):
997
+ self.world_root.set_x(0)
998
+ self.world_root.set_y(0)
999
+ self.foreground.set_x(FG_LEFT_X)
1000
+
1001
+ def cut_to_right(self):
1002
+ self.world_root.set_x(-1296 + 256)
1003
+ self.world_root.set_y(0)
1004
+ self.foreground.set_x(FG_RIGHT_X)
1005
+
1006
+ def cut_to_center(self):
1007
+ self.world_root.set_x(-(1296 / 2) + (256 / 2))
1008
+ self.world_root.set_y(0)
1009
+ self.foreground.set_x(FG_CENTER_X)
1010
+
1011
+ def cut_to_judge(self):
1012
+ self.world_root.set_x(0)
1013
+ self.world_root.set_y(-256)
1014
+
1015
+ def cut_to_phoenix_action(self):
1016
+ self.world_root.set_x(0)
1017
+ self.world_root.set_y(-512)
1018
+
1019
+ def cut_to_edgeworth_action(self):
1020
+ self.world_root.set_x(0)
1021
+ self.world_root.set_y(-768)
1022
+
1023
+ def cut_to_gavel(self):
1024
+ self.world_root.set_x(-560)
1025
+ self.world_root.set_y(-256)
1026
+
1027
+ current_music_track: Optional[dict] = None
1028
+ current_voice_blips: Optional[dict] = None
1029
+
1030
+ def start_music_track(self, name: str):
1031
+ self.end_music_track()
1032
+ self.current_music_track = {
1033
+ "type": "audio",
1034
+ "path": join(ASSETS_FOLDER, "music", f"{name}.mp3"),
1035
+ "offset": self.time,
1036
+ "loop_type": "loop_until_truncated",
1037
+ }
1038
+ self.audio_commands.append(self.current_music_track)
1039
+
1040
+ def end_music_track(self):
1041
+ if self.current_music_track is not None:
1042
+ self.current_music_track["end"] = self.time
1043
+ self.current_music_track = None
1044
+
1045
+ def start_voice_blips(self, gender: str):
1046
+ self.end_voice_blips()
1047
+ self.current_voice_blips = {
1048
+ "type": "audio",
1049
+ "path": join(ASSETS_FOLDER, "sound", f"sfx-blip{gender}.wav"),
1050
+ "offset": self.time,
1051
+ "loop_delay": 0.06,
1052
+ "loop_type": "loop_complete_only",
1053
+ }
1054
+ self.audio_commands.append(self.current_voice_blips)
1055
+
1056
+ def end_voice_blips(self):
1057
+ if self.current_voice_blips is not None:
1058
+ self.current_voice_blips["end"] = self.time
1059
+ self.current_voice_blips = None
1060
+
1061
+ def next_dialogue_sound(self):
1062
+ self.audio_commands.append(
1063
+ {
1064
+ "type": "audio",
1065
+ "path": join(ASSETS_FOLDER, "sound", "sfx-pichoop.wav"),
1066
+ "offset": self.time,
1067
+ }
1068
+ )
1069
+
1070
+ def play_phoenix_desk_slam(self):
1071
+ fp_before = self.phoenix.filepath
1072
+ cb_before = self.phoenix.callbacks
1073
+ self.phoenix.set_filepath(
1074
+ get_sprite_location("phoenix", "deskslam"),
1075
+ {0.8: lambda: self.phoenix.set_filepath(fp_before, cb_before)},
1076
+ )
1077
+ self.audio_commands.append(
1078
+ {
1079
+ "type": "audio",
1080
+ "path": join(ASSETS_FOLDER, "sound", "sfx-deskslam.wav"),
1081
+ "offset": self.time + 0.15,
1082
+ }
1083
+ )
1084
+
1085
+ def play_edgeworth_desk_slam(self):
1086
+ fp_before = self.edgeworth.filepath
1087
+ cb_before = self.edgeworth.callbacks
1088
+ self.edgeworth.set_filepath(
1089
+ get_sprite_location("edgeworth", "deskslam"),
1090
+ {0.8: lambda: self.edgeworth.set_filepath(fp_before, cb_before)},
1091
+ )
1092
+ self.audio_commands.append(
1093
+ {
1094
+ "type": "audio",
1095
+ "path": join(ASSETS_FOLDER, "sound", "sfx-deskslam.wav"),
1096
+ "offset": self.time + 0.25,
1097
+ }
1098
+ )
1099
+
1100
+
1101
+ def get_sprite_location(character: str, emotion: str):
1102
+ return Path(
1103
+ ASSETS_FOLDER, CHARACTERS_FOLDER, character, f"{character}-{emotion}.gif"
1104
+ ).as_posix()
1105
+
1106
+
1107
+ def get_sprite_tag(location: str, character: str, emotion: str):
1108
+ return f"<sprite {location} {get_sprite_location(character, emotion)}/>"
1109
+
1110
+
1111
+ class DialogueBoxBuilder:
1112
+ def __init__(self, callbacks: dict = None, verify_sprites: bool = False) -> None:
1113
+ self.character_data = load_character_data(verify_sprites=verify_sprites)
1114
+ self.music_data = load_music_data()
1115
+ self.current_character_name: str = None
1116
+ self.current_character_animation: str = None
1117
+ self.previous_character_name: str = None
1118
+
1119
+ #
1120
+ self.pan_probability_in: float = 1.0
1121
+
1122
+ self.has_gone_to_tense_music: bool = False
1123
+ self.callbacks = {} if callbacks is None else callbacks
1124
+
1125
+ self._sentiment_analyzer = None
1126
+ self.sentiment_mode = (
1127
+ "neutral"
1128
+ if getenv("oe_bypass_sentiment", "")
1129
+ else getenv("oe_sentiment_model", "neutral").lower() or "neutral"
1130
+ )
1131
+ if self.sentiment_mode == "hf":
1132
+ try:
1133
+ from transformers import pipeline
1134
+ except ImportError as exc:
1135
+ raise RuntimeError(
1136
+ "Hugging Face sentiment requires the optional 'sentiment' extra: "
1137
+ "uv run --extra sentiment <command>"
1138
+ ) from exc
1139
+ environ.setdefault("TOKENIZERS_PARALLELISM", "false")
1140
+ model_cache = models_path()
1141
+ model_cache.mkdir(parents=True, exist_ok=True)
1142
+ self._sentiment_analyzer = pipeline(
1143
+ "sentiment-analysis",
1144
+ model=SENTIMENT_MODEL_PATH,
1145
+ tokenizer=SENTIMENT_MODEL_PATH,
1146
+ model_kwargs={"cache_dir": str(model_cache)},
1147
+ )
1148
+ elif self.sentiment_mode != "neutral":
1149
+ raise ValueError(
1150
+ f"Unsupported oe_sentiment_model={self.sentiment_mode!r}; use 'neutral' or 'hf'. "
1151
+ "Polyglot sentiment is no longer supported."
1152
+ )
1153
+
1154
+ def hf_sentiment(self, text: str):
1155
+ return self._sentiment_analyzer(text)[0]
1156
+
1157
+ def reload_character_data(self, verify_sprites: bool = False):
1158
+ self.character_data = load_character_data(verify_sprites=verify_sprites)
1159
+
1160
+ def initialize_box(
1161
+ self,
1162
+ user_name: str,
1163
+ do_objection: bool = False,
1164
+ go_to_tense_music: bool = False,
1165
+ text: str = None,
1166
+ ) -> DialoguePage:
1167
+ this_char_data = self.character_data["characters"][self.current_character_name]
1168
+ location = this_char_data["location"]
1169
+ gender = this_char_data["gender"]
1170
+
1171
+ actions: list[BaseDialogueItem] = []
1172
+
1173
+ if do_objection:
1174
+ actions.extend(
1175
+ [
1176
+ DialogueAction("hidebox", 0),
1177
+ DialogueAction(
1178
+ f"bubble objection {self.current_character_name}", 0
1179
+ ),
1180
+ DialogueAction(f"wait 0.1", 0),
1181
+ DialogueAction("music stop", 0) if go_to_tense_music else None,
1182
+ DialogueAction(f"wait 1.2", 0),
1183
+ DialogueAction(f"music start {self.tense_track}", 0)
1184
+ if go_to_tense_music
1185
+ else None,
1186
+ ]
1187
+ )
1188
+
1189
+ if go_to_tense_music:
1190
+ self.has_gone_to_tense_music = True
1191
+
1192
+ actions.extend(
1193
+ [
1194
+ DialogueAction("wait 0.03", 0),
1195
+ ]
1196
+ )
1197
+
1198
+ previous_location = (
1199
+ self.character_data["characters"]
1200
+ .get(self.previous_character_name, {})
1201
+ .get("location", None)
1202
+ )
1203
+ pannable_locations = ["left", "center", "right"]
1204
+ can_pan = (
1205
+ (location in pannable_locations)
1206
+ and (previous_location in pannable_locations)
1207
+ and (location != previous_location)
1208
+ )
1209
+ PAN_TIME = 0.5
1210
+ SWITCH_SPRITE_TIME = 0.18
1211
+ move_cam_actions = []
1212
+ if can_pan and random() < pan_probability(self.pan_probability_in):
1213
+ self.pan_probability_in -= 0.5
1214
+ panning_across_whole_courtroom = (
1215
+ location == "left" and previous_location == "right"
1216
+ ) or (location == "right" and previous_location == "left")
1217
+
1218
+ if panning_across_whole_courtroom:
1219
+ move_cam_actions.extend(
1220
+ [
1221
+ DialogueAction("hidebox", 0),
1222
+ DialogueAction("wait 0.25", 0),
1223
+ DialogueAction("hide center", 0),
1224
+ DialogueAction(f"pan {location}", 0),
1225
+ DialogueAction(
1226
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1227
+ 0,
1228
+ ),
1229
+ DialogueAction(f"wait 0.14", 0),
1230
+ DialogueAction("hide left", 0),
1231
+ DialogueAction("hide right", 0),
1232
+ DialogueAction(f"wait 0.26", 0),
1233
+ DialogueAction("show left", 0),
1234
+ DialogueAction("show right", 0),
1235
+ DialogueAction("wait 0.1", 0),
1236
+ DialogueAction(f"wait 0.1", 0),
1237
+ DialogueAction("show center", 0),
1238
+ ]
1239
+ )
1240
+
1241
+ # Panning to the witness
1242
+ elif location == "center":
1243
+ move_cam_actions.extend(
1244
+ [
1245
+ DialogueAction("hidebox", 0),
1246
+ DialogueAction("wait 0.25", 0),
1247
+ DialogueAction(f"pan {location}", 0),
1248
+ DialogueAction(
1249
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1250
+ 0,
1251
+ ),
1252
+ DialogueAction(f"wait {SWITCH_SPRITE_TIME}", 0),
1253
+ DialogueAction("hide left", 0),
1254
+ DialogueAction("hide right", 0),
1255
+ DialogueAction("show center", 0),
1256
+ DialogueAction(f"wait {PAN_TIME - SWITCH_SPRITE_TIME}", 0),
1257
+ DialogueAction("show left", 0),
1258
+ DialogueAction("show right", 0),
1259
+ DialogueAction(f"wait 0.1", 0),
1260
+ ]
1261
+ )
1262
+
1263
+ # Panning to the defense or prosecution from the witness
1264
+ else:
1265
+ move_cam_actions.extend(
1266
+ [
1267
+ DialogueAction("hidebox", 0),
1268
+ DialogueAction("wait 0.25", 0),
1269
+ DialogueAction(f"pan {location}", 0),
1270
+ DialogueAction(
1271
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1272
+ 0,
1273
+ ),
1274
+ DialogueAction(f"wait {SWITCH_SPRITE_TIME}", 0),
1275
+ DialogueAction("show left", 0),
1276
+ DialogueAction("show right", 0),
1277
+ DialogueAction("hide center", 0),
1278
+ DialogueAction(f"wait {1 - SWITCH_SPRITE_TIME}", 0),
1279
+ DialogueAction("show center", 0),
1280
+ DialogueAction(f"wait 0.1", 0),
1281
+ ]
1282
+ )
1283
+
1284
+ else:
1285
+ if location == previous_location:
1286
+ self.pan_probability_in += 0.2
1287
+ else:
1288
+ self.pan_probability_in += 0.1
1289
+
1290
+ # Prevents one person talking a bunch at the beginning
1291
+ # from making it pan constantly later on
1292
+ self.pan_probability_in = min(self.pan_probability_in, 1.0)
1293
+
1294
+ move_cam_actions.extend(
1295
+ [
1296
+ DialogueAction(
1297
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1298
+ 0,
1299
+ ),
1300
+ DialogueAction(f"cut {location}", 0),
1301
+ ]
1302
+ )
1303
+
1304
+ actions.extend(move_cam_actions)
1305
+ actions.extend(
1306
+ [
1307
+ DialogueAction(f'nametag "{user_name}"', 0),
1308
+ DialogueAction("showbox", 0),
1309
+ ]
1310
+ )
1311
+
1312
+ self.previous_character_name = self.current_character_name
1313
+
1314
+ return DialoguePage(actions)
1315
+
1316
+ def finish_box(self, page: DialoguePage):
1317
+ this_char_data = self.character_data["characters"][self.current_character_name]
1318
+ pos = this_char_data["location"]
1319
+
1320
+ page.commands.extend(
1321
+ [
1322
+ DialogueAction(
1323
+ f"sprite {pos} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1324
+ 0,
1325
+ ),
1326
+ DialogueAction(f"stopblip", 0),
1327
+ DialogueAction("showarrow", 0),
1328
+ DialogueAction("wait 2", 0),
1329
+ DialogueAction("hidearrow", 0),
1330
+ DialogueAction("sound pichoop", 0),
1331
+ DialogueAction("wait 0.3", 0),
1332
+ ]
1333
+ )
1334
+
1335
+ def get_characters_for_users(
1336
+ self,
1337
+ common: Counter,
1338
+ assigned_characters: dict = None,
1339
+ adult_mode: bool = False,
1340
+ ):
1341
+ users_to_characters = (
1342
+ {} if assigned_characters is None else assigned_characters.copy()
1343
+ )
1344
+ most_common = [t[0] for t in common.most_common()]
1345
+
1346
+ high_priority_character_names = self.character_data["high_priority"]
1347
+ character_names = [
1348
+ name
1349
+ for name in self.character_data["characters"]
1350
+ if not adult_mode
1351
+ or (adult_mode and name not in self.character_data["omit_for_adult_mode"])
1352
+ ]
1353
+ rnd_character_names = [
1354
+ name
1355
+ for name in character_names
1356
+ if name not in high_priority_character_names
1357
+ ]
1358
+
1359
+ # Assign high priority characters first
1360
+ i: int = 0
1361
+ for name in high_priority_character_names:
1362
+ try:
1363
+ if (
1364
+ name not in users_to_characters.values()
1365
+ and most_common[i] not in users_to_characters
1366
+ ):
1367
+ users_to_characters[most_common[i]] = name
1368
+ except IndexError:
1369
+ pass
1370
+ i += 1
1371
+
1372
+ # Everyone else is chosen at random
1373
+ rnd_characters = [
1374
+ c for c in rnd_character_names if c not in users_to_characters.values()
1375
+ ]
1376
+ for user_id in most_common:
1377
+ # Skip users who were manually assigned characters
1378
+ if user_id in users_to_characters:
1379
+ continue
1380
+
1381
+ # Reload choosable characters if we ran out
1382
+ if len(rnd_characters) == 0:
1383
+ rnd_characters = rnd_character_names.copy()
1384
+
1385
+ # Assign a character randomly chosen from the list to this user,
1386
+ # and remove them from the pool
1387
+ rnd_character = choice(rnd_characters)
1388
+ rnd_characters.remove(rnd_character)
1389
+ users_to_characters[user_id] = rnd_character
1390
+
1391
+ return users_to_characters
1392
+
1393
+ def build_from_comments(
1394
+ self,
1395
+ comments: list[Comment],
1396
+ music_code: str = "pwr",
1397
+ assigned_characters: dict = None,
1398
+ adult_mode: bool = False,
1399
+ avoid_spoiler_sprites: bool = False,
1400
+ ):
1401
+ self.avoid_spoiler_sprites = avoid_spoiler_sprites
1402
+
1403
+ # Do character check
1404
+ counter = Counter()
1405
+ for comment in comments:
1406
+ counter.update({comment.effective_user_id: 1})
1407
+ users_to_characters = self.get_characters_for_users(
1408
+ counter,
1409
+ assigned_characters=assigned_characters,
1410
+ adult_mode=adult_mode,
1411
+ )
1412
+
1413
+ if "on_characters_cast" in self.callbacks:
1414
+ self.callbacks["on_characters_cast"](users_to_characters)
1415
+
1416
+ # Get music
1417
+ if music_code in self.music_data:
1418
+ music_pack = self.music_data[music_code]
1419
+ else:
1420
+ raise KeyError(
1421
+ f'Music code "{music_code}" not found. Ensure that a folder for it exists in the "music" folder, and that it has a "config.toml" file.'
1422
+ )
1423
+
1424
+ self.pages: list[DialoguePage] = []
1425
+
1426
+ # Start relaxed music
1427
+ self.relaxed_track = join(music_code, choice(music_pack["relaxed"]))
1428
+ self.tense_track = join(music_code, choice(music_pack["tense"]))
1429
+ self.pages.append(
1430
+ DialoguePage(
1431
+ [
1432
+ DialogueAction(
1433
+ f"sprite left {Path(ASSETS_FOLDER, 'characters', 'phoenix', 'phoenix-normal-idle.gif').as_posix()}",
1434
+ 0,
1435
+ ),
1436
+ DialogueAction(f"music start {self.relaxed_track}", 0),
1437
+ ]
1438
+ )
1439
+ )
1440
+
1441
+ self.has_done_objection = False
1442
+
1443
+ # Add boxes for the dialogue
1444
+ for i, comment in enumerate(comments):
1445
+ self.pages.extend(
1446
+ self.get_boxes_with_pauses(
1447
+ user_name=comment.user_name,
1448
+ character=users_to_characters[comment.effective_user_id],
1449
+ text=comment.text_content,
1450
+ evidence_path=comment.evidence_path,
1451
+ manual_score=comment.score,
1452
+ )
1453
+ )
1454
+
1455
+ if "on_comment_processed" in self.callbacks:
1456
+ self.callbacks["on_comment_processed"](i, len(comments), comment)
1457
+
1458
+ def get_sentiment(self, text: str):
1459
+ if self.sentiment_mode == "hf":
1460
+ return self.hf_sentiment(text)
1461
+ return {"label": "neutral", "score": 1.0}
1462
+
1463
+ def update_pose_for_sentence(
1464
+ self, sentence: str, sprites: list[str], manual_score: float = 0.0
1465
+ ):
1466
+ if manual_score == 0:
1467
+ sentiment: dict = self.get_sentiment(sentence)
1468
+ else:
1469
+ sentiment: dict = {
1470
+ "label": "positive" if manual_score > 0 else "negative",
1471
+ "score": 1.0,
1472
+ }
1473
+
1474
+ try:
1475
+ sprite_cat = sentiment.get("label", "neutral")
1476
+ except IndexError:
1477
+ sprite_cat = "neutral"
1478
+
1479
+ self.current_character_animation = choice(
1480
+ [
1481
+ s
1482
+ for s in sprites[sprite_cat]
1483
+ if not self.avoid_spoiler_sprites
1484
+ or "spoiler" not in sprites
1485
+ or (self.avoid_spoiler_sprites and s not in sprites["spoiler"])
1486
+ ]
1487
+ )
1488
+
1489
+ def get_boxes_with_pauses(
1490
+ self,
1491
+ user_name: str,
1492
+ character: str,
1493
+ text: str,
1494
+ evidence_path: str = None,
1495
+ manual_score: float = 0,
1496
+ ):
1497
+ self.current_character_name = character
1498
+ this_char_data = self.character_data["characters"][character]
1499
+ location = this_char_data["location"]
1500
+ gender = this_char_data["gender"]
1501
+ sprites = this_char_data["sprites"]
1502
+
1503
+ # Split text into sentences
1504
+ sentences = split_sentences(text)
1505
+
1506
+ # Determine if this should have an objection
1507
+ if manual_score == 0:
1508
+ text_polarity_data = self.get_sentiment(text)
1509
+ polarity_type = text_polarity_data["label"]
1510
+ polarity_confidence = text_polarity_data["score"]
1511
+ else:
1512
+ polarity_type = "positive" if manual_score > 0 else "negative"
1513
+ polarity_confidence = abs(manual_score)
1514
+
1515
+ do_objection = (
1516
+ (
1517
+ (polarity_type == "negative" and random() > 0.3)
1518
+ or (polarity_type == "positive" and random() > 0.7)
1519
+ )
1520
+ and polarity_confidence > 0.5
1521
+ and not self.has_done_objection
1522
+ )
1523
+ go_to_tense_music = do_objection and not self.has_gone_to_tense_music
1524
+ # Stuff at beginning of text box
1525
+ all_pages: list[DialoguePage] = []
1526
+
1527
+ self.update_pose_for_sentence(sentences[0], sprites, manual_score=manual_score)
1528
+ current_page = self.initialize_box(
1529
+ user_name, do_objection, go_to_tense_music, text=text
1530
+ )
1531
+
1532
+ current_page.commands.append(DialogueAction("evidence clear", 0))
1533
+ if evidence_path is not None:
1534
+ current_page.commands.append(
1535
+ DialogueAction(
1536
+ f"evidence {'left' if location == 'right' else 'right'} \"{evidence_path}\"",
1537
+ 0,
1538
+ )
1539
+ )
1540
+
1541
+ # Add actual content of text box
1542
+ current_line_index = 0
1543
+ current_line_width = 0
1544
+
1545
+ sentence_index = 0
1546
+ word_index = 0
1547
+
1548
+ # Get the font for this text
1549
+ best_font = get_best_font(text, FONT_ARRAY)
1550
+
1551
+ space_width = get_text_width(" ", font=best_font)
1552
+
1553
+ sentences_in_this_box = 0
1554
+ for sentence_index, sentence in enumerate(sentences):
1555
+ # Get the sentence sentiment here
1556
+
1557
+ # We don't want to
1558
+ if sentence_index > 0:
1559
+ self.update_pose_for_sentence(sentence, sprites, manual_score=manual_score)
1560
+
1561
+ # Start animating the speaking and blips
1562
+ current_page.commands.extend(
1563
+ [
1564
+ DialogueAction(f"startblip {gender}", 0),
1565
+ DialogueAction(
1566
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-talk')}",
1567
+ 0,
1568
+ ),
1569
+ ]
1570
+ )
1571
+
1572
+ # try:
1573
+ # pos_tags = sentence.pos_tags
1574
+ # except ValueError:
1575
+ # pos_tags = [(word, None) for word in sentence.words]
1576
+
1577
+ pos_tags = [(word, None) for word in sentence.split()]
1578
+
1579
+ # First pass - let's find any words that are too long, and split them into characters.
1580
+ # The tuples in this updates list will be of the format (word, pos, space_after)
1581
+ # The space_after variable is important because for really long strings, we treat each character as a word
1582
+ # for the purposes of wrapping, but we don't want each character to be separated by a space.
1583
+ updated_pos_tags = []
1584
+ for word, pos in pos_tags:
1585
+ word_width = get_text_width(word, font=best_font)
1586
+
1587
+ # If this word ALONE is too wide for the box, then we need to split it into characters
1588
+ # Each character except the last should not have a space after it, so it still looks
1589
+ # like one word
1590
+ if word_width > MAX_WIDTH:
1591
+ for i, c in enumerate(word):
1592
+ updated_pos_tags.append((c, pos, i == len(word) - 1))
1593
+ else:
1594
+ updated_pos_tags.append((word, pos, True))
1595
+
1596
+ for word_index, (word, pos, space_after) in enumerate(updated_pos_tags):
1597
+ word_width = get_text_width(word, font=best_font)
1598
+
1599
+ # Line break if this word is too wide to fit
1600
+ if current_line_width + word_width > MAX_WIDTH:
1601
+ current_line_width = 0
1602
+ if current_line_index < 2:
1603
+ # Go to next line down
1604
+ current_page.commands.append(DialogueTextLineBreak())
1605
+ current_line_index += 1
1606
+
1607
+ else:
1608
+ # This page is full, so create a new page
1609
+ self.finish_box(current_page)
1610
+ all_pages.append(current_page)
1611
+ current_page = self.initialize_box(user_name, text=text)
1612
+ current_page.commands.extend(
1613
+ [
1614
+ DialogueAction(f"startblip {gender}", 0),
1615
+ DialogueAction(
1616
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-talk')}",
1617
+ 0,
1618
+ ),
1619
+ ]
1620
+ )
1621
+ current_line_index = 0
1622
+ sentences_in_this_box = 0
1623
+
1624
+ # Should this be highlighted?
1625
+ # Add this word to current line
1626
+ tags: list[str] = []
1627
+ if pos == "PROPN" and random() > 0.5:
1628
+ tags.append("red")
1629
+ current_page.commands.append(DialogueTextChunk(word, tags))
1630
+ current_line_width += word_width
1631
+
1632
+ # If the current word is punctuation (not sentence end), then add a very short pause
1633
+ if word[-1] in ",-":
1634
+ current_page.commands.extend(
1635
+ [
1636
+ DialogueAction(f"stopblip", 0),
1637
+ DialogueAction(
1638
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1639
+ 0,
1640
+ ),
1641
+ DialogueAction("wait 0.3", 0),
1642
+ DialogueAction(f"startblip {gender}", 0),
1643
+ DialogueAction(
1644
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-talk')}",
1645
+ 0,
1646
+ ),
1647
+ ]
1648
+ )
1649
+
1650
+ # If the next word is not the last word in the sentence, then
1651
+ # add a space after it
1652
+ if word_index != len(updated_pos_tags) - 1 and space_after:
1653
+ current_page.commands.append(DialogueTextChunk(" ", []))
1654
+ current_line_width += space_width
1655
+
1656
+ sentences_in_this_box += 1
1657
+ if sentences_in_this_box < 2:
1658
+ # In between each sentence, pause and change the sprite
1659
+ current_page.commands.extend(
1660
+ [
1661
+ DialogueAction(f"stopblip", 0),
1662
+ # DialogueTextChunk(" ", []),
1663
+ DialogueAction(
1664
+ f"sprite {location} {get_sprite_location(self.current_character_name, f'{self.current_character_animation}-idle')}",
1665
+ 0,
1666
+ ),
1667
+ ]
1668
+ )
1669
+ current_line_width += space_width
1670
+
1671
+ # We only want to have a delay if this isn't the last sentence
1672
+ # in the box (otherwise there'll be a weird delay between the text
1673
+ # showing up and the "next page" arrow showing up.)
1674
+ if sentence_index != len(sentences) - 1:
1675
+ current_page.commands.append(
1676
+ DialogueAction("wait 0.6", 0),
1677
+ )
1678
+
1679
+ elif sentence_index != len(sentences) - 1:
1680
+ # There are two sentences in this box, so end this box and
1681
+ # start a new one
1682
+ self.finish_box(current_page)
1683
+ all_pages.append(current_page)
1684
+ current_page = self.initialize_box(user_name, text=text)
1685
+ current_line_index = 0
1686
+ current_line_width = 0
1687
+ sentences_in_this_box = 0
1688
+
1689
+ self.finish_box(current_page)
1690
+ all_pages.append(current_page)
1691
+
1692
+ return all_pages
1693
+
1694
+ def render(
1695
+ self,
1696
+ comments: list[Comment],
1697
+ output_filename: str = None,
1698
+ music_code: str = "pwr",
1699
+ assigned_characters: dict = None,
1700
+ adult_mode: bool = False,
1701
+ avoid_spoiler_sprites: bool = False,
1702
+ volume: int = -15,
1703
+ resolution_scale: float = 1.0,
1704
+ ):
1705
+ self.build_from_comments(
1706
+ comments,
1707
+ music_code=music_code,
1708
+ assigned_characters=assigned_characters,
1709
+ adult_mode=adult_mode,
1710
+ avoid_spoiler_sprites=avoid_spoiler_sprites,
1711
+ )
1712
+ director = AceAttorneyDirector(callbacks=self.callbacks)
1713
+ director.set_current_pages(self.pages)
1714
+ director.render_movie(
1715
+ output_filename=output_filename,
1716
+ volume_adjustment=volume,
1717
+ resolution_scale=resolution_scale,
1718
+ )