objectionengine2 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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}"