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.
- objectionengine2/MovieKit.py +615 -0
- objectionengine2/__init__.py +3 -0
- objectionengine2/ace_attorney_scene.py +1718 -0
- objectionengine2/beans/comment.py +22 -0
- objectionengine2/beans/text.py +9 -0
- objectionengine2/cache.py +18 -0
- objectionengine2/composers/compose_gavel_slam.py +72 -0
- objectionengine2/composers/compose_verdict.py +56 -0
- objectionengine2/font_constants.py +37 -0
- objectionengine2/font_tools.py +98 -0
- objectionengine2/gavel_slam.py +80 -0
- objectionengine2/judge_verdict.py +179 -0
- objectionengine2/loading.py +253 -0
- objectionengine2/make_movie.py +133 -0
- objectionengine2/math_helpers.py +22 -0
- objectionengine2/parse_tags.py +213 -0
- objectionengine2/renderer.py +1 -0
- objectionengine2/tag_macros.py +25 -0
- objectionengine2/testimony_indicator.py +113 -0
- objectionengine2/utils.py +94 -0
- objectionengine2/xml_parser.py +279 -0
- objectionengine2-1.0.0.dist-info/METADATA +101 -0
- objectionengine2-1.0.0.dist-info/RECORD +25 -0
- objectionengine2-1.0.0.dist-info/WHEEL +4 -0
- objectionengine2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|