diff --git a/docs/UsingTestimonyIndicatorEffect.md b/docs/UsingTestimonyIndicatorEffect.md new file mode 100644 index 0000000..935929c --- /dev/null +++ b/docs/UsingTestimonyIndicatorEffect.md @@ -0,0 +1,60 @@ +# Using the Testimony Indicator Effect +To indicate that a specific type of dialogue is playing out, you can place +a flashing text label in the top-left corner of the screen. In the original +games, this label would read "Testimony" during a witness's testimony in +court. With *Objection*, you can set this label to anything you want, as well +as change its colors as you please. + +![Testimony indicator effect in action](img/testimony-indicator.gif) + +See `example_testimony_indicator.py` for example usage. + +## Setting up the effect +Only a few commands are necessary to set up the label effect: +```py +[ + # Set the text for the label + DialogueAction("testimony set 'Testimony'", 0), + + # (Optional) Set the fill color of the text. + # The arguments following "fillcolor" are RGB values in the range 0-255. + DialogueAction("testimony fillcolor 230 255 230", 0), + + # (Optional) Set the stroke color of the text. + # The arguments following "strokecolor" are RGB values in the range 0-255. + DialogueAction("testimony strokecolor 20 150 80", 0), + + # Start flashing the label + DialogueAction("testimony show", 0) +] +``` +Just add those commands to your list of `BaseDialogueItem` objects and you'll +have your label! + +### Resetting the colors +If you don't set the fill or stroke colors, they'll default to `(255, 255, 255)` +(white) and `(0, 192, 56)` (the green color used in the original games' +"Testimony" label), respectively. + +If you'd like to return the colors to their defaults, you can pass a single +parameter, `default`, to `testimony fillcolor` and `testimony strokecolor`, +like so: +```py +[ + # Reset the fill color of the label to white + DialogueAction("testimony fillcolor default", 0), + + # Reset the stroke color of the label to the green color used in the + # original games + DialogueAction("testimony strokecolor default", 0) +] +``` + +### Turning off the label +Disabling the label is as easy as issuing a single command: +```py +[ + DialogueAction("testimony hide", 0) +] +``` +That's it! diff --git a/docs/img/testimony-indicator.gif b/docs/img/testimony-indicator.gif new file mode 100644 index 0000000..bdbe826 Binary files /dev/null and b/docs/img/testimony-indicator.gif differ diff --git a/examples/example_testimony_indicator.py b/examples/example_testimony_indicator.py new file mode 100644 index 0000000..849dbed --- /dev/null +++ b/examples/example_testimony_indicator.py @@ -0,0 +1,252 @@ +from objection_engine.ace_attorney_scene import AceAttorneyDirector +from objection_engine.parse_tags import ( + DialoguePage, + DialogueAction, + DialogueTextChunk, + DialogueTextLineBreak, +) + +pages = [ + DialoguePage( + [ + DialogueAction("music start pwr/cross-moderato", 0), + DialogueAction( + "testimony set 'Discussion'", 0 + ), # Set testimony indicator text with default colors + DialogueAction("testimony show", 0), # Show testimony indicator + ] + ), + DialoguePage( + [ + DialogueAction("wait 0.03", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-normal-idle.gif", 0 + ), + DialogueAction("cut left", 0), + DialogueAction('nametag "Phoenix"', 0), + DialogueAction("showbox", 0), + DialogueAction("evidence clear", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-normal-talk.gif", 0 + ), + DialogueTextChunk("Hello", []), + DialogueTextChunk(".", []), + DialogueAction("stopblip", 0), + DialogueTextChunk(" ", []), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-normal-idle.gif", 0 + ), + DialogueAction("wait 0.6", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-talk.gif", + 0, + ), + DialogueTextChunk("My", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("name", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("is", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("Phoenix", []), + DialogueTextChunk(".", []), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction("stopblip", 0), + DialogueAction("showarrow", 0), + DialogueAction("wait 2", 0), + DialogueAction("hidearrow", 0), + DialogueAction("sound pichoop", 0), + DialogueAction("wait 0.3", 0), + ] + ), + DialoguePage( + [ + DialogueAction("wait 0.03", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction("cut left", 0), + DialogueAction('nametag "Phoenix"', 0), + DialogueAction("showbox", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-talk.gif", + 0, + ), + DialogueTextChunk("I", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("am", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("a", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("defense", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("attorney", []), + DialogueTextChunk(".", []), + DialogueAction("stopblip", 0), + DialogueTextChunk(" ", []), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction("stopblip", 0), + DialogueAction("showarrow", 0), + DialogueAction("wait 2", 0), + DialogueAction("hidearrow", 0), + DialogueAction("sound pichoop", 0), + DialogueAction("wait 0.3", 0), + ] + ), + DialoguePage( + [ + DialogueAction("wait 0.03", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction("cut left", 0), + DialogueAction('nametag "Phoenix"', 0), + DialogueAction("showbox", 0), + DialogueAction("evidence clear", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-talk.gif", + 0, + ), + DialogueTextChunk("Here", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("is", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("another", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("line", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("of", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("dialogue", []), + DialogueTextChunk(".", []), + DialogueAction("stopblip", 0), + DialogueTextChunk(" ", []), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction( + "sprite left assets/characters/phoenix/phoenix-handsondesk-idle.gif", + 0, + ), + DialogueAction("stopblip", 0), + DialogueAction("showarrow", 0), + DialogueAction("wait 2", 0), + DialogueAction("hidearrow", 0), + DialogueAction("sound pichoop", 0), + DialogueAction("wait 0.3", 0), + ] + ), + DialoguePage( + [ + DialogueAction("wait 0.03", 0), + DialogueAction("hidebox", 0), + DialogueAction("testimony hide", 0), # Hide testimony indicator + DialogueAction( + "testimony set 'Counterargument'", 0 + ), # Set a new testimony indicator + DialogueAction("testimony fillcolor 56 200 200", 0), # Set the fill color + DialogueAction("testimony strokecolor 128 0 56", 0), # Set the stroke color + DialogueAction("wait 0.5", 0), + DialogueAction("pan right", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-idle.gif", + 0, + ), + DialogueAction("wait 1.0", 0), + DialogueAction('nametag "Edgeworth"', 0), + DialogueAction("showbox", 0), + DialogueAction("testimony show", 0), # Start showing new indicator + DialogueAction("evidence clear", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-talk.gif", + 0, + ), + DialogueTextChunk("I", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("am", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("Edgeworth", []), + DialogueTextChunk(",", []), + DialogueAction("stopblip", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-idle.gif", + 0, + ), + DialogueAction("wait 0.3", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-talk.gif", + 0, + ), + DialogueAction("testimony fillcolor default", 0), # Reset the fill color + DialogueAction( + "testimony strokecolor default", 0 + ), # Reset the stroke color + DialogueTextChunk(" ", []), + DialogueTextChunk("because", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("I", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("have", []), + DialogueTextChunk(" ", []), + DialogueTextLineBreak(), + DialogueTextChunk("the", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("second", []), + DialogueTextChunk("-", []), + DialogueAction("stopblip", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-idle.gif", + 0, + ), + DialogueAction("wait 0.3", 0), + DialogueAction("startblip male", 0), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-talk.gif", + 0, + ), + DialogueTextChunk(" ", []), + DialogueTextChunk("most", []), + DialogueTextChunk(" ", []), + DialogueTextChunk("lines", []), + DialogueTextChunk(".", []), + DialogueAction("stopblip", 0), + DialogueTextChunk(" ", []), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-idle.gif", + 0, + ), + DialogueAction( + "sprite right assets/characters/edgeworth/edgeworth-document-idle.gif", + 0, + ), + DialogueAction("stopblip", 0), + DialogueAction("showarrow", 0), + DialogueAction("wait 2", 0), + DialogueAction("hidearrow", 0), + DialogueAction("sound pichoop", 0), + DialogueAction("wait 0.3", 0), + ] + ), +] + +director = AceAttorneyDirector() +director.set_current_pages(pages) +director.render_movie() diff --git a/objection_engine/ace_attorney_scene.py b/objection_engine/ace_attorney_scene.py index 1a2f89a..356789f 100644 --- a/objection_engine/ace_attorney_scene.py +++ b/objection_engine/ace_attorney_scene.py @@ -8,6 +8,8 @@ from os import environ, getenv from turtle import pos +from objection_engine.testimony_indicator import TestimonyIndicatorTextObject + environ["TOKENIZERS_PARALLELISM"] = "false" # to make HF Transformers happy from transformers import pipeline @@ -596,7 +598,13 @@ def __init__(self, callbacks: dict = None, fps: float = 30): self.evidence = EvidenceObject(parent=self.textbox_shaker, director=self) - self.judge_verdict = JudgeVerdictTextObject(parent=self.root, name="Judge Verdict") + self.judge_verdict = JudgeVerdictTextObject( + parent=self.root, name="Judge Verdict" + ) + + self.testimony_indicator = TestimonyIndicatorTextObject( + parent=self.root, name="Testimony Indicator" + ) self.scene = Scene(width=256, height=192, root=self.root) @@ -830,6 +838,33 @@ def update(self, delta: float): current_dialogue_obj.completed = True + elif c == "testimony": + command = action_split[1] + if command == "set": + new_text = action_split[2] + self.testimony_indicator.set_text(new_text) + elif command == "fillcolor": + if len(action_split) == 3 and action_split[2] == "default": + self.testimony_indicator.set_fill_color(None) + else: + r = int(action_split[2]) + g = int(action_split[3]) + b = int(action_split[4]) + self.testimony_indicator.set_fill_color((r, g, b)) + elif command == "strokecolor": + if len(action_split) == 3 and action_split[2] == "default": + self.testimony_indicator.set_stroke_color(None) + else: + r = int(action_split[2]) + g = int(action_split[3]) + b = int(action_split[4]) + self.testimony_indicator.set_stroke_color((r, g, b)) + elif command == "show": + self.testimony_indicator.make_visible() + elif command == "hide": + self.testimony_indicator.make_invisible() + + current_dialogue_obj.completed = True elif c == "nop": current_dialogue_obj.completed = True diff --git a/objection_engine/testimony_indicator.py b/objection_engine/testimony_indicator.py new file mode 100644 index 0000000..56af5e1 --- /dev/null +++ b/objection_engine/testimony_indicator.py @@ -0,0 +1,113 @@ +from PIL import Image, ImageDraw, ImageFont +from os.path import join, exists +from objection_engine.MovieKit import SceneObject +from objection_engine.loading import ASSETS_FOLDER + +TESTIMONY_STROKE_WIDTH = 2 +TESTIMONY_INDICATOR_FONT_PATH = join( + ASSETS_FOLDER, "testimony_indicator", "DINCondensed-Bold.ttf" +) + +""" +Visible for 43 frames (1.433s) +Invisible for 10 frames (0.34s) +""" + + +class TestimonyIndicatorTextObject(SceneObject): + def __init__(self, parent: SceneObject = None, name: str = ""): + super().__init__(parent, name, (0, 0, 10)) + self.text_visible_time = 1.43 + self.text_invisible_time = 0.34 + self.font = None + self.time_remaining = self.text_visible_time + self.text_visible = True + self.can_be_displayed = False + + self.stroke_color = (0, 192, 56) + self.fill_color = (255, 255, 255) + + if not exists(TESTIMONY_INDICATOR_FONT_PATH): + return + + self.font = ImageFont.truetype(TESTIMONY_INDICATOR_FONT_PATH, 32) + self.set_text("Testimony") + + def get_text_bbox(self, text: str): + x1, y1, x2, y2 = self.font.getbbox(text) + x1 -= TESTIMONY_STROKE_WIDTH + y1 -= TESTIMONY_STROKE_WIDTH + x2 += TESTIMONY_STROKE_WIDTH + y2 += TESTIMONY_STROKE_WIDTH + return (x2 - x1, y2 - y1) + + def set_fill_color(self, color: tuple[int, int, int] = None): + self.fill_color = color if color is not None else (255, 255, 255) + self.render_internal_graphic() + + def set_stroke_color(self, color: tuple[int, int, int] = None): + self.stroke_color = color if color is not None else (0, 192, 56) + self.render_internal_graphic() + + def set_text(self, text: str): + self._text = text + self.render_internal_graphic() + + def render_internal_graphic(self): + if self.font is None: + return + img = Image.new("RGBA", self.get_text_bbox(self._text), (255, 0, 255, 0)) + ctx = ImageDraw.Draw(img) + ctx.fontmode = "1" + ctx.text( + xy=(TESTIMONY_STROKE_WIDTH, TESTIMONY_STROKE_WIDTH), + text=self._text, + fill=self.fill_color, + stroke_width=TESTIMONY_STROKE_WIDTH, + stroke_fill=self.stroke_color, + font=self.font, + anchor="lt", + ) + + self.testimony_img = img.resize( + (int(round(img.width * 0.65)), img.height), + resample=Image.Resampling.NEAREST, + ).convert("RGBA") + + def make_visible(self): + self.can_be_displayed = True + self.reset_timing() + + def make_invisible(self): + self.can_be_displayed = False + + def reset_timing(self): + self.text_visible = True + self.time_remaining = self.text_visible_time + + def update(self, delta): + self.time_remaining -= delta + + while self.time_remaining < 0.0: + if self.text_visible: + self.text_visible = False + self.time_remaining += self.text_invisible_time + else: + self.text_visible = True + self.time_remaining += self.text_visible_time + + def render(self, img: Image.Image, ctx: ImageDraw.ImageDraw): + if self.font is None: + return + + if not self.can_be_displayed: + return + + if not self.text_visible: + return + + img.paste( + self.testimony_img, + (int(TESTIMONY_STROKE_WIDTH / 2) - 1, int(TESTIMONY_STROKE_WIDTH / 2) - 1), + self.testimony_img, + )