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,22 @@
|
|
|
1
|
+
class Comment:
|
|
2
|
+
def __init__(self,
|
|
3
|
+
user_id: str = None,
|
|
4
|
+
user_name: str = 'Prosecutor',
|
|
5
|
+
text_content: str = '...',
|
|
6
|
+
evidence_path: str = None,
|
|
7
|
+
score: float = 0
|
|
8
|
+
):
|
|
9
|
+
"""
|
|
10
|
+
All arguments are optional
|
|
11
|
+
:param str user_id: An unique identifier to tell users appart
|
|
12
|
+
:param str evidence_path: Path pointing to an image to be used as evidence
|
|
13
|
+
:param str user_name: Name of the user
|
|
14
|
+
:param str text_content: Text to display
|
|
15
|
+
:param float score: Positiveness of a commentary (negative below zero, positive above zero)
|
|
16
|
+
"""
|
|
17
|
+
self.user_name = user_name
|
|
18
|
+
self.user_id = user_id
|
|
19
|
+
self.text_content = text_content
|
|
20
|
+
self.score = score
|
|
21
|
+
self.evidence_path = evidence_path
|
|
22
|
+
self.effective_user_id = self.user_id or self.user_name
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from typing import Optional, List
|
|
2
|
+
from objectionengine2.font_constants import FONT_ARRAY
|
|
3
|
+
from objectionengine2.font_tools import get_font_score
|
|
4
|
+
|
|
5
|
+
def is_renderable(text: str, font_array: Optional[List[dict]] = None) -> bool:
|
|
6
|
+
score = 0
|
|
7
|
+
for font in FONT_ARRAY if font_array is None else font_array:
|
|
8
|
+
score = max(score, get_font_score(font, text))
|
|
9
|
+
return score >= len(text)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Per-user cache paths for downloaded Objection Engine data."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_cache_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cache_path() -> Path:
|
|
9
|
+
"""Return the platform's cache directory for objectionengine2."""
|
|
10
|
+
return user_cache_path("objectionengine2", appauthor=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def assets_path() -> Path:
|
|
14
|
+
return cache_path() / "assets"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def models_path() -> Path:
|
|
18
|
+
return cache_path() / "models"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from objectionengine2.parse_tags import DialogueAction
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def compose_gavel_slam(
|
|
5
|
+
num_slams: int = 3,
|
|
6
|
+
delay_between_slams: float = 0.17,
|
|
7
|
+
finish_wait_time: float = 0.766,
|
|
8
|
+
):
|
|
9
|
+
if num_slams <= 0:
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
if num_slams == 1:
|
|
13
|
+
return [
|
|
14
|
+
DialogueAction("hidebox", 0),
|
|
15
|
+
DialogueAction("cut gavel", 0),
|
|
16
|
+
DialogueAction("gavel 0", 0),
|
|
17
|
+
DialogueAction("wait 0.3", 0),
|
|
18
|
+
DialogueAction("gavel 1", 0),
|
|
19
|
+
DialogueAction("wait 0.04", 0),
|
|
20
|
+
DialogueAction("gavel 2", 0),
|
|
21
|
+
DialogueAction("wait 0.04", 0),
|
|
22
|
+
DialogueAction("gavel 3", 0),
|
|
23
|
+
DialogueAction("sound gavel", 0),
|
|
24
|
+
DialogueAction("shake 3 0.2", 0),
|
|
25
|
+
DialogueAction(f"wait {finish_wait_time}", 0),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
commands = [
|
|
29
|
+
DialogueAction("hidebox", 0),
|
|
30
|
+
DialogueAction("cut gavel", 0),
|
|
31
|
+
DialogueAction("gavel 0", 0),
|
|
32
|
+
DialogueAction("wait 0.300", 0),
|
|
33
|
+
DialogueAction("gavel 1", 0),
|
|
34
|
+
DialogueAction("wait 0.04", 0),
|
|
35
|
+
DialogueAction("gavel 3", 0),
|
|
36
|
+
DialogueAction("sound gavel", 0),
|
|
37
|
+
DialogueAction("shake 3 0.2", 0),
|
|
38
|
+
DialogueAction(f"wait {delay_between_slams}", 0),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# First and last slams have different duration
|
|
42
|
+
for _ in range(num_slams - 2):
|
|
43
|
+
commands.extend(
|
|
44
|
+
[
|
|
45
|
+
DialogueAction("gavel 2", 0),
|
|
46
|
+
DialogueAction("wait 0.04", 0),
|
|
47
|
+
DialogueAction("gavel 1", 0),
|
|
48
|
+
DialogueAction("wait 0.17", 0),
|
|
49
|
+
DialogueAction("gavel 3", 0),
|
|
50
|
+
DialogueAction("sound gavel", 0),
|
|
51
|
+
DialogueAction("shake 3 0.2", 0),
|
|
52
|
+
DialogueAction(f"wait {delay_between_slams}", 0),
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Last slam
|
|
57
|
+
commands.extend(
|
|
58
|
+
[
|
|
59
|
+
DialogueAction("gavel 2", 0),
|
|
60
|
+
DialogueAction("wait 0.04", 0),
|
|
61
|
+
DialogueAction("gavel 1", 0),
|
|
62
|
+
DialogueAction("wait 0.17", 0),
|
|
63
|
+
DialogueAction("gavel 2", 0),
|
|
64
|
+
DialogueAction("wait 0.04", 0),
|
|
65
|
+
DialogueAction("gavel 3", 0),
|
|
66
|
+
DialogueAction("sound gavel", 0),
|
|
67
|
+
DialogueAction("shake 3 0.2", 0),
|
|
68
|
+
DialogueAction(f"wait {finish_wait_time}", 0),
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return commands
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from objectionengine2.parse_tags import DialogueAction
|
|
2
|
+
|
|
3
|
+
def compose_verdict(
|
|
4
|
+
text: str,
|
|
5
|
+
slam_group: str = "letter",
|
|
6
|
+
color: str = "white",
|
|
7
|
+
slam_delay: float = 0.0,
|
|
8
|
+
finish_wait_time: float = 2.0,
|
|
9
|
+
):
|
|
10
|
+
if slam_group not in ["letter", "word"]:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
f'slam_group "{slam_group}" is not valid; must be "letter" or "word"'
|
|
13
|
+
)
|
|
14
|
+
if color not in ["black", "white"]:
|
|
15
|
+
raise ValueError(f'color "{color}" is not valid; must be "black" or "white"')
|
|
16
|
+
|
|
17
|
+
output = []
|
|
18
|
+
output.append(DialogueAction(f'verdict set "{text}" {color}', 0))
|
|
19
|
+
|
|
20
|
+
slam_delay_command = f"wait {slam_delay}"
|
|
21
|
+
if slam_group == "letter":
|
|
22
|
+
for i in range(len(text)):
|
|
23
|
+
output.extend(
|
|
24
|
+
[
|
|
25
|
+
DialogueAction(f"verdict show {i}", 0),
|
|
26
|
+
DialogueAction("wait 0.2", 0),
|
|
27
|
+
DialogueAction("sound guilty", 0),
|
|
28
|
+
DialogueAction("shake 4 0.1", 0),
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
if i != len(text) - 1:
|
|
32
|
+
output.append(DialogueAction(slam_delay_command, 0))
|
|
33
|
+
|
|
34
|
+
elif slam_group == "word":
|
|
35
|
+
for i in range(len(text)):
|
|
36
|
+
output.append(DialogueAction(f"verdict show {i}", 0))
|
|
37
|
+
if text[i].isspace():
|
|
38
|
+
output.extend(
|
|
39
|
+
[
|
|
40
|
+
DialogueAction("wait 0.2", 0),
|
|
41
|
+
DialogueAction("sound guilty", 0),
|
|
42
|
+
DialogueAction("shake 4 0.1", 0),
|
|
43
|
+
DialogueAction(slam_delay_command, 0),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
output.extend(
|
|
47
|
+
[
|
|
48
|
+
DialogueAction("wait 0.2", 0),
|
|
49
|
+
DialogueAction("sound guilty", 0),
|
|
50
|
+
DialogueAction("shake 4 0.1", 0),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
output.append(DialogueAction(f"wait {finish_wait_time}", 0))
|
|
55
|
+
output.append(DialogueAction("verdict clear", 0))
|
|
56
|
+
return output
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
from objectionengine2.loading import ASSETS_FOLDER
|
|
3
|
+
|
|
4
|
+
class TextType(IntEnum):
|
|
5
|
+
DIALOGUE = 0
|
|
6
|
+
NAME = 1
|
|
7
|
+
|
|
8
|
+
FONT_ARRAY = [
|
|
9
|
+
# AA-Like > Pixel > Generic
|
|
10
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari-cyrillic/igiari-cyrillic.ttf'},
|
|
11
|
+
# AA-like, Latin, hiragana, katakana, (part of) cyrillic
|
|
12
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/Igiari.ttf'},
|
|
13
|
+
# AA-like, Latin, hiragana, katakana, (part of) cyrillic
|
|
14
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/Galmuri11.ttf'},
|
|
15
|
+
# Pixel, Kanji, Hiragana, Katakana
|
|
16
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/jackeyfont.ttf'},
|
|
17
|
+
# Arabic
|
|
18
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/arabic-1.ttf', 'size': 12, 'offset': {TextType.NAME: (0, -5)}, 'rtl': True},
|
|
19
|
+
# Pixel-font, Hebrew
|
|
20
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/STANRG__.ttf'},
|
|
21
|
+
# Generic
|
|
22
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/NotoSans-Regular.ttf'},
|
|
23
|
+
# Pixel font, Arabic
|
|
24
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/igiari/bitsy-font-with-arabic.ttf', 'size': 10, 'rtl': True},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
NAMETAG_FONT_ARRAY = [
|
|
28
|
+
{'path': f'{ASSETS_FOLDER}/textbox/font/ace-name/ace-name.ttf', 'size': 8}
|
|
29
|
+
] + FONT_ARRAY
|
|
30
|
+
|
|
31
|
+
TEXT_COLORS = {
|
|
32
|
+
"red": (240, 112, 56),
|
|
33
|
+
"blue": (104, 192, 240),
|
|
34
|
+
"green": (0, 240, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
MAX_LINE_WIDTH = 220
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from .font_constants import FONT_ARRAY
|
|
2
|
+
from PIL import ImageFont
|
|
3
|
+
from typing import List, Dict, Union
|
|
4
|
+
from textwrap import wrap
|
|
5
|
+
import spacy
|
|
6
|
+
|
|
7
|
+
nlp = spacy.blank("xx")
|
|
8
|
+
nlp.add_pipe('sentencizer')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def split_sentences(text: str) -> list[str]:
|
|
12
|
+
"""Split dialogue without requiring Polyglot language data."""
|
|
13
|
+
sentences = [span.text.strip() for span in nlp(text).sents]
|
|
14
|
+
return [sentence for sentence in sentences if sentence] or [text]
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from fontTools.ttLib import TTFont
|
|
18
|
+
except:
|
|
19
|
+
from fonttools.ttLib import TTFont
|
|
20
|
+
|
|
21
|
+
def get_text_width(text, font_size = 15, font = None):
|
|
22
|
+
if font is None:
|
|
23
|
+
font = get_best_font(text, FONT_ARRAY)
|
|
24
|
+
font_path = font['path']
|
|
25
|
+
font_obj = ImageFont.truetype(font_path, font_size)
|
|
26
|
+
return font_obj.getlength(text)
|
|
27
|
+
|
|
28
|
+
def get_font_score(font, text):
|
|
29
|
+
font_path = font['path']
|
|
30
|
+
font = TTFont(font_path)
|
|
31
|
+
|
|
32
|
+
# We check all chars for presence on the font
|
|
33
|
+
valid_chars = 0
|
|
34
|
+
for char in text:
|
|
35
|
+
# We check if the char is in any table of the font
|
|
36
|
+
for table in font['cmap'].tables:
|
|
37
|
+
if ord(char) in table.cmap:
|
|
38
|
+
valid_chars += 1
|
|
39
|
+
break
|
|
40
|
+
return valid_chars
|
|
41
|
+
|
|
42
|
+
def get_best_font(text, font_array):
|
|
43
|
+
best_font = font_array[-1]
|
|
44
|
+
best_font_points = 0
|
|
45
|
+
text = text.replace('\n', '').replace('\r', '').replace('\t', '').replace('\u200B', '')
|
|
46
|
+
for font in font_array:
|
|
47
|
+
pts = get_font_score(font, text)
|
|
48
|
+
if pts > best_font_points:
|
|
49
|
+
best_font_points = pts
|
|
50
|
+
best_font = font
|
|
51
|
+
if best_font_points >= len(text):
|
|
52
|
+
return font
|
|
53
|
+
print(f'WARNING. NO OPTIMAL FONT FOUND, font: {best_font}, font score: {best_font_points}/{len(text)}, text \"{text}\"\n')
|
|
54
|
+
return best_font
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fit_words_within_width(words: Union[list[str], str], font: ImageFont.FreeTypeFont, insert_space: bool):
|
|
58
|
+
new_text = ""
|
|
59
|
+
space = " " if insert_space else ""
|
|
60
|
+
for word in words:
|
|
61
|
+
last_sentence = new_text.split("\n")[-1] + word + space
|
|
62
|
+
if font.getlength(text=last_sentence) >= 240:
|
|
63
|
+
if new_text.split("\n")[-1] != "":
|
|
64
|
+
new_text += "\n"
|
|
65
|
+
new_text += fit_words_within_width(word, font, False) + space
|
|
66
|
+
else:
|
|
67
|
+
new_text += word + space
|
|
68
|
+
|
|
69
|
+
return new_text
|
|
70
|
+
|
|
71
|
+
def split_str_into_newlines(text: str, font_path, font_size):
|
|
72
|
+
font = ImageFont.truetype(font_path, font_size)
|
|
73
|
+
words = text.split(" ")
|
|
74
|
+
return fit_words_within_width(words, font, True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def split_with_joined_sentences(text: str):
|
|
78
|
+
"""
|
|
79
|
+
"""
|
|
80
|
+
tokens = nlp(text)
|
|
81
|
+
sentences = [sent.text.strip() for sent in tokens.sents]
|
|
82
|
+
joined_sentences = []
|
|
83
|
+
i = 0
|
|
84
|
+
while i < len(sentences):
|
|
85
|
+
sentence = sentences[i]
|
|
86
|
+
if len(sentence) > 85: # Long sentences should be wrapped to multiple shorter lines
|
|
87
|
+
text_chunks = [chunk for chunk in wrap(sentence, 85)]
|
|
88
|
+
joined_sentences = [*joined_sentences, *text_chunks]
|
|
89
|
+
i += 1
|
|
90
|
+
else:
|
|
91
|
+
if i + 1 < len(sentences) and len(f"{sentence} {sentences[i+1]}") <= 85: # Maybe we can join two different sentences
|
|
92
|
+
joined_sentences.append(sentence + " " + sentences[i+1])
|
|
93
|
+
i += 2
|
|
94
|
+
else:
|
|
95
|
+
joined_sentences.append(sentence)
|
|
96
|
+
i += 1
|
|
97
|
+
|
|
98
|
+
return joined_sentences
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from objectionengine2.loading import ASSETS_FOLDER, _print_note
|
|
2
|
+
from objectionengine2.MovieKit import ImageObject, SceneObject
|
|
3
|
+
from os.path import join, exists
|
|
4
|
+
|
|
5
|
+
GAVEL_SLAM_PATH = join(ASSETS_FOLDER, "gavel_slam")
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
https://www.youtube.com/watch?v=XBR35H_ya4g as reference
|
|
9
|
+
Single slam at 9:53
|
|
10
|
+
0 - 9 frames (0.300s)
|
|
11
|
+
1 - 1 frame (0.033s)
|
|
12
|
+
2 - 1 frame (0.033s)
|
|
13
|
+
3 - 23 frames (0.766s)
|
|
14
|
+
|
|
15
|
+
# Triple slam at 11:07
|
|
16
|
+
0 - 9 frames (0.300s)
|
|
17
|
+
1 - 1 frame (0.033s)
|
|
18
|
+
3 - 5 frames (0.166s)
|
|
19
|
+
2 - 1 frame (0.033s)
|
|
20
|
+
1 - 5 frames (0.166s)
|
|
21
|
+
3 - 5 frames (0.166s)
|
|
22
|
+
2 - 1 frame (0.033s)
|
|
23
|
+
1 - 5 frames (0.166s)
|
|
24
|
+
2 - 1 frame (0.033s)
|
|
25
|
+
3 - 23 frames (0.766s)
|
|
26
|
+
|
|
27
|
+
NOTE: using 0.033 for to indicate 1 frame didn't seem to work (it would simply
|
|
28
|
+
skip the frame) - instead, I rounded up fo 0.04.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
class GavelSlamObject(SceneObject):
|
|
32
|
+
def __init__(self, parent: SceneObject = None, name: str = "", pos: tuple[int, int, int] = (0, 0, 0)):
|
|
33
|
+
super().__init__(parent, name, pos)
|
|
34
|
+
if not exists(GAVEL_SLAM_PATH):
|
|
35
|
+
_print_note(
|
|
36
|
+
f"Gavel slam assets not found at {GAVEL_SLAM_PATH}"
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
self.bg_img = ImageObject(
|
|
41
|
+
parent=self,
|
|
42
|
+
name="Gavel Slam BG",
|
|
43
|
+
pos=(0, 0, 11),
|
|
44
|
+
filepath=join(GAVEL_SLAM_PATH, "gavel_slam_bg.png"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.block_img = ImageObject(
|
|
48
|
+
parent=self,
|
|
49
|
+
name="Gavel Slam Block",
|
|
50
|
+
pos=(0, 0, 11),
|
|
51
|
+
filepath=join(GAVEL_SLAM_PATH, "gavel_slam_block.png"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self.gavel_frames = [
|
|
55
|
+
ImageObject(
|
|
56
|
+
parent=self,
|
|
57
|
+
name="Gavel Frame 1",
|
|
58
|
+
pos=(0, 0, 11),
|
|
59
|
+
filepath=join(GAVEL_SLAM_PATH, "gavel_slam_gavel_1.png"),
|
|
60
|
+
),
|
|
61
|
+
ImageObject(
|
|
62
|
+
parent=self,
|
|
63
|
+
name="Gavel Frame 2",
|
|
64
|
+
pos=(0, 0, 11),
|
|
65
|
+
filepath=join(GAVEL_SLAM_PATH, "gavel_slam_gavel_2.png"),
|
|
66
|
+
),
|
|
67
|
+
ImageObject(
|
|
68
|
+
parent=self,
|
|
69
|
+
name="Gavel Frame 3",
|
|
70
|
+
pos=(0, 0, 11),
|
|
71
|
+
filepath=join(GAVEL_SLAM_PATH, "gavel_slam_gavel_3.png"),
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
self.set_gavel_frame(0)
|
|
75
|
+
|
|
76
|
+
def set_gavel_frame(self, frame: int = 0):
|
|
77
|
+
for f in self.gavel_frames: f.hide()
|
|
78
|
+
if frame <= 0 or frame > len(self.gavel_frames):
|
|
79
|
+
return
|
|
80
|
+
self.gavel_frames[frame - 1].show()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from objectionengine2.loading import ASSETS_FOLDER, _print_note
|
|
2
|
+
from objectionengine2.math_helpers import lerp
|
|
3
|
+
from .MovieKit import SceneObject
|
|
4
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
5
|
+
from os.path import exists
|
|
6
|
+
|
|
7
|
+
VERDICT_FONT_SIZE = 70
|
|
8
|
+
VERDICT_FONT_START_SIZE_RATIO = 8 / 3
|
|
9
|
+
VERDICT_STROKE_WIDTH_RATIO = 1 / 14
|
|
10
|
+
VERDICT_STROKE_WIDTH = int(VERDICT_FONT_SIZE * VERDICT_STROKE_WIDTH_RATIO)
|
|
11
|
+
|
|
12
|
+
VERDICT_MAX_WIDTH = 230
|
|
13
|
+
VERDICT_SLAM_SPEED = 8.0
|
|
14
|
+
|
|
15
|
+
VERDICT_COLORS = {
|
|
16
|
+
"black": {"fill": (0, 0, 0), "stroke": (255, 255, 255)},
|
|
17
|
+
"white": {"fill": (255, 255, 255), "stroke": (0, 0, 0)},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
VERDICT_FONT_PATH = f"{ASSETS_FOLDER}/verdict/DFMinchoStd-W12.otf"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JudgeVerdictTextObject(SceneObject):
|
|
24
|
+
def __init__(self, parent: SceneObject = None, name: str = ""):
|
|
25
|
+
super().__init__(parent, name, (0, 0, 10))
|
|
26
|
+
self.font = None
|
|
27
|
+
|
|
28
|
+
# If the verdict font isn't available, then don't crash, but don't
|
|
29
|
+
# render anything
|
|
30
|
+
if not exists(VERDICT_FONT_PATH):
|
|
31
|
+
_print_note(
|
|
32
|
+
f"Verdict font not found at {VERDICT_FONT_PATH}, so verdict text will not be rendered"
|
|
33
|
+
)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
self.font = ImageFont.truetype(VERDICT_FONT_PATH, VERDICT_FONT_SIZE)
|
|
37
|
+
self._text = ""
|
|
38
|
+
self.clear()
|
|
39
|
+
|
|
40
|
+
def get_text_bbox(self, text):
|
|
41
|
+
if self.font is None:
|
|
42
|
+
return (0, 0)
|
|
43
|
+
|
|
44
|
+
bb = self.font.getbbox(text)
|
|
45
|
+
x1, y1, x2, y2 = bb
|
|
46
|
+
|
|
47
|
+
padding = VERDICT_STROKE_WIDTH
|
|
48
|
+
x1 -= padding
|
|
49
|
+
y1 -= padding
|
|
50
|
+
x2 += padding
|
|
51
|
+
y2 += padding
|
|
52
|
+
h = y2 - y1
|
|
53
|
+
w = x2 - x1
|
|
54
|
+
|
|
55
|
+
return (w, h)
|
|
56
|
+
|
|
57
|
+
def clear(self):
|
|
58
|
+
if self.font is None:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
self.set_text("")
|
|
62
|
+
|
|
63
|
+
def set_text(self, text: str, text_color: str = None):
|
|
64
|
+
if self.font is None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
self._text = text
|
|
68
|
+
|
|
69
|
+
w, h = self.get_text_bbox(self._text)
|
|
70
|
+
|
|
71
|
+
x_squish = min(1.0, VERDICT_MAX_WIDTH / w)
|
|
72
|
+
chars = []
|
|
73
|
+
for i, c in enumerate(self._text):
|
|
74
|
+
next_char = "" if i == len(self._text) - 1 else self._text[i + 1]
|
|
75
|
+
next_char_width = self.font.getlength(next_char)
|
|
76
|
+
char_width = self.font.getlength(c + next_char) - next_char_width
|
|
77
|
+
|
|
78
|
+
# For each character, make an image and store it along with the character
|
|
79
|
+
# it holds and its getlength value
|
|
80
|
+
char_img = Image.new("RGBA", self.get_text_bbox(c), (255, 0, 255, 0))
|
|
81
|
+
char_img_ctx = ImageDraw.Draw(char_img)
|
|
82
|
+
|
|
83
|
+
if text_color not in VERDICT_COLORS:
|
|
84
|
+
text_color = "black"
|
|
85
|
+
args = {
|
|
86
|
+
"xy": (VERDICT_STROKE_WIDTH, VERDICT_STROKE_WIDTH),
|
|
87
|
+
"text": c,
|
|
88
|
+
"fill": VERDICT_COLORS[text_color]["fill"],
|
|
89
|
+
"stroke_width": VERDICT_STROKE_WIDTH,
|
|
90
|
+
"stroke_fill": VERDICT_COLORS[text_color]["stroke"],
|
|
91
|
+
"font": self.font,
|
|
92
|
+
"anchor": "lt",
|
|
93
|
+
}
|
|
94
|
+
char_img_ctx.text(**args)
|
|
95
|
+
|
|
96
|
+
# Squish character on X axis if the whole string will be too wide
|
|
97
|
+
char_img = char_img.resize(
|
|
98
|
+
(int(char_img.width * x_squish), char_img.height)
|
|
99
|
+
)
|
|
100
|
+
true_char_width = int(char_width * x_squish)
|
|
101
|
+
|
|
102
|
+
# Get bounding box stuff!
|
|
103
|
+
# From https://github.com/python-pillow/Pillow/issues/3921#issuecomment-533085656
|
|
104
|
+
bottom_1 = self.font.getsize(self._text[i])[1]
|
|
105
|
+
right, bottom_2 = self.font.getsize(self._text[: i + 1])
|
|
106
|
+
bottom = bottom_1 if bottom_1 < bottom_2 else bottom_2
|
|
107
|
+
width, height = self.font.getmask(c).size
|
|
108
|
+
top = bottom - height
|
|
109
|
+
left = right - width
|
|
110
|
+
|
|
111
|
+
rect = (left, top, right, bottom)
|
|
112
|
+
|
|
113
|
+
chars.append(
|
|
114
|
+
{
|
|
115
|
+
"img": char_img,
|
|
116
|
+
"char": c,
|
|
117
|
+
"width": true_char_width,
|
|
118
|
+
"rect": rect,
|
|
119
|
+
"scale": VERDICT_FONT_START_SIZE_RATIO,
|
|
120
|
+
"visible": False,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
width_sum = sum([i["width"] for i in chars])
|
|
125
|
+
self._characters = {"chars": chars, "width_sum": width_sum, "height": h}
|
|
126
|
+
|
|
127
|
+
def show_index(self, index: int):
|
|
128
|
+
if self.font is None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
self._characters["chars"][index]["visible"] = True
|
|
132
|
+
|
|
133
|
+
def update(self, delta):
|
|
134
|
+
if self.font is None:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
for character in self._characters["chars"]:
|
|
138
|
+
if not character["visible"]:
|
|
139
|
+
continue
|
|
140
|
+
character["scale"] = max(
|
|
141
|
+
1.0, character["scale"] - delta * VERDICT_SLAM_SPEED
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw):
|
|
145
|
+
if self.font is None:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
width_so_far = -int(self._characters["width_sum"] / 2) - VERDICT_STROKE_WIDTH
|
|
149
|
+
for character in self._characters["chars"]:
|
|
150
|
+
if not character["visible"]:
|
|
151
|
+
continue
|
|
152
|
+
char_img: Image.Image = character["img"]
|
|
153
|
+
char_width = character["width"]
|
|
154
|
+
|
|
155
|
+
x = width_so_far
|
|
156
|
+
y = (
|
|
157
|
+
character["rect"][1]
|
|
158
|
+
- int(self._characters["height"] / 2)
|
|
159
|
+
- VERDICT_STROKE_WIDTH
|
|
160
|
+
)
|
|
161
|
+
w = char_img.width
|
|
162
|
+
h = char_img.height
|
|
163
|
+
|
|
164
|
+
scale = character["scale"]
|
|
165
|
+
scaled_w = int(char_img.width * scale)
|
|
166
|
+
scaled_h = int(char_img.height * scale)
|
|
167
|
+
|
|
168
|
+
# https://math.stackexchange.com/a/3055687
|
|
169
|
+
scaled_x = x + int(w / 2) - int(scaled_w / 2)
|
|
170
|
+
scaled_y = y + int(h / 2) - int(scaled_h / 2)
|
|
171
|
+
scaled_char_img = char_img.resize(
|
|
172
|
+
(scaled_w, scaled_h), resample=Image.Resampling.NEAREST
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
img.alpha_composite(
|
|
176
|
+
scaled_char_img,
|
|
177
|
+
(scaled_x + int(img.width / 2), scaled_y + int(img.height / 2)),
|
|
178
|
+
)
|
|
179
|
+
width_so_far += char_width
|