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,253 @@
|
|
|
1
|
+
from difflib import get_close_matches
|
|
2
|
+
from toml import load
|
|
3
|
+
from os import DirEntry, scandir
|
|
4
|
+
from os.path import join
|
|
5
|
+
from PIL import Image, ImageFile
|
|
6
|
+
|
|
7
|
+
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|
8
|
+
|
|
9
|
+
from rich import print
|
|
10
|
+
from .cache import assets_path
|
|
11
|
+
|
|
12
|
+
ASSETS_FOLDER = str(assets_path())
|
|
13
|
+
CHARACTERS_FOLDER = "characters"
|
|
14
|
+
MUSIC_FOLDER = "music"
|
|
15
|
+
|
|
16
|
+
def _print_note(note: str):
|
|
17
|
+
print('[yellow][bold]NOTE[/bold] - ' + note)
|
|
18
|
+
|
|
19
|
+
def _sprite_not_found_error(
|
|
20
|
+
character_name: str,
|
|
21
|
+
sprite_name: str,
|
|
22
|
+
files_in_character_dir: list,
|
|
23
|
+
):
|
|
24
|
+
error_string = f'[yellow][bold]NOTE[/bold] - Character "{character_name}" does not have "{sprite_name}"'
|
|
25
|
+
matches = get_close_matches(
|
|
26
|
+
sprite_name, [f.name for f in files_in_character_dir], cutoff=0.9
|
|
27
|
+
)
|
|
28
|
+
if len(matches) > 0:
|
|
29
|
+
error_string += f' (is the filename "{matches[0]}" misspelled?)'
|
|
30
|
+
|
|
31
|
+
error_string += "; this character will be removed from the possible characters"
|
|
32
|
+
print(error_string)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sprite_corrupt_error(sprite_name: str):
|
|
36
|
+
_print_note(
|
|
37
|
+
f'Sprite "{sprite_name}" cannot be opened; this character will be removed from the possible characters'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _no_sprites_error(character_name: str):
|
|
41
|
+
_print_note(
|
|
42
|
+
f'Character "{character_name}" has no sprites; they will be removed from the possible characters'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _character_has_no_config_error(character_name: str):
|
|
46
|
+
_print_note(
|
|
47
|
+
f'Character "{character_name}" has no config.toml; they will be removed from the possible characters'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _sprite_is_usable(sprite_file: DirEntry) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Confirm that all of the frames in the sprite are usable
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
with Image.open(sprite_file.path) as img:
|
|
56
|
+
if hasattr(img, "is_animated"):
|
|
57
|
+
for frame_no in range(img.n_frames):
|
|
58
|
+
img.seek(frame_no)
|
|
59
|
+
img.convert("RGBA")
|
|
60
|
+
else:
|
|
61
|
+
img.convert("RGBA")
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def load_character_data(verify_sprites: bool = False) -> dict:
|
|
67
|
+
"""
|
|
68
|
+
Returns information about the available characters in a dictionary.
|
|
69
|
+
|
|
70
|
+
If the optional parameter `verify_sprites` is set to `True`, all sprites
|
|
71
|
+
will be opened and checked to ensure they can be properly loaded during
|
|
72
|
+
render time.
|
|
73
|
+
Note that this is an expensive operation - using all of the DS trilogy
|
|
74
|
+
character data, it takes my computer around 7-8 seconds to run when enabled,
|
|
75
|
+
versus a fraction of a second to run when disabled!
|
|
76
|
+
|
|
77
|
+
The return value is a dictionary containing the following data:
|
|
78
|
+
- `characters` is a dictionary, where the keys
|
|
79
|
+
are character IDs (i.e. directories in the "characters" directory), and
|
|
80
|
+
the values are the contents of the character's config.toml file as a
|
|
81
|
+
dictionary. They may contain the following fields:
|
|
82
|
+
- `display_name` is the name that appears for the character in an
|
|
83
|
+
actual game.
|
|
84
|
+
- `location` is the position in the scene where the character is
|
|
85
|
+
(usually `left`, `center`, or `right`).
|
|
86
|
+
- `gender` is the character's gender, used for the speech blip
|
|
87
|
+
sound effect (usually `male` or `female`).
|
|
88
|
+
- `sprites` is a dictionary that maps different sentiment levels to
|
|
89
|
+
lists of animation names.
|
|
90
|
+
- `neutral` sprites are displayed when a comment is not especially
|
|
91
|
+
positive or negative.
|
|
92
|
+
- `positive` sprites are used when a comment is determined by the
|
|
93
|
+
sentiment analysis model to be positive (happy, excited, etc).
|
|
94
|
+
- `negative` sprites are used when a comment is determined by the
|
|
95
|
+
sentiment analysis model to be negative (sad, angry, etc).
|
|
96
|
+
- `neutral` sprites are used when a comment is not found by the
|
|
97
|
+
sentiment analysis model to be especially positive or negative.
|
|
98
|
+
- The `spoiler` sprite list contains names of animations that may imply
|
|
99
|
+
spoilers to viewers (such as an especially angry or deranged version
|
|
100
|
+
of a character). If you wish, you can use this list to determine
|
|
101
|
+
which sprites to remove from the final selection pool.
|
|
102
|
+
- `high_priority` is a list of character IDs that should be used, in order,
|
|
103
|
+
before moving to random assignment of characters to users.
|
|
104
|
+
- `omit_for_adult_mode` is a list of character IDs that should be removed
|
|
105
|
+
from the selection pool immediately if adult mode is enabled.
|
|
106
|
+
"""
|
|
107
|
+
# Find all of the characters
|
|
108
|
+
characters = {}
|
|
109
|
+
folders = [f for f in scandir(join(ASSETS_FOLDER, CHARACTERS_FOLDER)) if f.is_dir()]
|
|
110
|
+
for f in folders:
|
|
111
|
+
# Allow us to skip characters by prefixing their name with
|
|
112
|
+
# an underscore
|
|
113
|
+
if f.name.startswith("_"):
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
with open(join(f.path, "config.toml")) as config_file:
|
|
117
|
+
characters[f.name] = load(config_file)
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
_character_has_no_config_error(f.name)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Validate that all characters have the sprites they should
|
|
123
|
+
characters_to_remove = set()
|
|
124
|
+
for character_name, character_data in characters.items():
|
|
125
|
+
files_in_character_dir = [
|
|
126
|
+
f
|
|
127
|
+
for f in scandir(join(ASSETS_FOLDER, CHARACTERS_FOLDER, character_name))
|
|
128
|
+
if f.is_file()
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# First - let's confirm that all of the sprites are here.
|
|
132
|
+
raw_sprites: dict = character_data.get("sprites", {})
|
|
133
|
+
|
|
134
|
+
neutral_sprites = raw_sprites.get("neutral", [])
|
|
135
|
+
positive_sprites = raw_sprites.get("positive", [])
|
|
136
|
+
negative_sprites = raw_sprites.get("negative", [])
|
|
137
|
+
spoiler_sprites = raw_sprites.get("spoiler", [])
|
|
138
|
+
|
|
139
|
+
all_expected_sprites = []
|
|
140
|
+
all_expected_sprites.extend(s for s in neutral_sprites)
|
|
141
|
+
all_expected_sprites.extend(s for s in positive_sprites)
|
|
142
|
+
all_expected_sprites.extend(s for s in negative_sprites)
|
|
143
|
+
|
|
144
|
+
# If there are no sprites, then don't use this character
|
|
145
|
+
if len(all_expected_sprites) == 0:
|
|
146
|
+
_no_sprites_error(character_name)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
sprites_to_remove = set()
|
|
150
|
+
for sprite in all_expected_sprites:
|
|
151
|
+
sprite_prefix = f"{character_name}-{sprite}"
|
|
152
|
+
idle_sprite_filename = f"{sprite_prefix}-idle.gif"
|
|
153
|
+
talk_sprite_filename = f"{sprite_prefix}-talk.gif"
|
|
154
|
+
|
|
155
|
+
idle_sprite = next(
|
|
156
|
+
(f for f in files_in_character_dir if f.name == idle_sprite_filename),
|
|
157
|
+
None,
|
|
158
|
+
)
|
|
159
|
+
talk_sprite = next(
|
|
160
|
+
(f for f in files_in_character_dir if f.name == talk_sprite_filename),
|
|
161
|
+
None,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if idle_sprite is None:
|
|
165
|
+
_sprite_not_found_error(
|
|
166
|
+
character_name,
|
|
167
|
+
idle_sprite_filename,
|
|
168
|
+
files_in_character_dir,
|
|
169
|
+
)
|
|
170
|
+
sprites_to_remove.add(character_name)
|
|
171
|
+
|
|
172
|
+
elif verify_sprites and not _sprite_is_usable(idle_sprite):
|
|
173
|
+
_sprite_corrupt_error(
|
|
174
|
+
character_name,
|
|
175
|
+
idle_sprite_filename,
|
|
176
|
+
)
|
|
177
|
+
sprites_to_remove.add(character_name)
|
|
178
|
+
|
|
179
|
+
if talk_sprite is None:
|
|
180
|
+
_sprite_not_found_error(
|
|
181
|
+
character_name,
|
|
182
|
+
talk_sprite_filename,
|
|
183
|
+
files_in_character_dir,
|
|
184
|
+
)
|
|
185
|
+
sprites_to_remove.add(character_name)
|
|
186
|
+
|
|
187
|
+
elif verify_sprites and not _sprite_is_usable(talk_sprite):
|
|
188
|
+
_sprite_corrupt_error(
|
|
189
|
+
character_name,
|
|
190
|
+
talk_sprite_filename,
|
|
191
|
+
)
|
|
192
|
+
sprites_to_remove.add(character_name)
|
|
193
|
+
|
|
194
|
+
# Remove bad sprites from all lists
|
|
195
|
+
for sprite_to_remove in sprites_to_remove:
|
|
196
|
+
while sprite_to_remove in neutral_sprites:
|
|
197
|
+
neutral_sprites.remove(sprite_to_remove)
|
|
198
|
+
while sprite_to_remove in positive_sprites:
|
|
199
|
+
positive_sprites.remove(sprite_to_remove)
|
|
200
|
+
while sprite_to_remove in negative_sprites:
|
|
201
|
+
negative_sprites.remove(sprite_to_remove)
|
|
202
|
+
while sprite_to_remove in spoiler_sprites:
|
|
203
|
+
spoiler_sprites.remove(sprite_to_remove)
|
|
204
|
+
|
|
205
|
+
# If the positive or negative lists are now empty, give them
|
|
206
|
+
# the neutral sprites (and vice versa)
|
|
207
|
+
if len(neutral_sprites) == 0:
|
|
208
|
+
neutral_sprites.extend(positive_sprites)
|
|
209
|
+
neutral_sprites.extend(negative_sprites)
|
|
210
|
+
if len(positive_sprites) == 0:
|
|
211
|
+
positive_sprites.extend(neutral_sprites)
|
|
212
|
+
if len(negative_sprites) == 0:
|
|
213
|
+
negative_sprites.extend(neutral_sprites)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Config file in characters folder gives high priority characters
|
|
217
|
+
try:
|
|
218
|
+
with open(join(ASSETS_FOLDER, CHARACTERS_FOLDER, "config.toml")) as config_file:
|
|
219
|
+
data = load(config_file)
|
|
220
|
+
high_priority_characters = data.get("high_priority", [])
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
high_priority_characters = []
|
|
223
|
+
|
|
224
|
+
for character in characters_to_remove:
|
|
225
|
+
del characters[character]
|
|
226
|
+
if character in high_priority_characters:
|
|
227
|
+
high_priority_characters.remove(character)
|
|
228
|
+
|
|
229
|
+
# Characters being children is specified in their config files
|
|
230
|
+
omit_for_adult_mode = [
|
|
231
|
+
c for c, data in characters.items() if data.get("is_child", False)
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"characters": characters,
|
|
236
|
+
"high_priority": high_priority_characters,
|
|
237
|
+
"omit_for_adult_mode": omit_for_adult_mode,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_music_data() -> dict:
|
|
242
|
+
# Find all of the music packs
|
|
243
|
+
music_packs = {}
|
|
244
|
+
folders = [f for f in scandir(join(ASSETS_FOLDER, MUSIC_FOLDER)) if f.is_dir()]
|
|
245
|
+
for f in folders:
|
|
246
|
+
try:
|
|
247
|
+
with open(join(f.path, "config.toml")) as config_file:
|
|
248
|
+
music_packs[f.name] = load(config_file)
|
|
249
|
+
except FileNotFoundError:
|
|
250
|
+
print(f'ERROR - Music folder "{f.name}" has no config.toml')
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
return music_packs
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from objectionengine2.ace_attorney_scene import (
|
|
2
|
+
AceAttorneyDirector,
|
|
3
|
+
DialogueBoxBuilder,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
from objectionengine2.beans.comment import Comment
|
|
7
|
+
from objectionengine2.parse_tags import DialoguePage
|
|
8
|
+
|
|
9
|
+
from rich import print
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.progress import (
|
|
13
|
+
Progress,
|
|
14
|
+
TextColumn,
|
|
15
|
+
BarColumn,
|
|
16
|
+
MofNCompleteColumn,
|
|
17
|
+
TaskProgressColumn,
|
|
18
|
+
TimeElapsedColumn,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from objectionengine2.utils import ensure_assets_are_available
|
|
22
|
+
|
|
23
|
+
def render_comment_list(
|
|
24
|
+
comment_list: list["Comment"],
|
|
25
|
+
output_filename: str = "output.mp4",
|
|
26
|
+
music_code: str = "pwr",
|
|
27
|
+
resolution_scale: int = 1,
|
|
28
|
+
assigned_characters: dict = None,
|
|
29
|
+
adult_mode: bool = False,
|
|
30
|
+
avoid_spoiler_sprites: bool = False,
|
|
31
|
+
):
|
|
32
|
+
ensure_assets_are_available()
|
|
33
|
+
with Progress(
|
|
34
|
+
TextColumn("[progress.description]{task.description}"),
|
|
35
|
+
BarColumn(),
|
|
36
|
+
TaskProgressColumn(),
|
|
37
|
+
MofNCompleteColumn(),
|
|
38
|
+
TimeElapsedColumn(),
|
|
39
|
+
) as progress:
|
|
40
|
+
process_comments_task = progress.add_task(
|
|
41
|
+
"Processing comments...", start=True, total=len(comment_list), visible=False
|
|
42
|
+
)
|
|
43
|
+
complete_pages_task = progress.add_task(
|
|
44
|
+
"Rendering video...", total=0, start=False, visible=False
|
|
45
|
+
)
|
|
46
|
+
composite_audio_task = progress.add_task(
|
|
47
|
+
"Rendering audio...", total=0, start=False, visible=False
|
|
48
|
+
)
|
|
49
|
+
composite_task = progress.add_task(
|
|
50
|
+
"Compositing...", total=0, start=False, visible=False
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def on_characters_cast(cast: dict):
|
|
54
|
+
try:
|
|
55
|
+
cast_table = Table("User ID", "Character ID", title="Cast")
|
|
56
|
+
for username, character in cast.items():
|
|
57
|
+
cast_table.add_row(username, character)
|
|
58
|
+
print(cast_table)
|
|
59
|
+
except:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def on_comment_processed(
|
|
63
|
+
comment_index: int, num_comments: int, comment: Comment
|
|
64
|
+
):
|
|
65
|
+
progress.update(
|
|
66
|
+
process_comments_task,
|
|
67
|
+
completed=comment_index + 1,
|
|
68
|
+
total=num_comments,
|
|
69
|
+
refresh=True,
|
|
70
|
+
visible=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def on_page_completed(
|
|
74
|
+
page_index,
|
|
75
|
+
num_pages: int,
|
|
76
|
+
page: DialoguePage,
|
|
77
|
+
time_to_render: float,
|
|
78
|
+
time_onscreen: float,
|
|
79
|
+
):
|
|
80
|
+
print(
|
|
81
|
+
f'"{page.get_raw_text():30.30s}"\t\tRender {time_to_render:3.2f} sec\t\tVisible {time_onscreen:3.2f} sec\t\t{time_to_render / time_onscreen:3.2f} SPS'
|
|
82
|
+
)
|
|
83
|
+
progress.start_task(complete_pages_task)
|
|
84
|
+
progress.update(
|
|
85
|
+
complete_pages_task,
|
|
86
|
+
completed=page_index + 1,
|
|
87
|
+
total=num_pages,
|
|
88
|
+
refresh=True,
|
|
89
|
+
visible=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def on_all_pages_completed():
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def on_audio_composite_progress(audio_index: int, num_audios: int, audio: dict):
|
|
96
|
+
progress.start_task(composite_audio_task)
|
|
97
|
+
progress.update(
|
|
98
|
+
composite_audio_task,
|
|
99
|
+
completed=audio_index + 1,
|
|
100
|
+
total=num_audios,
|
|
101
|
+
refresh=True,
|
|
102
|
+
visible=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def on_ffmpeg_started():
|
|
106
|
+
progress.start_task(composite_task)
|
|
107
|
+
progress.update(
|
|
108
|
+
composite_task, completed=0, total=1, refresh=True, visible=True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def on_ffmpeg_finished():
|
|
112
|
+
progress.update(composite_task, completed=1, refresh=True)
|
|
113
|
+
|
|
114
|
+
callbacks = {
|
|
115
|
+
"on_characters_cast": on_characters_cast,
|
|
116
|
+
"on_comment_processed": on_comment_processed,
|
|
117
|
+
"on_page_completed": on_page_completed,
|
|
118
|
+
"on_all_pages_completed": on_all_pages_completed,
|
|
119
|
+
"on_audio_composite_progress": on_audio_composite_progress,
|
|
120
|
+
"on_ffmpeg_started": on_ffmpeg_started,
|
|
121
|
+
"on_ffmpeg_finished": on_ffmpeg_finished,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
builder = DialogueBoxBuilder(callbacks=callbacks)
|
|
125
|
+
builder.render(
|
|
126
|
+
comment_list,
|
|
127
|
+
output_filename=output_filename,
|
|
128
|
+
music_code=music_code.lower(),
|
|
129
|
+
assigned_characters=assigned_characters,
|
|
130
|
+
adult_mode=adult_mode,
|
|
131
|
+
avoid_spoiler_sprites=avoid_spoiler_sprites,
|
|
132
|
+
resolution_scale=resolution_scale
|
|
133
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from math import cos, pi
|
|
2
|
+
|
|
3
|
+
def lerp(a: float, b: float, t: float) -> float:
|
|
4
|
+
return (1.0 - t) * a + b * t
|
|
5
|
+
|
|
6
|
+
def inv_lerp(a: float, b: float, v: float) -> float:
|
|
7
|
+
return (v - a) / (b - a)
|
|
8
|
+
|
|
9
|
+
def remap(a_in: float, b_in: float, a_out: float, b_out: float, v: float, func: callable = None):
|
|
10
|
+
t = inv_lerp(a_in, b_in, v)
|
|
11
|
+
if func is not None:
|
|
12
|
+
t = func(t)
|
|
13
|
+
return lerp(a_out, b_out, t)
|
|
14
|
+
|
|
15
|
+
def ease_in_out_sine(x: int):
|
|
16
|
+
return -(cos(pi * x) - 1.0) / 2.0
|
|
17
|
+
|
|
18
|
+
def ease_in_out_quint(x: int):
|
|
19
|
+
return (16 * x ** 5) if x < 0.5 else 1 - ((-2 * x + 2) ** 5) / 2
|
|
20
|
+
|
|
21
|
+
def ease_in_out_cubic(x: int):
|
|
22
|
+
return 4 * x * x * x if x < 0.5 else 1 - ((-2 * x + 2) ** 3) / 2
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from re import compile
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from objectionengine2.font_tools import get_best_font, split_str_into_newlines, split_with_joined_sentences, get_text_width
|
|
5
|
+
from .font_constants import FONT_ARRAY
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DialogueTag:
|
|
9
|
+
name: str = ""
|
|
10
|
+
start: int = 0
|
|
11
|
+
end: int = 0
|
|
12
|
+
|
|
13
|
+
def range(self):
|
|
14
|
+
return range(self.start, self.end)
|
|
15
|
+
|
|
16
|
+
class BaseDialogueItem:
|
|
17
|
+
completed: bool = False
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return f"BaseDialogueItem()"
|
|
21
|
+
|
|
22
|
+
class DialogueAction(BaseDialogueItem):
|
|
23
|
+
name: str = ""
|
|
24
|
+
index: int = -1
|
|
25
|
+
|
|
26
|
+
def __init__(self, name: str, index: str):
|
|
27
|
+
self.name = name
|
|
28
|
+
self.index = index
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"DialogueAction(\'{self.name}\', {self.index})"
|
|
32
|
+
|
|
33
|
+
class DialogueTextLineBreak(BaseDialogueItem):
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"DialogueTextLineBreak()"
|
|
36
|
+
|
|
37
|
+
class DialogueTextChunk(BaseDialogueItem):
|
|
38
|
+
text: str = ""
|
|
39
|
+
tags: list[str] = field(default_factory=list)
|
|
40
|
+
position: int = 0
|
|
41
|
+
|
|
42
|
+
def __init__(self, text: str, tags: list[str]):
|
|
43
|
+
self.text = text
|
|
44
|
+
self.tags = tags
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self.text)
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"DialogueTextChunk(\'{self.text}\', {self.tags}, {self.position})"
|
|
51
|
+
|
|
52
|
+
class DialoguePage:
|
|
53
|
+
commands: list[BaseDialogueItem]
|
|
54
|
+
current_item: int
|
|
55
|
+
|
|
56
|
+
def __init__(self, commands: list[BaseDialogueItem]):
|
|
57
|
+
self.commands = commands
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"DialoguePage({self.commands})"
|
|
61
|
+
|
|
62
|
+
def get_current_item(self):
|
|
63
|
+
for command in self.commands:
|
|
64
|
+
if (command is not None) and (not command.completed):
|
|
65
|
+
return command
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
lens = []
|
|
70
|
+
for command in self.commands:
|
|
71
|
+
if isinstance(command, DialogueTextChunk):
|
|
72
|
+
lens.append(len(command))
|
|
73
|
+
return sum(lens)
|
|
74
|
+
|
|
75
|
+
def get_raw_text(self) -> str:
|
|
76
|
+
texts = []
|
|
77
|
+
for command in self.commands:
|
|
78
|
+
if isinstance(command, DialogueTextChunk):
|
|
79
|
+
texts.append(command.text)
|
|
80
|
+
return ''.join(texts)
|
|
81
|
+
|
|
82
|
+
def get_visible_text(self, visible_chars: int = 10) -> 'DialoguePage':
|
|
83
|
+
"""
|
|
84
|
+
Iterate through each line, and within each line, its chunks.
|
|
85
|
+
Count the characters and keep adding to a variable.
|
|
86
|
+
Once the variable hits visible_chars, stop counting.
|
|
87
|
+
The chunks we read should be added to a list, and the chunk we're reading right now
|
|
88
|
+
should be cut off to its correct length before being added.
|
|
89
|
+
"""
|
|
90
|
+
read_everything = True
|
|
91
|
+
chars_remaining = visible_chars
|
|
92
|
+
new_lines = []
|
|
93
|
+
for line in self.commands:
|
|
94
|
+
new_line = []
|
|
95
|
+
for chunk in line:
|
|
96
|
+
if chars_remaining >= len(chunk):
|
|
97
|
+
new_line.append(deepcopy(chunk))
|
|
98
|
+
chars_remaining -= len(chunk)
|
|
99
|
+
elif chars_remaining > 0:
|
|
100
|
+
partial_copy = deepcopy(chunk)
|
|
101
|
+
partial_copy.text = partial_copy.text[:chars_remaining]
|
|
102
|
+
new_line.append(partial_copy)
|
|
103
|
+
chars_remaining = 0
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
break
|
|
107
|
+
new_lines.append(new_line)
|
|
108
|
+
if chars_remaining <= 0:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
d = DialoguePage(new_lines)
|
|
112
|
+
# print("output from get_visible_text", d)
|
|
113
|
+
return d
|
|
114
|
+
|
|
115
|
+
def condense_chunks(self) -> 'DialoguePage':
|
|
116
|
+
chunks = []
|
|
117
|
+
current_string = ""
|
|
118
|
+
current_tags = None
|
|
119
|
+
for chunk in self.commands:
|
|
120
|
+
if isinstance(chunk, DialogueTextChunk) and (current_tags is None or chunk.tags == current_tags):
|
|
121
|
+
current_string += chunk.text
|
|
122
|
+
if current_tags is None:
|
|
123
|
+
current_tags = chunk.tags.copy()
|
|
124
|
+
elif isinstance(chunk, DialogueTextChunk):
|
|
125
|
+
chunks.append(DialogueTextChunk(current_string, current_tags))
|
|
126
|
+
current_string = chunk.text
|
|
127
|
+
current_tags = chunk.tags.copy()
|
|
128
|
+
elif isinstance(chunk, DialogueAction):
|
|
129
|
+
if len(current_string) > 0:
|
|
130
|
+
chunks.append(DialogueTextChunk(current_string, current_tags))
|
|
131
|
+
chunks.append(chunk)
|
|
132
|
+
current_string = ""
|
|
133
|
+
current_tags = None
|
|
134
|
+
elif isinstance(chunk, DialogueTextLineBreak):
|
|
135
|
+
if len(current_string) > 0:
|
|
136
|
+
chunks.append(DialogueTextChunk(current_string, current_tags))
|
|
137
|
+
chunks.append(chunk)
|
|
138
|
+
current_string = ""
|
|
139
|
+
current_tags = None
|
|
140
|
+
if len(current_string) > 0:
|
|
141
|
+
chunks.append(DialogueTextChunk(current_string, current_tags))
|
|
142
|
+
|
|
143
|
+
d = DialoguePage(chunks)
|
|
144
|
+
# print("condensed chunks:", d)
|
|
145
|
+
return d
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class DialogueTextContent:
|
|
149
|
+
cleaned_lines: str
|
|
150
|
+
tags: list[DialogueTag]
|
|
151
|
+
actions: list[DialogueAction]
|
|
152
|
+
|
|
153
|
+
def get_text_chunks(self) -> list[DialoguePage]:
|
|
154
|
+
pages = []
|
|
155
|
+
current_position = 0
|
|
156
|
+
for box_text in split_with_joined_sentences(self.cleaned_lines):
|
|
157
|
+
splitter_font_path = get_best_font(box_text, FONT_ARRAY)['path']
|
|
158
|
+
wrapped_box_lines = split_str_into_newlines(box_text, splitter_font_path, 15).split('\n')
|
|
159
|
+
chunks: list[list[DialogueTextChunk]] = []
|
|
160
|
+
|
|
161
|
+
for line in wrapped_box_lines:
|
|
162
|
+
for char in line:
|
|
163
|
+
# print(f"Processing char {char} at position {current_position}")
|
|
164
|
+
# First, process actions
|
|
165
|
+
for action in [a for a in self.actions if a.index == current_position]:
|
|
166
|
+
# print(f"Action {action} takes place at this position, so insert it")
|
|
167
|
+
chunks.append(action)
|
|
168
|
+
|
|
169
|
+
this_char_tags: list[str] = []
|
|
170
|
+
for tag in self.tags:
|
|
171
|
+
if current_position in tag.range():
|
|
172
|
+
this_char_tags.append(tag.name)
|
|
173
|
+
chunks.append(DialogueTextChunk(char, this_char_tags))
|
|
174
|
+
current_position += 1
|
|
175
|
+
|
|
176
|
+
chunks.append(DialogueTextLineBreak())
|
|
177
|
+
new_page = DialoguePage(chunks).condense_chunks()
|
|
178
|
+
pages.append(new_page)
|
|
179
|
+
|
|
180
|
+
return pages
|
|
181
|
+
|
|
182
|
+
__tag_matcher = compile(r"\[(.*?)\]")
|
|
183
|
+
def parse_line(text: str) -> DialoguePage:
|
|
184
|
+
current_line_width = 0.0
|
|
185
|
+
|
|
186
|
+
page_content: list[BaseDialogueItem] = []
|
|
187
|
+
|
|
188
|
+
remaining_text = text
|
|
189
|
+
while len(remaining_text) > 0:
|
|
190
|
+
re_match = __tag_matcher.match(remaining_text)
|
|
191
|
+
if re_match is not None:
|
|
192
|
+
# Tag found at beginning of string, let's get it
|
|
193
|
+
match_content = re_match.group(1)
|
|
194
|
+
|
|
195
|
+
if match_content == "br":
|
|
196
|
+
page_content.append(DialogueTextLineBreak())
|
|
197
|
+
else:
|
|
198
|
+
page_content.append(DialogueAction(match_content, 0))
|
|
199
|
+
remaining_text = remaining_text[len(re_match.group(0)):]
|
|
200
|
+
else:
|
|
201
|
+
char = remaining_text[0]
|
|
202
|
+
page_content.append(DialogueTextChunk(char, []))
|
|
203
|
+
|
|
204
|
+
# Uncomment for auto line breaking (but its stinky)
|
|
205
|
+
# char_width = get_text_width(char, font=get_best_font(char, FONT_ARRAY))
|
|
206
|
+
# current_line_width += char_width
|
|
207
|
+
# if current_line_width > MAX_WIDTH:
|
|
208
|
+
# page_content.append(DialogueTextLineBreak())
|
|
209
|
+
# current_line_width = 0.0
|
|
210
|
+
|
|
211
|
+
remaining_text = remaining_text[1:]
|
|
212
|
+
|
|
213
|
+
return page_content
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .make_movie import render_comment_list
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .ace_attorney_scene import get_sprite_tag
|
|
2
|
+
B_M = "<startblip male/>"
|
|
3
|
+
B_F = "<startblip female/>"
|
|
4
|
+
B_ST = "<stopblip/>"
|
|
5
|
+
|
|
6
|
+
SPR_PHX_NORMAL_T = B_M + get_sprite_tag('left', 'phoenix', 'normal-talk')
|
|
7
|
+
SPR_PHX_NORMAL_I = B_ST + get_sprite_tag('left', 'phoenix', 'normal-idle')
|
|
8
|
+
SPR_PHX_SWEAT_T = B_M + get_sprite_tag('left', 'phoenix', 'sweating-talk')
|
|
9
|
+
SPR_PHX_SWEAT_I = B_ST + get_sprite_tag('left', 'phoenix', 'sweating-idle')
|
|
10
|
+
|
|
11
|
+
SPR_EDW_NORMAL_T = B_M + get_sprite_tag('right', 'edgeworth', 'normal-talk')
|
|
12
|
+
SPR_EDW_NORMAL_I = B_ST + get_sprite_tag('right', 'edgeworth', 'normal-idle')
|
|
13
|
+
|
|
14
|
+
SLAM_PHX = B_ST + "<deskslam phoenix/><wait 0.8/>"
|
|
15
|
+
SLAM_EDW = B_ST + "<deskslam edgeworth/><wait 0.8/>"
|
|
16
|
+
|
|
17
|
+
OBJ_PHX = B_ST + "<bubble objection phoenix/><wait 0.8/>"
|
|
18
|
+
OBJ_EDW = B_ST + "<bubble objection edgeworth/><wait 0.8/>"
|
|
19
|
+
HDI_PHX = B_ST + "<bubble holdit phoenix/><wait 0.8/>"
|
|
20
|
+
|
|
21
|
+
END_BOX = "<showarrow/><wait 2/><hidearrow/><sound pichoop/><wait 0.3/>"
|
|
22
|
+
|
|
23
|
+
SHAKE = "<shake 3 0.3/>"
|
|
24
|
+
S_DRAMAPOUND = f"<sound dramapound/><flash 0.15/>{SHAKE}"
|
|
25
|
+
S_SMACK = f"<sound smack/><flash 0.15/>{SHAKE}"
|