diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 2f242407..bf36243e 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -3,7 +3,7 @@ github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username -ko_fi: ganstakingofsa +ko_fi: bronya_rand tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username diff --git a/.github/IMAGES/ddlcmt-open-graph.png b/.github/IMAGES/ddlcmt-open-graph.png new file mode 100755 index 00000000..136f0fe6 Binary files /dev/null and b/.github/IMAGES/ddlcmt-open-graph.png differ diff --git a/.gitignore b/.gitignore index a0ffc756..654313f5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ game/saves errors.txt *.pyc __pycache__ -ZIPs \ No newline at end of file +ZIPs +.venv +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 39b47ac2..0d665944 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,17 @@ { - "python.formatting.provider": "black", "files.exclude": { "**/*.rpyc": true, "**/*.rpa": true, "**/*.rpymc": true, "**/cache/": true - } + }, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true } \ No newline at end of file diff --git a/Additional Mod Features/Better BSODs/bsod.rpy b/Additional Mod Features/Better BSODs/bsod.rpy index 7ac312b7..6f193264 100644 --- a/Additional Mod Features/Better BSODs/bsod.rpy +++ b/Additional Mod Features/Better BSODs/bsod.rpy @@ -1,10 +1,12 @@ -## Copyright 2019-2024 Azariel Del Carmen (bronya_rand). All rights reserved. - +# Copyright 2019-2025 Azariel Del Carmen (bronya_rand). All rights reserved. # bsod.rpy # This file contains the screen code to display a fake Blue Screen of death. init python: + import secrets + cursor = 0 + purple = False def fakePercent(st, at, winver): @@ -32,11 +34,18 @@ init python: cursor = 0 return Text(" ", style="bsod_linux_text"), 0.3 - if renpy.windows: - try: osVer = tuple(map(int, subprocess.run("powershell (Get-WmiObject -class Win32_OperatingSystem).Version", check=True, shell=True, stdout=subprocess.PIPE).stdout.split(b"."))) # Vista+ - except: osVer = tuple(map(int, platform.version().split("."))) or (5, 1, 2600) # XP returns JIC (but Ren'Py 8 doesn't even support XP...) + def fake_macos_bigsur_panic(st, at): + global purple + + if purple: + purple = False + return Solid("#ff41ff"), 0.15 + else: + purple = True + return Solid("#000"), 2 -## BSOD screen ##################################################################\ + +## BSOD screen ################################################################## ## ## This screen is used to fake a BSOD/kernel panic on the players' computer ## on all platforms (Mobile devices defaults to the Linux BSOD). @@ -57,12 +66,13 @@ screen bsod(bsodCode="DDLC_ESCAPE_PLAN_FAILED", bsodFile="libGLESv2.dll", rsod=F if renpy.windows: - if osVer < (6, 2, 9200): # Windows 7 - + python: + os = get_windows_version() + + if os <= (6, 1): # Windows 7 and below add Solid("#000082") vbox: - style_prefix "bsod_win7" text "A problem has been detected and Windows has been shut down to prevent damage to your computer." @@ -75,14 +85,11 @@ screen bsod(bsodCode="DDLC_ESCAPE_PLAN_FAILED", bsodFile="libGLESv2.dll", rsod=F text "*** STOP: 0x00000051 (OXFD69420, 0x00000005, OXFBF92317" + ", 0x00000000)\n" text "*** " + bsodFile.upper() + " - Address FBF92317 base at FBF102721, Datestamp 3d6dd67c" - elif osVer < (10, 0, 10240): # Windows 8/8.1 - + elif os == (6, 2) or os == (6, 3): # Windows 8 and 8.1 add Solid("#1273aa") - style_prefix "bsod_win8" - vbox: - + style_prefix "bsod_win8" xalign 0.5 yalign 0.4 @@ -92,33 +99,25 @@ screen bsod(bsodCode="DDLC_ESCAPE_PLAN_FAILED", bsodFile="libGLESv2.dll", rsod=F add DynamicDisplayable(fakePercent, 8) text "If you'd like to know more, you can search online later for this error: " + bsodCode.upper() style "bsod_win8_sub_text" - else: # Windows 10, 11 and RSOD - - # After a silent update, Windows 11 now returns to a - # Windows 10 BSOD color. We will remove the black for - # blue now. + else: # Windows 10, 11 and RSOD / Unknown if rsod: - add Solid("#d40e0eff") python: blackCol = "#f00" else: - add Solid("#0078d7") python: blackCol = "#0078d7" - style_prefix "bsod_win10" - vbox: - + style_prefix "bsod_win10" xalign 0.2 yalign 0.4 text ":(" style "bsod_win10_sad_text" - if osVer < (10, 0, 22000): + if os == "10": python: bsodQRSize = 100 @@ -155,29 +154,56 @@ screen bsod(bsodCode="DDLC_ESCAPE_PLAN_FAILED", bsodFile="libGLESv2.dll", rsod=F elif renpy.macintosh: - add Solid("#222") + python: + release = get_macos_version() - add im.MatrixColor("mod_assets/DDLCModTemplateLogo.png", im.matrix.desaturate() * im.matrix.brightness(-0.36)) at bsod_qrcode(440) xalign 0.5 yalign 0.54 - vbox: + if release <= (10, 10): # OS X Yosemite and below + add Solid("#222") - style_prefix "bsod_mac" - xalign 0.53 - yalign 0.51 - - text "You need to restart your computer. Hold down the Power\n" - text "button until it turns off, then press the Power button again." line_spacing 25 - text "Redémarrez l'ordinateur. Enfoncez le bouton de démarrage\n" - text "jusqu'à l'extinction, puis appuyez dessus une nouvelle fois." line_spacing 25 - text "Debe reiniciar el o rdenador. Mantenga pulsado el botón de\n" - text "arranque hasta que se apague y luego vuelva a pulsarlo." line_spacing 25 - text "Sie müssen den Computer neu starten. Halten Sie den\n" - text "Ein-/Ausschalter gedrückt bis das Gerät ausgeschaltet ist\n" - text "und drücken Sie ihn dann erneut." line_spacing 25 - text "Devi riavviare il computer. Tieni premuto il pulsante di\n" - text "accensione finché non si spegne, quindi premi di nuovo il\n" - text "pulsante di accensione." + add im.MatrixColor("mod_assets/DDLCModTemplateLogo.png", im.matrix.desaturate() * im.matrix.brightness(-0.36)) at bsod_qrcode(440) xalign 0.5 yalign 0.54 + vbox: - else: + style_prefix "bsod_osx" + xalign 0.53 + yalign 0.51 + + text "Your computer restarted because of a problem. Press a key or wait a few\n" + text "seconds to continue starting up" line_spacing 25 + text "Votre ordinateur a redémarré en raison d'un problème. Pour poursuivre\n" + text "le démarrage, appuyez sur une touche ou patientez quelques secondes." line_spacing 25 + text "El ordenador se ha reiniciado debido a un problema. Para continuar con\n" + text "el arranque, pulse cualquier tecla o espere unos segundos." line_spacing 25 + text "Ihr Computer wurde aufgrund eines Problems neu gestartet. Drücken\n" + text "Sie zum Fortfahren eine Taste oder warten Sie einige Sekunden." line_spacing 25 + + # Due to font limitations, JP and CN are not supported. If using a font + # that supports these languages, uncomment the lines below. + # Alternative languages will be used (IT/NL). + + #text "問題が起きたためコンピュータを再起動しました。このまま起動する場合は、\n" + #text "いずれかのキーを押すか、数秒間そのままお待ちください。" line_spacing 25 + #text "电脑因出现问题而重新启动。请按一下按键,或等几秒钟以继续启动。" + + text "Il computer è stato riavviato a causa di un problema. Per continuare l'avvio,\n" + text "premere un tasto o attendere qualche secondo." line_spacing 25 + text "De computer is opnieuw opgestart vanwege een probleem. Druk op een toets\n" + text "om door te gaan met opstarten, of wacht een paar seconden." line_spacing 25 + elif release <= (10, 15): # OS X El Capitan, macOS Sierra -> macOS Catalina + add Solid("#000") + + vbox: + style_prefix "bsod_macos" + xalign 0.0 + yalign 0.0 + + text "**************************************************\n" + text "This system was automatically rebooted after panic" line_spacing 5 + text "**************************************************" line_spacing 5 + + else: # macOS Big Sur and above + add DynamicDisplayable(fake_macos_bigsur_panic) + + else: # Linux and other platforms add Solid("#000") @@ -213,7 +239,10 @@ screen bsod(bsodCode="DDLC_ESCAPE_PLAN_FAILED", bsodFile="libGLESv2.dll", rsod=F text "Kernel panic - not syncing: Attempted to kill init!" add DynamicDisplayable(constantCursor) - add Solid("#000000") at bsod_transition + if renpy.windows: + add Solid("#000") at win_bsod_transition + else: + add Solid("#000") at general_bsod_transition style bsod_win7_text is gui_text style bsod_win7_text: @@ -264,13 +293,18 @@ style bsod_win10_sub_text is bsod_win10_text style bsod_win10_sub_text: size 11 -style bsod_mac_text is gui_text -style bsod_mac_text: +style bsod_osx_text is gui_text +style bsod_osx_text: font gui.default_font size 28 outlines [] line_spacing -30 +style bsod_macos_text is bsod_osx_text +style bsod_macos_text: + size 21 + line_spacing -25 + style bsod_linux_text is gui_text style bsod_linux_text: font "gui/font/F25_Bank_Printer.ttf" @@ -278,13 +312,18 @@ style bsod_linux_text: outlines [] line_leading 5 -transform bsod_transition: +transform win_bsod_transition: "black" - 0.1 + 0.05 yoffset 250 - 0.1 + 0.05 yoffset 500 - 0.1 + 0.05 + yoffset 750 + +transform general_bsod_transition: + "black" + pause 2.5 yoffset 750 transform bsod_qrcode(x): diff --git a/Additional Mod Features/Discord/discord.rpy b/Additional Mod Features/Discord/discord.rpy deleted file mode 100644 index 853f3bb1..00000000 --- a/Additional Mod Features/Discord/discord.rpy +++ /dev/null @@ -1,148 +0,0 @@ -# discord.rpy -# This file sets up Discord Rich Presence into your mod. -# This requires that pypresence is installed in your mod (bundled with this template). - -## BEFORE STARTING READ THIS. -## To make RPC work for you, make a new application on Discord's Developer Portal -## https://discord.com/developers/applications -## Follow the comments inside of `set_defaults` in order to setup RPC to your liking. - -## Based similarly off off Lazalith's RPC code: https://github.com/Lezalith/RenPy-Discord-Presence - -default persistent.enable_discord = not renpy.android - -init -950 python in discord: - from pypresence import Presence, DiscordError, DiscordNotFound, InvalidPipe - from store import config, NoRollback, persistent - from copy import deepcopy - import time - - class DiscordRPC(NoRollback): - def __init__(self, client_id): - self.client_id = str(client_id) - self.start = time.time() - self.rpc_connected = False - - self.set_defaults() - self.original_props = self.self_dict() - self.props = {} - self.auth() - self.connect() - - # Easy method to reset stuff back to stock RPC info. - def set_defaults(self): - # Details indicates what the player is doing ATM - # Example: In Main Menu - self.details = renpy.version() - - # State indicates additional information to details - # Example: Browsing Settings - self.state = "Monika Is Watching You Code" - - # Defines the largest image to use in rich presence as the - # main icon. - self.large_image = "ddlcmodtemplatelogo" - - # Defines the text when a user hovers on the large icon in - # a players' status. - # Example: DDLC Mod Template - self.large_text = "Remembering" # Uses the name of the mod defined in-game. - - # Defines the smallest image to use in rich presence as the - # secondary icon. - self.small_image = "test" - # Defines the text when a user hovers on the small icon in - # a players' status. - self.small_text = config.version # Uses the version name of the mod - - def self_dict(self): - return { - "state": self.state, - "details": self.details, - "large_image": self.large_image, - "large_text": self.large_text, - "small_image": self.small_image, - "small_text": self.small_text, - "start": self.start, - } - - def reset_time(self): - self.start = time.time() - - def auth(self): - if not persistent.enable_discord: - self.rpc = None - return - - try: - self.rpc = Presence(self.client_id) - except (DiscordError, DiscordNotFound): - self.rpc = None - - def connect(self, reset=False): - if self.rpc is None: self.auth() - if self.rpc is None: return - try: - self.rpc.connect() - self.rpc_connected = True - if reset: - if len(self.props) <= 1: self.set(**self.original_props) - else: self.set(**self.props) - except InvalidPipe: - self.rpc = None - - def close(self): - if self.rpc is None: return - self.rpc.close() - self.rpc_connected = False - - def reset(self): - self.set(**self.original_props) - - def record_to_rollback(self): - global rollback_status - rollback_status = deepcopy(self.props) - - def rollback_check(self): - global rollback_status - - if self.rpc is None: return - if self.props != rollback_status: - self.set(**rollback_status) - - def on_load(self): - global rollback_status - self.set(**rollback_status) - - def set(self, **props): - if self.rpc is None: return - self.props = deepcopy(props) - self.props["start"] = self.start - - self.rpc.update(**self.props) - self.record_to_rollback() - - def update(self, **props): - if self.rpc is None: return - for p in props: - self.props[p] = props[p] - self.props["start"] = self.start - - self.rpc.update(**self.props) - self.record_to_rollback() - - def clear(self): - self.props = {} - self.record_to_rollback() - self.rpc.clear() - -default discord.rollback_status = {} - -init -940 python: - # Place your Discord RPC token inside the ""'s - RPC = discord.DiscordRPC("979471077187125248") - - config.quit_callbacks += [RPC.close] - config.after_load_callbacks += [RPC.on_load] - config.interact_callbacks += [RPC.rollback_check] - config.start_callbacks += [RPC.reset] diff --git a/Additional Mod Features/Discord/discord/discord.rpy b/Additional Mod Features/Discord/discord/discord.rpy new file mode 100644 index 00000000..9ba56645 --- /dev/null +++ b/Additional Mod Features/Discord/discord/discord.rpy @@ -0,0 +1,2 @@ +default last_reported_status_data = {} +default persistent.enable_discord = not renpy.android \ No newline at end of file diff --git a/Additional Mod Features/Discord/discord/py/discord_ren.py b/Additional Mod Features/Discord/discord/py/discord_ren.py new file mode 100644 index 00000000..3b2f1315 --- /dev/null +++ b/Additional Mod Features/Discord/discord/py/discord_ren.py @@ -0,0 +1,213 @@ +# This file contains the Python code for the Discord Rich Presence integration. +# This requires the pypresence library to be included in your mod's `python-packages` folder. + +# Before starting, make a new application on Discord's Developer Portal: +# https://discord.com/developers/applications +# Follow the comments inside of `set_defaults` in order to set up RPC to your liking. + +## This import is not used when the game is running, but exists so IDEs reports +## one warning than multiple. +import renpy # type: ignore +from renpy import NoRollback # type: ignore +from game.definitions.py.core_ren import persistent + +last_reported_status_data = { + "state": "", + "details": "", + "large_image": "", + "large_text": "", + "small_image": "", + "small_text": "", +} + +"""renpy +init -950 python: +""" + +from pypresence import Presence, DiscordError, DiscordNotFound, InvalidPipe +from copy import deepcopy +import time + +class DiscordRPC(NoRollback): + def __init__(self, client_id: str) -> None: + """ + Initializes the DiscordRPC with the given client ID. + + :param client_id: The Discord application client ID. + :type client_id: str + """ + + self.client_id = client_id + self.rpc_connected = False + self.rpc: Presence | None = None + + # Discord Status Data + self.start: float = 0.0 + self.details: str = "" + self.state: str = "" + self.large_image: str = "" + self.large_text: str = "" + self.small_image: str = "" + self.small_text: str = "" + + self.original_state: dict = {} + + def set_defaults(self) -> None: + """ + Sets the default values for the Discord Rich Presence status. + Modify these values to customize the default status. + """ + + # Details indicates what the player is doing in the mod. + # Example: In Main Menu + self.details = renpy.version() + + # State indicates additional information to details. + # Example: Browsing Settings + self.state = "Bronya... :o" + + # Sets the largest image to use in rich presence + self.large_image = "ddlcmodtemplatelogo" + + # Sets the text when a user hovers on the large image + self.large_text = renpy.config.name # Uses the name of the mod defined in-game. + + # Sets the smallest image to use in rich presence + self.small_image = "test" + + # Sets the text when a user hovers on the small image + self.small_text = renpy.config.version # Uses the version name of the mod + + self.original_state = self.__dict__() + + def initialize_rpc(self) -> None: + """ + Initializes the Discord RPC connection. + """ + if not persistent.enable_discord: + return + + try: + self.rpc = Presence(self.client_id) + except (DiscordError, DiscordNotFound): + renpy.exports.write_log("Discord client not found.") + return + + def connect(self) -> None: + """ + Connects to the Discord Rich Presence service. + """ + + if not persistent.enable_discord: + return + if self.rpc_connected: + return # Already connected + if self.rpc is None: + self.initialize_rpc() + if self.rpc is None: + return + + self.set_defaults() + self.start = time.time() + + try: + self.rpc.connect() + self.rpc_connected = True + except InvalidPipe: + self.rpc = None + + def disconnect(self) -> None: + """ + Disconnects from the Discord Rich Presence service. + """ + + if self.rpc is None: + return + self.rpc.close() + self.rpc_connected = False + + def set(self, **kwargs) -> None: + """ + Sets the Discord Rich Presence status. + + :param kwargs: Key-value pairs for the status data. + """ + if not persistent.enable_discord: + return + if self.rpc is None or not self.rpc_connected: + return + + # Update the status data with provided keyword arguments. + valid_keys = { + "state", "details", "large_image", "large_text", + "small_image", "small_text" + } + for key, value in kwargs.items(): + if key in valid_keys: + setattr(self, key, value) + + updated_data = self.__dict__() + self.rpc.update(**updated_data) + self.record_to_rollback() + + def reset(self) -> None: + """ + Resets the Discord Rich Presence status to the original state. + """ + self.set(**self.original_state) + + def record_to_rollback(self) -> None: + """ + Records the current status data for rollback purposes. + """ + global last_reported_status_data + last_reported_status_data = deepcopy(self.__dict__()) + + def rollback_check(self) -> None: + """ + Checks if the status data has changed and updates it if necessary. + """ + + global last_reported_status_data + if not persistent.enable_discord: + return + if self.rpc is None or not self.rpc_connected: + return + + current_data = self.__dict__() + if current_data != last_reported_status_data: + self.set(**last_reported_status_data) + + def on_load(self) -> None: + """ + Updates the Discord Rich Presence status after loading a saved game. + """ + + global last_reported_status_data + if not persistent.enable_discord: + return + if self.rpc is None or not self.rpc_connected: + return + + self.set(**last_reported_status_data) + + def __dict__(self) -> dict: + return { + "state": self.state, + "details": self.details, + "large_image": self.large_image, + "large_text": self.large_text, + "small_image": self.small_image, + "small_text": self.small_text, + "start": self.start, + } + +# Place your Discord RPC token inside the ""'s +RPC = DiscordRPC("979471077187125248") +RPC.initialize_rpc() +RPC.connect() + +renpy.config.quit_callbacks.append(RPC.disconnect) +renpy.config.after_load_callbacks.append(RPC.on_load) +renpy.config.interact_callbacks.append(RPC.rollback_check) +renpy.config.start_callbacks.append(RPC.reset) \ No newline at end of file diff --git a/Additional Mod Features/Pronouns/pronouns.rpy b/Additional Mod Features/Pronouns/pronouns.rpy deleted file mode 100644 index 07d46206..00000000 --- a/Additional Mod Features/Pronouns/pronouns.rpy +++ /dev/null @@ -1,71 +0,0 @@ -## Copyright 2019-2024 Azariel Del Carmen (bronya_rand). All rights reserved. - -## pronouns.rpy -# This file asks the user for their pronoun input. - -default pronoun_temp = "" - -init python: - def SetPronoun(type): - global pronoun_temp - if not pronoun_temp: return - if type == "he": - persistent.he = pronoun_temp.lower() - he = pronoun_temp.lower() - he_capital = pronoun_temp.lower().capitalize() - elif type == "he's": - persistent.hes = pronoun_temp.lower() - hes = pronoun_temp.lower() - hes_capital = pronoun_temp.lower().capitalize() - elif type == "are": - persistent.are = pronoun_temp.lower() - are = pronoun_temp.lower() - are_capital = pronoun_temp.lower().capitalize() - elif type == "him": - persistent.him = pronoun_temp.lower() - him = pronoun_temp.lower() - him_capital = pronoun_temp.lower().capitalize() - pronoun_temp = "" - -label pronoun_screen: - $ renpy.call_screen("pronoun_input", message="Enter your first pronoun (He/She/They)", ok_action=Function(SetPronoun, type="he")) - $ renpy.call_screen("pronoun_input", message="Enter your second pronoun (He's/She's/They're)", ok_action=Function(SetPronoun, type="he's"), hes=True) - $ renpy.call_screen("pronoun_input", message="Enter your third pronoun (Him/Her/Them)", ok_action=Function(SetPronoun, type="him")) - $ renpy.call_screen("pronoun_input", message="Enter your fourth pronoun (Is/Are)", ok_action=Function(SetPronoun, type="are")) - return - -screen pronoun_input(message, ok_action, hes=False): - - ## Ensure other screens do not get input while this screen is displayed. - modal True - - zorder 200 - - style_prefix "confirm" - - add "gui/overlay/confirm.png" - key "K_RETURN" action [Play("sound", gui.activate_sound), ok_action] - - frame: - - vbox: - xalign .5 - yalign .5 - spacing 30 - - label _(message): - style "confirm_prompt" - xalign 0.5 - - python: - allowList = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - if hes: - allowList = allowList + "'" - - input default "" value VariableInputValue("pronoun_temp") length 12 allow allowList - - hbox: - xalign 0.5 - spacing 100 - - textbutton _("OK") action [ok_action, Return(0)] \ No newline at end of file diff --git a/CREDITS.md b/CREDITS.md deleted file mode 100644 index 30c312e6..00000000 --- a/CREDITS.md +++ /dev/null @@ -1,20 +0,0 @@ - -# Credits -Special thanks to the following for contributing to the mod template -> This list goes from the past to present. - -- Dan Salvato (DDLC) -- renpytom (Ren'Py) -- MAS Team (template base before revamping) -- alicerunsonfedora (Xcode) -- Terra (In-depth poem game) -- Yagamirai01 (NVL) -- Alexxonder (Auto Color Adjustments) -- Elckarow (Python 3 updates, New poem responses/effects) -- NekoLaiS (Cryllic compatibility) -- The DDMC Community (Feature suggestions and feedback) -- Pseurae (Donation/Act 3 GL2 Fix) -- Lezalith (New Console (4.1.1+)) -- RS/6000 (New Mod Template Logo (4.2.1+)) -- Tulkas (Android Gestures) -- FiT (Weiss Chibi Branding Icon Design) \ No newline at end of file diff --git a/Original Script Files/script-ch0.rpy b/Original Script Files/script-ch0.rpy index adc3c2f4..c6a3566d 100644 --- a/Original Script Files/script-ch0.rpy +++ b/Original Script Files/script-ch0.rpy @@ -8,7 +8,7 @@ label ch0_main: try: renpy.file("../characters/monika.chr") except: renpy.jump("ch0_kill") - $ restore_all_characters() + $ restore_characters() s "Heeeeeeeyyy!!" "I see an annoying girl running toward me from the distance, waving her arms in the air like she's totally oblivious to any attention she might draw to herself." "That girl is Sayori, my neighbor and good friend since we were children." diff --git a/Original Script Files/script-ch1.rpy b/Original Script Files/script-ch1.rpy index c398f2bf..fef74c15 100644 --- a/Original Script Files/script-ch1.rpy +++ b/Original Script Files/script-ch1.rpy @@ -128,8 +128,7 @@ label ch1_main: "I can't help but notice her intense expression, like she was waiting for this chance." "Meanwhile, Natsuki is rummaging around in the closet." - - $ nextscene = poemwinner[0] + "_exclusive_" + str(eval("chibi_" + poemwinner[0][0] + ".appeal")) + $ nextscene = get_exclusive_scene(0) call expression nextscene diff --git a/Original Script Files/script-ch2.rpy b/Original Script Files/script-ch2.rpy index 088d751f..d80e7464 100644 --- a/Original Script Files/script-ch2.rpy +++ b/Original Script Files/script-ch2.rpy @@ -334,7 +334,7 @@ label ch2_main: - $ nextscene = poemwinner[1] + "_exclusive_" + str(eval("chibi_" + poemwinner[1][0] + ".appeal")) + $ nextscene = get_exclusive_scene(1) call expression nextscene diff --git a/Original Script Files/script-ch21.rpy b/Original Script Files/script-ch21.rpy index 565b8430..5d2e49fb 100644 --- a/Original Script Files/script-ch21.rpy +++ b/Original Script Files/script-ch21.rpy @@ -86,7 +86,7 @@ label ch21_main: "Meanwhile, Natsuki is rummaging around in the closet." - $ nextscene = poemwinner[0] + "_exclusive2_" + str(eval("chibi_" + poemwinner[0][0] + ".appeal")) + $ nextscene = get_exclusive_scene(0) call expression nextscene return diff --git a/Original Script Files/script-ch22.rpy b/Original Script Files/script-ch22.rpy index 91107e56..c9857ce4 100644 --- a/Original Script Files/script-ch22.rpy +++ b/Original Script Files/script-ch22.rpy @@ -235,12 +235,12 @@ label ch22_main: y "I was wondering if you would like to spend some time together today." y 3o "I mean--in the club!" if poemwinner[0] == "natsuki": - $ chibi_y.appeal = 1 + $ poemappeal["yuri"][0] = 1 mc "Ah, I suppose so." mc "I don't think I could say no to you, after you gave that book to me." mc "Well, I guess I need to make sure Natsuki isn't waiting for me." mc "After we finished reading yesterday, she--" - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: y 3r "She's fine!" $ style.say_dialogue = style.normal y 3h "She's reading over there. See?" @@ -264,7 +264,7 @@ label ch22_main: mc "Ah--" mc "In that case, I don't see any problem..." else: - $ chibi_y.appeal = 2 + $ poemappeal["yuri"][1] = 1 mc "Yeah, definitely." mc "I planned on it anyway." show yuri zorder 2 at h11 @@ -284,15 +284,15 @@ label ch22_main: mc "Take your time." "Yuri takes a deep breath, then pulls a copy of the book out of her bag." label ch22_main2: - if n_poemappeal[1] == 1: - $ n_poemappeal[1] = 0 + if poemappeal["natsuki"][1] == 1: + $ poemappeal["natsuki"][1] = 0 $ poemwinner[1] = "yuri" scene bg club_day2 show yuri 3a at i11 with wipeleft - $ nextscene = "yuri_exclusive2_" + str(eval("chibi_y.appeal")) + "_ch22" + $ nextscene = f"yuri_exclusive2_{get_appeal('yuri')}_ch22" call expression nextscene return diff --git a/Original Script Files/script-ch23.rpy b/Original Script Files/script-ch23.rpy index 5f5d79c5..530c2e41 100644 --- a/Original Script Files/script-ch23.rpy +++ b/Original Script Files/script-ch23.rpy @@ -66,7 +66,7 @@ label ch23_main: n 1q "You say that like I do it on a regular basis or something." n "I just wasn't paying attention, okay? I'm sorry." n 4u "Seriously... What's gotten into you lately?" - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: n "Look..." n "I did some thinking about yesterday." n 2q "I was a little more hostile than I meant to be..." @@ -238,7 +238,9 @@ label ch23_main: y 2u "Um... Thank you for understanding, Monika." if poemwinner[2] == "natsuki": $ poemwinner[2] = "yuri" - $ chibi_y.appeal += 1 + $ poemappeal["yuri"][0] = 1 + $ poemappeal["yuri"][1] = 1 + $ poemappeal["yuri"][2] = 1 scene bg club_day2 show yuri 3 zorder 2 at t11 @@ -266,7 +268,7 @@ label ch23_end: m "Okay, everyone!" m "It's time to figure out the festival preparations." m 1i "Let's hurry and get this over with." - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: show natsuki 4q zorder 3 at f31 n "..." else: @@ -282,7 +284,7 @@ label ch23_end: show monika zorder 3 at f32 m 2r "Look, can we just get this done?" m 2d "I'm going to be printing and assembling all the poetry pamphlets." - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: m 2i "Natsuki, you can make cupcakes." m "I know you're at least good at that." show monika zorder 2 at t32 @@ -705,11 +707,11 @@ label yuri_kill_3: m "It must have been pretty boring..." m 2e "I'll make it up to you, okay?" m "Just gimme a sec..." - $ consolehistory = [] - call updateconsole ("os.remove(\"characters/yuri.chr\")", "yuri.chr deleted successfully.") + $ console.clear_history() + $ console("os.remove(\"characters/yuri.chr\")", "yuri.chr deleted successfully.") $ delete_character("yuri") $ pause(1.0) - call updateconsole ("os.remove(\"characters/natsuki.chr\")", "natsuki.chr deleted successfully.") + $ console("os.remove(\"characters/natsuki.chr\")", "natsuki.chr deleted successfully.") $ delete_character("natsuki") $ pause(1.0) m 2a "I'm almost done." diff --git a/Original Script Files/script-ch3.rpy b/Original Script Files/script-ch3.rpy index 8e60e836..98f8621d 100644 --- a/Original Script Files/script-ch3.rpy +++ b/Original Script Files/script-ch3.rpy @@ -188,11 +188,11 @@ label ch3_main: - if chibi_n.appeal == 0 and chibi_y.appeal == 0: + if get_appeal("natsuki") == 0 and get_appeal("yuri") == 0: jump ch3_start_none - elif chibi_n.appeal > 1: + elif get_appeal("natsuki") > 1: jump ch3_start_natsuki - elif chibi_y.appeal > 1: + elif get_appeal("yuri") > 1: jump ch3_start_yuri elif poemwinner[1] == "natsuki": jump ch3_start_natsuki diff --git a/Original Script Files/script-ch30.rpy b/Original Script Files/script-ch30.rpy index 494eb976..a499dcfc 100644 --- a/Original Script Files/script-ch30.rpy +++ b/Original Script Files/script-ch30.rpy @@ -562,8 +562,8 @@ label ch30_endb: repeat $ pause(1.5) m "Please hurry and help me." - $ consolehistory = [] - call updateconsole ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") + $ console.clear_history() + $ console ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") m "HELP ME!!!" show m_rectstatic show m_rectstatic2 @@ -613,9 +613,9 @@ label ch30_endb: $ pause(3.0) - call updateconsole ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") - call updateconsole ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") - call hideconsole + $ console ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") + $ console ("renpy.file(\"characters/monika.chr\")", "monika.chr does not exist.") + hide screen console_screen hide noise onlayer front hide glitch_color onlayer front m "Did you do this to me, [player]?" diff --git a/Original Script Files/script-ch5.rpy b/Original Script Files/script-ch5.rpy index 78e6558d..1c0b4746 100644 --- a/Original Script Files/script-ch5.rpy +++ b/Original Script Files/script-ch5.rpy @@ -160,7 +160,7 @@ label ch5_main: "I flip to Sayori's poem." "It's different from the one she practiced." "It's one that I haven't read before..." - call showpoem (poem_s3, music=False) + $ poem_db.show_poem("poem_s3") mc "Ah--" "What is this...?" "Reading the poem, I get a pit in my stomach." diff --git a/Original Script Files/script-poemresponses2.rpy b/Original Script Files/script-poemresponses2.rpy index 808870b4..126bb241 100644 --- a/Original Script Files/script-poemresponses2.rpy +++ b/Original Script Files/script-poemresponses2.rpy @@ -3,7 +3,7 @@ label ch21_y_end: label ch22_y_end: stop music fadeout 2.0 - call showpoem (poem_y22, music=False, paper="images/bg/poem_y1.jpg", img="yuri 2s") + $ poem_db.show_poem("poem_y22", music=False, img="yuri 2s") y 2q "Ahaha..." y "It doesn't really matter what it's about." y "My mind has been a little hyperactive lately, so I had to take it out on your pen." @@ -24,7 +24,7 @@ label ch23_y_end: show darkred zorder 5: alpha 0 linear 2.0 alpha 1.0 - call showpoem (poem_y23, track="bgm/5_yuri2.ogg", revert_music=False, paper="images/bg/poem_y2.jpg", img="yuri eyes", where=truecenter) + $ poem_db.show_poem("poem_y23", track="bgm/5_yuri2.ogg", revert_music=False, img="yuri eyes", where=truecenter) y "Do you like it??" y "I wrote it for you!" $ gtext = glitchtext(80) @@ -52,10 +52,10 @@ label ch23_y_end: label ch21_n_end: jump ch1_n_end label ch22_n_end: - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: jump ch22_n_end2 else: - call showpoem (poem_n2) + $ poem_db.show_poem("poem_n2") n 2a "Not bad, right?" mc "It's quite a bit longer than yesterday's." n 2w "Yesterday's was way too short..." @@ -81,7 +81,7 @@ label ch22_n_end: n 42c "Whatever... We're done sharing, so you can leave now." return label ch22_n_end2: - call showpoem (poem_n2b, revert_music=False) + $ poem_db.show_poem("poem_n2b", revert_music=False) $ style.say_dialogue = style.edited n 1g "[player]..." n "Why didn't you come read with me today?" @@ -156,7 +156,7 @@ label ch22_n_end2: label ch23_n_end: $ natsuki_23 = True $ style.say_dialogue = style.normal - call showpoem (poem_n23, revert_music=False) + $ poem_db.show_poem("poem_n23", revert_music=False) $ renpy.music.stop(channel="music_poem", fadeout=2.0) $ style.say_dialogue = style.edited show screen tear(8, offtimeMult=1, ontimeMult=10) @@ -195,10 +195,10 @@ label ch23_n_end: return label ch21_m_end: - call showpoem (poem_m21) + $ poem_db.show_poem("poem_m21") jump ch1_m_end2 label ch22_m_end: - call showpoem (poem_m22, revert_music=False) + $ poem_db.show_poem("poem_m22", revert_music=False) $ currentpos = get_pos(channel="music_poem") $ audio.t5c = "bgm/5.ogg" stop music_poem fadeout 2.0 @@ -263,7 +263,7 @@ label ch21_n_good: label ch22_n_bad: - if n_poemappeal[0] < 0: + if poemappeal["natsuki"][0] < 0: n 1r "..." n "Yeah, just as I thought..." mc "...?" @@ -295,7 +295,7 @@ label ch22_n_bad: label ch22_n_med: - if n_poemappeal[0] < 0: + if poemappeal["natsuki"][0] < 0: n "...Hm." n 2k "Well, I can admit that it's better than the last one." n "It's nice to see that you're putting in some effort." @@ -313,7 +313,7 @@ label ch22_n_med: return - elif n_poemappeal[0] == 0: + elif poemappeal["natsuki"][0] == 0: n "...Hm." n 2k "Well, it's not really any worse than your last one." n "But I can't really say it's any better, either." @@ -353,7 +353,7 @@ label ch23_n_bad: if y_gave: jump ch23_n_ygave - if n_poemappeal[0] < 0 and n_poemappeal[1] < 0: + if poemappeal["natsuki"][0] < 0 and poemappeal["natsuki"][1] < 0: n 5x "I'm not going to read another one of your Yuri suck-up poems." n 5s "But I'm still going to make you read mine." n "There's a reason." @@ -363,7 +363,7 @@ label ch23_n_bad: n "Then you can go away." return - elif n_poemappeal[0] < 0 or n_poemappeal[1] < 0: + elif poemappeal["natsuki"][0] < 0 or poemappeal["natsuki"][1] < 0: n "..." n 2c "...Meh." n "I guess you really haven't learned anything after all." @@ -395,9 +395,9 @@ label ch23_n_med: if y_gave: jump ch23_n_ygave - if n_poemappeal[0] < 0 and n_poemappeal[1] < 0: + if poemappeal["natsuki"][0] < 0 and poemappeal["natsuki"][1] < 0: jump ch23_n_bad - elif n_poemappeal[1] < 0: + elif poemappeal["natsuki"][1] < 0: n "..." n 2k "...This one's alright." mc "Alright?" @@ -477,7 +477,7 @@ label ch22_y_med: label ch22_y_good: - if y_poemappeal[0] < 1: + if poemappeal["yuri"][0] < 1: y 2b "I've been waiting for this..." y "Let's see what you've written for today." y 2e "..." @@ -594,7 +594,7 @@ label ch21_m_start: mc "Yeah, that's true." "I hand Monika my poem." m 2a "...Mhm!" - $ nextscene = "m2_" + poemwinner[0] + "_" + str(eval("chibi_" + poemwinner[0][0] + ".appeal")) + $ nextscene = get_monika_scene(0) call expression nextscene m 1a "Anyway, do you want to read my poem now?" @@ -607,7 +607,7 @@ label ch21_m_start: return label ch22_m_start: - if chibi_y.appeal < 2: + if get_appeal("yuri") < 2: m 1b "Hi again, [player]!" m "How's the writing going?" mc "Alright, I guess..." @@ -622,7 +622,7 @@ label ch22_m_start: "I give my poem to Monika." m "..." m "...Alright!" - $ nextscene = "m2_yuri_" + str(eval("chibi_y.appeal")) + $ nextscene = f"m2_yuri_{get_appeal("yuri")}" call expression nextscene m 1a "But anyway..." @@ -631,9 +631,9 @@ label ch22_m_start: return label ch23_m_start: - $ nextscene = "m2_yuri_" + str(eval("chibi_y.appeal")) + $ nextscene = f"m2_yuri_{get_appeal("yuri")}" call expression nextscene - if chibi_y.appeal < 3: + if get_appeal("yuri") < 3: m 1a "Anyway..." if y_gave: m 1m "I guess we won't worry about your poem..." @@ -749,4 +749,3 @@ label m2_yuri_3: m 1i "Don't say I didn't warn you, [player]." $ skip_poem = True return -# Decompiled by unrpyc: https://github.com/CensoredUsername/unrpyc diff --git a/README.md b/README.md index debc3f13..811d1145 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ -# Welcome to the **New** Python 3 Modification Club! +# Welcome to the **New** Modification Club! -

- -

+> [!WARNING] +> This branch of the Python 3 DDLC Mod Template is a work in progress. Use at your own risk!

- +

@@ -17,89 +16,206 @@

-The **new** Python 3 DDLC Mod Template is a mod template made by Azariel Del Carmen (bronya_rand) for the **original** Doki Doki Literature Club that adheres to [Team Salvato's IP Guidelines](http://teamsalvato.com/ip-guidelines/) for fan mods on Ren'Py 8. +## Table of Contents +- [📖 Overview](#-overview) +- [📋 Credit Requirements (Important)](#-credit-requirements) +- [✨ Features](#-features) +- [🚀 Quick Start](#-quick-start) +- [📦 Building & Distribution](#-building--distribution) +- [🎯 Platform-Specific Guides](#-platform-specific-guides) +- [📚 Additional Resources](#-additional-resources) +- [👏 Credits](#-credits) + +## 📖 Overview + +The DDLC Mod Template 2.0 is a comprehensive mod template for **Doki Doki Literature Club** that fully adheres to [Team Salvato's IP Guidelines](http://teamsalvato.com/ip-guidelines/). Built for Ren'Py 8.X by Azariel Del Carmen (bronya_rand), this template provides everything you need to create fan-made, cross-platform DDLC mods with modern features and optimized code. + +**Perfect for:** +- First-time mod creators looking for a solid foundation. +- Experienced modders wanting to upgrade to Ren'Py 8. +- Developers seeking cross-platform compatibility (Windows x64, macOS, Linux, Android). + +> [!NOTE] +> **The DDLC Mod Template is not affiliated in any way with Team Salvato nor is it designed for the sequel "Doki Doki Literature Club Plus".** + +> [!NOTE] +> For legacy Ren'Py support (Ren'Py 6.99-7.8.X), see the [Python 2](https://github.com/Bronya-Rand/DDLCModTemplate2.0/tree/python-2) branch of the mod template. + +--- + +## ✨ Features + +### Core Features + +- ✅ **Team Salvato Compliant** - Includes required splashscreen (disclaimer) and follows all IP guidelines for fan mods. +- 🐍 **Python 3 & Ren'Py 8 Optimized** - Clean, modern code optimized for the latest Ren'Py. +- 📚 **Original DDLC Scripts Included** - Reference the original game scripts for learning purposes. +- 🌐 **Cross-Platform Support** - Build for Windows, macOS, Linux, and Android. +- 🎨 **Automatic GUI Coloring** - Customize GUI and menu button colors without editing assets. +- 🖼️ **Dynamic Super Resolution (DSR/DSP)** - Universal resolution template supporting custom resolutions. +- 📝 **Player Name Change** - Allow players to correct or change their name in-game. +- 💬 **Enhanced Console & Poem Responses** - Improved Monika console and cleaner poem response system. + +### Gameplay Features + +- 🎮 **Uncensored Mode** - Option to show more sensitive content. +- 📹 **Let's Play Mode** - Protect personal information while streaming/recording. +- 📖 **NVL Support** - Full NVL (novel-style) dialogue support thanks to Yagamirai01. + +### Returned DDLC Features + +Classic DDLC features restored and improved: +- 👻 **Ghost Menu** - Dan's spooky easter egg. +- 💔 **Character Kill Scripts** - Sayori and Monika deletion scripts. +- 📄 **Special Poems** - Act 2 random poems _(now improved!)_. + +### Optional Extras + +> [!IMPORTANT] +> Download `DDLCModTemplate-X.X.X-Extras.zip` to access these optional features. + +- 💥 **Better Blue Screens of Death** - Create custom BSODs on all platforms. +- 🖼️ **Gallery System** - Showcase your artwork and CGs. +- 🏆 **Achievements Menu** - Reward players for completing milestones. +- 🎮 **[BETA] Discord Rich Presence** - Show mod activity on Discord. + +--- + +## 🚀 Quick Start -> Additionaly [here](./CREDITS.md) are all the contributors that contributed to the mod template. +### Prerequisites +- **Ren'Py 8.X** - Download from [Ren'Py.org](https://www.renpy.org/latest.html) +- **DDLC PC Version** - Download from [DDLC.moe](https://ddlc.moe/) +- **DDLC Mod Template** - Download from [Releases](https://github.com/Bronya-Rand/DDLCModTemplate2.0/releases) -> For Ren'Py 6-7 support, see the [Python 2](https://github.com/Bronya-Rand/DDLCModTemplate2.0/tree/python-2) branch. +### Installation Steps -### Disclaimers +1. **Extract Ren'Py** to a folder of your choice. +> [!WARNING] +> Do not extract Ren'Py to a cloud storage folder (e.g. Google Drive, OneDrive, etc.) as it will cause issues when testing your mod. -- Team Salvato - > The template code/files are designed for original DDLC fan games and mods that use DDLC assets with Ren'Py. It is not meant for non-DDLC projects. The DDLC Mod Template is not afilliated in anyway with Team Salvato. -- bronya_rand - > You may not use the template to make unofficial DDLC patchers, fixes, etc. +2. **Create a new folder** in the `renpy-8.X.X-sdk` folder and extract the DDLC Mod Template ZIP into it. -### **Credit Requirements** +3. **Extract DDLC assets** - Open `DDLC-1.1.1-pc.zip` and copy these RPA files into the mod template's `game` folder: + - `audio.rpa` + - `fonts.rpa` + - `images.rpa` -You must include a name credit in your mods' credits screen and/or `credits.txt` file. Below is a example credit you may use. +4. **Launch the template** + - Open the Ren'Py Launcher. + - Select the DDLC Mod Template project. + - Click _Launch Project_ to test it. -> This mod was made possible by bronya_rand's DDLC Mod Template 2.0: https://github.com/Bronya-Rand/DDLCModTemplate2.0 +🎉 You're ready to start modding! -By default a credits screen is enabled in-game, either in the Extras screen or as a button in-game if the Extras screen is disabled. +--- -Optional but very appreciated credits that you can also add are +## 📦 Building & Distribution -1. A custom splash screen that features the Team Salvato logo (and/or your mod logo) and a `Bronya Rand` logo (which can be found [here](.github/IMAGES/Logos/)). -2. A small mention in the game's disclaimer saying that this mod was not possible without using bronya_rand's mod template. -3. A presplash screen that contains a `Bronya Rand` logo (which can be found [here](.github/IMAGES/Logos)). -4. Present a custom idea to me for approval either through Discord or Reddit. +When you're ready to release your mod: -### Template Features +1. Open the **Ren'Py Launcher**. +2. Click on **Build Distributions**. +3. **Uncheck all options** and check **Ren'Py 8 DDLC Compliant Mod**. +4. Click **Build**. -1. Ren'Py 8 Team Salvato Compliant Mods and Splashscreen (Disclaimer)! -2. DDLC's exact RPY files with explainations. -3. Support for macOS, Linux\* and Android\*\*! +This creates a cross-platform mod package ZIP file (marked with `-Renpy8-DDLCMod` in the filename) containing your mod files ready for distribution. - > \* - Linux users must run your mod via `LinuxLauncher.sh`. +> [!TIP] +> Always test your mod thoroughly before building and distributing! - > \*\* - If your mod uses simple code or DDLC's/template built-in functions. More complex code or non-mobile friendly features may require some adjustments and changes to get working. See _Android Mod Guide.pdf_ or visit the DDMC Discord for additional help. +--- -4. Xcode Support! Open this project in Xcode and you can edit, build, and run your mod without opening the Ren'Py Launcher ever again! - > Note: You need to change your `RENPY_TOOL` location and the Ren'Py app location in the target scheme for Xcode. [Learn more ›](XCODE.md) -5. Uncensored Mode and Let's Play Mode! - Allow more "sensitive" content to be shown in-game and protect your IRL information while streaming/recording! -6. Automatic GUI Coloring and Different Menu Button Colors! - Color the GUI and/or menu buttons in the game to whatever you like without editing the asset files themselves! -7. Terra's in-depth Poem Game guide! -8. NVL Support thanks to Yagamirai01! -9. Patches for several Ren'Py releases and Windows features. -10. Python 3 support and code now in use! -11. Dynamic Super Resolution/Dynamic Super Positions (DSR/DSP) and Custom Resolutions! - Scale positions and/or your assets higher than they usually can go and display DDLC in different resolution modes. The DDLC Mod Template is now a universal X resolution template! -12. Player Name Change! - Did you wrongly typed your name or want to change it? You can now do so very easily! -13. New Monika Console and Poem Responses! - Enjoy a easier console to type commands in and a cleaner, easier poem response! +## 🎯 Platform-Specific Guides -In addition to these base features, the template comes with additional optional features you can use such as +### Android -- **[BETA]** Pronoun Support! - Allow players to identify with the pronoun they go by! - > See _mod_extras/pronouns.rpy_ in the `game` folder for a example on how to use this feature. -- Better Blue Screens of Death! - Make your own BSOD easily in-game on every OS! -- Gallery and Achievements Menu! - Allow players to see the work you have done in-game and earn achievements for playing your mod! -- **[BETA]** Discord Rich Presence! +Making your mod work on Android requires additional considerations, especially for complex features or non-mobile-friendly code. -> To download these features, you must download the `DDLCModTemplate-X.X.X-Extras.zip` along with the base game. +📱 **Read the full guide:** [Android Mod Guide](./Documentation/Android%20Mod%20Guide.pdf) -### Returned Features +> [!NOTE] +> For older templates, refer to the PDF included in your template's ZIP file as the latest guide may not match your version. -1. Ghost Menu (Dan's spooky easter egg). -2. Sayori Kill Script (plays if Sayori is deleted before the game starts). -3. Monika Kill Script (plays if Monika is deleted before a new game starts). -4. Special Poems (The random poems in DDLC that appear in Act 2) [now improved!]. +### Linux -### Getting Started +Linux users must run mods using the included launcher script: -Follow the steps listed [here](https://github.com/Bronya-Rand/DDLCModTemplate2.0/wiki/Installing-the-Mod-Template) in order to install the mod template. +```bash +./LinuxLauncher.sh +``` -> Once you finished writing your script, select _Build Distributions_. Uncheck all the options and check only the version of Ren'Py's "DDLC Compliant Mod" Option (`Ren'Py X DDLC Compliant Mod`) i.e. 'Ren'Py 7 DDLC Compliant Mod' for Ren'Py 7 and click Build. This will create a cross-platform mod package ZIP file with your mod files. +### macOS -- Ren'Py 6 Mods are classified with the `-Mod` ending in the ZIP filename. -- Ren'Py 7 Mods are classified with the `-Renpy7Mod` ending in the ZIP filename. -- Ren'Py 8 Mods are classified with the `-Renpy8-DDLCMod` ending in the ZIP filename. +macOS support is included out of the box. Build distributions include macOS packages automatically. -### Getting Started For Android Porting/Modding +--- -Refer to [_The DDLC Android Mod Guide_](./Documentation/Android%20Mod%20Guide.pdf) for more in-depth information about making your mod work on Android. +## 📋 Credit Requirements -> For older templates, refer to the PDF in your templates' ZIP file as the latest guide may not match your current template. +> [!IMPORTANT] +> **You MUST credit this template in your mod.** By default, a credits screen is enabled in-game (either in the Extras screen or as a standalone button). You can use the default implementation or choose one of the alternatives below. -Copyright © 2019-2024 Azariel "Bronya Rand" Del Carmen (bronya_rand). All rights reserved. +### Default Credit Text -Doki Doki Literature Club, the Doki Doki Literature Club code, is the property of Team Salvato (Dan Salvato LLC). Copyright © 2017 Team Salvato. All rights reserved. +Include this in your mod's credits screen and/or `credits.txt` file: + +``` +This mod was made possible by bronya_rand's DDLC Mod Template 2.0: https://github.com/Bronya-Rand/DDLCModTemplate2.0 +``` + +### Alternative Credit Methods + +If you prefer a different approach, you may use one of these alternatives: + +1. **Custom Splash Screen** - Feature the Team Salvato logo alongside a Bronya Rand logo ([available here](.github/IMAGES/Logos/)). +2. **Disclaimer Mention** - Add a line to your game's disclaimer: "This mod was made possible using bronya_rand's mod template". +3. **Presplash Screen** - Include a Bronya Rand logo ([available here](.github/IMAGES/Logos)) in your presplash. +4. **Custom Idea** - Contact me via Discord or Reddit with your proposed credit method for approval. + +--- + +## 📚 Additional Resources + +### Documentation + +- 📱 [Android Mod Guide](./Documentation/Android%20Mod%20Guide.pdf) - Complete guide for Android porting +- 🎮 [Discord RPC Guide](./Documentation/Discord%20RPC%20Guide.pdf) - Set up Discord Rich Presence +- 📝 [New Poem Game Guide](./Documentation/New%20Poemgame%20Guide.pdf) - In-depth poem game documentation + +### Community & Support + +- 💬 **DDMC Discord** - Get help and share your mods with the community +- 🐛 **Issues** - Report bugs on [GitHub Issues](https://github.com/Bronya-Rand/DDLCModTemplate2.0/issues) +- ☕ **Support Development** - [Buy me a Ko-fi](https://ko-fi.com/K3K22K8SU) + +--- + +## 👏 Credits + +Thanks to the following people for their contributions to the DDLC Mod Template: + +> [!NOTE] +> This list goes from the past to present. + +- Dan Salvato (DDLC) +- renpytom (Ren'Py) +- MAS Team (template base before revamping) +- alicerunsonfedora (Xcode) +- Terra (In-depth poem game) +- Yagamirai01 (NVL) +- Alexxonder (Auto Color Adjustments) +- Elckarow (Python 3 updates, New poem responses/effects) +- NekoLaiS (Cryllic compatibility) +- The DDMC Community (Feature suggestions and feedback) +- Pseurae (Donation/Act 3 GL2 Fix) +- Lezalith (New Console (4.1.1+)) +- RS/6000 (New Mod Template Logo (4.2.1+)) +- Tulkas (Android Gestures) +- FiT (Weiss Chibi Branding Icon Design) + +--- + +

+ Copyright © 2019-2025 Azariel "Bronya Rand" Del Carmen (bronya_rand). All rights reserved. Doki Doki Literature Club, the Doki Doki Literature Club code, is the property of Team Salvato. Copyright © 2017 Team Salvato. All rights reserved. +

diff --git a/zipper.py b/build/zipper.py similarity index 94% rename from zipper.py rename to build/zipper.py index 885d8cc1..d4822999 100644 --- a/zipper.py +++ b/build/zipper.py @@ -1,5 +1,5 @@ from zipfile import ZipFile, ZIP_DEFLATED -from zipper_env import PY3, EXTRAS +from build.zipper_env import PY3, EXTRAS import sys import os @@ -7,6 +7,7 @@ EXCLUDE_LIST = [ ".github", ".git", + ".venv", ".gitattributes", ".gitignore", "requirements.txt", @@ -15,6 +16,9 @@ "zipper.py", "zipper_env.py", "__pycache__", + "tests", + "tests.py", + "build" ] @@ -47,7 +51,7 @@ def main(): ZIP_DEFLATED, compresslevel=5, ) as main_template: - for src, dirs, files in os.walk("."): + for src, dirs, files in os.walk(".."): for f in files: path = os.path.join(src, f) validLocation = True diff --git a/zipper_env.py b/build/zipper_env.py similarity index 100% rename from zipper_env.py rename to build/zipper_env.py diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/act_two/README.md b/game/act_two/README.md index 5dd1e70c..b99cdfa9 100644 --- a/game/act_two/README.md +++ b/game/act_two/README.md @@ -1,10 +1,14 @@ -# Contents +# Contents of the `act_two` folder -## console.rpy -This file defines the code for Monika's console that appears at the end of Act Two through Act Four. +## Folders -## glitchtext.rpy -This file defines the glitched/corrupted text seen in Act Two through Four of the game. +- **[py](./py)**: Contains the Python code for the code used in Act Two of DDLC. +- **\_\_pycache\_\_**: Contains the compiled Python files for the code used in Act Two - Four of DDLC. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. -## poems_special.rpy -This file defines the special poems that the player can see during Act Two. Only three poems are ever shown to the player which are selected at random by `splash.rpy` (in *definitions* folder). \ No newline at end of file +## Files +- **README.md**: This file, which provides an overview of the contents of the `act_two` folder. +- **\_\_init\_\_.py**: An empty file used for testing purposes. +- **[poems_special.rpy](./poems_special.rpy)**: Defines the special poems that the player can see during Act Two. Only three poems are ever shown to the player which are selected at random by *[splash.rpy](../splash.rpy)*. + +## Moved Files +- **glitchtext.rpy**: This file was moved from the `game/act_two/py` folder as *[glitchtext_ren.py](./py/glitchtext_ren.py)* as this is purely a Ren'Py file with a Python function. \ No newline at end of file diff --git a/game/act_two/__init__.py b/game/act_two/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/act_two/console.rpy b/game/act_two/console.rpy index be54512c..422be5c0 100644 --- a/game/act_two/console.rpy +++ b/game/act_two/console.rpy @@ -1,67 +1,24 @@ -## console.rpy +# This file contains the Ren'Py code for Monika's console in DDLC. -# This file defines the Monika Console contents that appears in the game when -# Monika deletes characters. +# The logic for the console has been changed drastically compared to the original +# game to allow for better management of console inputs and outputs and display. +# It separates the Python logic from the display code, which is now in `py/console_ren.py`. -# This file has heavily changed from DDLC to provide better access to call the -# console than via labels. To call, do $ run_input(input="Text", output="Output"). -# To only show, the console, just do `show screen console_screen`. -# Thank you Lezalith for assistance in making this new console! +# To show the console, just do `$ console.show_screen()` or `show screen console_screen(console)`. -init -1: - - # None or tuple with (input, output). - default new_input = None - - # List with outputs. - default console_history = [] - - # Not to be changed midgame. - # Delay after input has finished showing, before output is displayed. - define console_delay = 0.5 - - define console_cps = 30 - -init python: - - # Make the console display the given input and output. - def run_input(input, output): - global new_input - - new_input = (input, output) - - if renpy.get_screen("console_screen"): - renpy.hide_screen("console_screen") - renpy.call_screen("console_screen", finish=True) - renpy.show_screen("console_screen") +# For the Python code, see `console_ren.py` in the `py` directory. - # Add the output to history. - def add_to_history(input): - global console_history - - console_history.insert(0, input[1]) - if len(console_history) > 5: - console_history.pop(5) - - # Add the output to history after code is done - def input_finished(): - global new_input - - add_to_history(new_input) - new_input = None - - renpy.restart_interaction() - - def clear_history(): - global console_history - - console_history = [] +init -1: + default console = Console(console_delay=0.5, console_cps=30, max_log_history=5) -screen console_screen(finish=False): +screen console_screen(console, input_text=None, output_text=None): + """ + This screen shows the console in-game. + """ style_prefix "console_screen" - default finish_actions = [Function(input_finished), SetScreenVariable("in_progress", False), Return()] + default finish_actions = [SetScreenVariable("in_progress", False), Return()] # String of input to show. # It is put outside of the new_input variable so it doesn't @@ -76,16 +33,14 @@ screen console_screen(finish=False): $ new_input_code = "_" - # If a new_input is available, set it as code to display. - if store.new_input: - + if input_text: $ in_progress = True - $ new_input_code = store.new_input[0] + $ new_input_code = input_text # New code is showing. if in_progress: - timer ( float(len(renpy.filter_text_tags(new_input_code, deny = []))) / float(console_cps) + console_delay ) action finish_actions + timer ( float(len(renpy.filter_text_tags(new_input_code, deny = []))) / float(console.console_cps) + console.console_delay ) action finish_actions frame: @@ -100,8 +55,9 @@ screen console_screen(finish=False): vbox: xpos 26 ypos 30 spacing 5 - for x in store.console_history: - text x + + for output in console.console_history.values(): + text output style console_screen_frame: background Frame(Transform(Solid("#333"), alpha=0.75)) @@ -117,7 +73,11 @@ style console_screen_text: # This label clears all console history and commands from the console in-game. # Decided to keep this for now as it just pauses stuff. +# This assumes the default console is used from the above init python import. label updateconsole_clearall(text="", history=""): + if config.developer: + $ renpy.notify("This label call is deprecated. Use `console.clear_history()` instead.") + $ console.clear_history() $ pause(len(text) / 30.0 + 0.5) $ pause(0.5) return \ No newline at end of file diff --git a/game/act_two/glitchtext.rpy b/game/act_two/glitchtext.rpy deleted file mode 100644 index 68e10872..00000000 --- a/game/act_two/glitchtext.rpy +++ /dev/null @@ -1,15 +0,0 @@ -## glitchtext.rpy - -# This file defines the glitched/corrupted text seen in DDLC. - -init python: - nonunicode = "¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽž" - - # This function grabs a random amount of non-unicode letters based off - # the length requested and returns it to the game to be displayed. - def glitchtext(length): - output = "" - for x in range(length): - output += random.choice(nonunicode) - return output - diff --git a/game/act_two/py/README.md b/game/act_two/py/README.md new file mode 100644 index 00000000..4b67ed1e --- /dev/null +++ b/game/act_two/py/README.md @@ -0,0 +1,10 @@ +# Contents of the `act_two/py` folder + +## Folders +- **\_\_pycache\_\_**: Contains the compiled Python files for the specific Python code features used in Act Two - Four of DDLC. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. + +## Files +- **README.md**: This file, which provides an overview of the contents of the `py` folder. +- **\_\_init\_\_.py**: An empty file used for testing purposes. +- **[glitchtext_ren.py](./glitchtext_ren.py)**: Contains the `glitchtext` function which generates glitched/corrupted text in DDLC. Replaces the old *glitchtext.rpy* file that was located in the `game/act_two` folder. +- **[console_ren.py](./console_ren.py)**: Contains the Python code for Monika's console in Act Two of DDLC that has been separated from the original *console.rpy* file. \ No newline at end of file diff --git a/game/act_two/py/__init__.py b/game/act_two/py/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/act_two/py/console_ren.py b/game/act_two/py/console_ren.py new file mode 100644 index 00000000..b1d85be6 --- /dev/null +++ b/game/act_two/py/console_ren.py @@ -0,0 +1,96 @@ +# This file contains the Python code for Monika's console in DDLC. + +# The logic for the console has been changed drastically compared to the original +# game to allow for better management of console inputs and outputs and display. +# It also follows the Ren'Py approach of using the new `_ren.py` file for Python code. + +# For the console display code, see `console.rpy` in the `act_two` directory. + +## This import is not used when the game is running, but exists so IDEs reports +## one warning than multiple. +import renpy # type: ignore + +"""renpy +init python: +""" + + +class Console(object): + """ + Handles the console logic for DDLC's "terminal". + """ + + def __init__( + self, + console_delay: float, + console_cps: int, + max_log_history: int = 5, + testing: bool = False, + ): + """ + Initializes the console with the given delay and characters per second (cps). + + :param console_delay: Delay after input has finished showing, before output is displayed. + :param console_cps: Characters per second for output display. + :param max_log_history: Maximum number of log entries to keep. + :param testing: Bypasses Ren'Py's screen system for testing purposes. Unused in DDLC. Used for Github Actions to test code logic. + + :type console_delay: float + :type console_cps: int + :type max_log_history: int + :type testing: bool + """ + + self.console_delay = console_delay + self.console_cps = console_cps + self.max_log_history = max_log_history + + # Initialize the console history as an empty dictionary. + self.console_history: dict[str, str] = {} + + self.testing = testing + + def __call__(self, input_text: str, output_text: str): + """ + Processes the input and output text for the console. + If you want specific stuff to happen whilst the input is being displayed, + you should add it here. + + :param input_text: The input text to be processed. + :param output_text: The output text to be displayed after the input. + """ + + # If console history exceeds the maximum with a new entry, remove the oldest entry. + if len(self.console_history) + 1 > self.max_log_history: + oldest_key = min(self.console_history.keys()) + del self.console_history[oldest_key] + + # Show the console screen with the input and output. + if not self.testing: + if renpy.get_screen("console_screen"): + renpy.hide_screen("console_screen") + renpy.call_screen( + "console_screen", + console=self, + input_text=input_text, + output_text=output_text, + ) + + # Store the input and output in the console history. + self.console_history[input_text] = output_text + self.show_screen() + + renpy.restart_interaction() + + def clear_history(self): + """ + Clears the console history. + """ + self.console_history.clear() + + def show_screen(self): + """ + Shows the console screen. + """ + if not self.testing: + renpy.show_screen("console_screen", console=self) diff --git a/game/act_two/py/glitchtext_ren.py b/game/act_two/py/glitchtext_ren.py new file mode 100644 index 00000000..1329620f --- /dev/null +++ b/game/act_two/py/glitchtext_ren.py @@ -0,0 +1,51 @@ +# This file contains the Python code for the glitched/corrupted text that appears in DDLC during Act 2-4 of DDLC. + +# The code logic is the same as the original game, only rewritten to use the Ren'Py's `_ren.py` approach for Python +# code. + +import random + +"""renpy +init python: +""" + +def glitchtext(length: int) -> str: + """ + Generates a string of random unicode characters of a specified length. + + :param length: The length of the string to generate. + + :type length: int + :return: A string of random unicode characters. + :rtype: str + """ + if length <= 0: + return "" + + ## Set of unicode ranges to use for generating the glitched text that are visible + ## This can be expanded with more ranges depending on font compatibility. + text_ranges = [ + (0x00C0, 0x00FF), # Latin-1 Supplement (accented characters) + (0x0100, 0x017F), # Latin Extended-A + (0x0180, 0x024F), # Latin Extended-B + (0x1E00, 0x1EFF), # Latin Extended Additional + (0x0370, 0x03FF), # Greek and Coptic + (0x0400, 0x04FF), # Cyrillic + ] + + exclude_chars = set([ + 0x00AD, # Soft hyphen + ]) + + ## Generate a string of random unicode characters + result: list[str] = [] + while len(result) < length: + # Randomly select a range from the text_ranges + for start, end in text_ranges: + for code_point in range(start, end + 1): + if code_point not in exclude_chars and chr(code_point).isprintable(): + result.append(chr(code_point)) + + # Shuffle the result to ensure randomness + random.shuffle(result) + return ''.join(result[:length]) diff --git a/game/chrs/README.md b/game/chrs/README.md index 6a0403ea..58573648 100644 --- a/game/chrs/README.md +++ b/game/chrs/README.md @@ -1,13 +1,8 @@ -# Contents - -## monika.chr -Monika's character file. Technically just a PNG. - -## natsuki.chr -Natsuki's character file. Technically just a JPEG. - -## sayori.chr -Sayori's character file. Technically just Sayo-nara's OGG file. - -## yuri.chr -Yuri's character file. Technically just a poem in a text file. \ No newline at end of file +# Contents of the `chrs` folder + +## Files +- **README.md**: This file, which provides an overview of the contents of the `chrs` folder. +- **monika.chr**: Monika's character file. Technically its just a PNG file. +- **natsuki.chr**: Natsuki's character file. Technically its just a JPEG file. +- **sayori.chr**: Sayori's character file. Technically its just a OGG file of the "Sayo-nara" track. +- **yuri.chr**: Yuri's character file. Technically its just a text file containing a poem. \ No newline at end of file diff --git a/game/core/0imports.rpy b/game/core/0imports.rpy index 1c1516e6..05ff468e 100644 --- a/game/core/0imports.rpy +++ b/game/core/0imports.rpy @@ -3,24 +3,6 @@ # This file imports certain python modules at runtime for DDLC and template # features. -python early: - # For DSR/DSP, Effects - import math - - # For Credits - import datetime - - # For Glitchtext - import random - - # For Splash - import re - import os - - # For BSOD - import subprocess - import platform - init -1 python: # Achievements/Gallery try: @@ -31,4 +13,4 @@ init -1 python: try: from store.gallery import GalleryImage, galleryList except ModuleNotFoundError: - pass + pass \ No newline at end of file diff --git a/game/core/README.md b/game/core/README.md index 0a50d24b..89184f29 100644 --- a/game/core/README.md +++ b/game/core/README.md @@ -1,27 +1,17 @@ -# Contents -> These files are crucial to DDLC/the mod template itself. **DO NOT** delete or edit these files (except `credits.rpy`) unless you know what you are doing. +# Contents of the `core` folder +> [!DANGER] +> The following files and folders in the `core` folder are crucial to DDLC and/or the mod template itself. **DO NOT** delete or edit these files unless you know what you are doing except for *[credits.rpy](./credits.rpy)*. -## 0imports.rpy -This file imports the needed python modules necessary for DDLC and the template to run properly. +## Folders -## credits.rpy -This file defines the code for the credits that play at the end of Act Four. +- **[py](./py)**: Contains the Python code for the core features of DDLC and the mod template. +- **\_\_pycache\_\_**: Contains the compiled Python files for the code used in the core features of DDLC and the mod template. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. -## depreciation.rpy -This file contains old labels formerly used by old features before a revised update. **This file is temporary and may be removed at any time**. +## Files +- **[0imports.rpy](./0imports.rpy)**: This file imports certain python modules after bootstrap but before game boot for DDLC and template features. +- **[credits.rpy](./credits.rpy)**: This file defines the code for the credits screen that plays at the end of Act Four. -## exceptions.rpy -This file stores the exceptions that the mod template can throw to you during development. The following exceptions stored in this file that can occur are: - -- `NotRenPyEight` - You are attempting to run this mod project on Ren'Py 6 or 7. - > Solution: Run your mod in Ren'Py 8 or download the Python 2 template and make your mod from scratch with it. -- `DDLCRPAsMissing` - You are missing a RPA file in the *game* folder which the game requires in order to load the game properly. - > Solution: Add the missing RPA and try relaunching the game again. -- `IllegalModLocation` - You have placed your mod/mod project in a location that the mod template prohibits (usually the OneDrive folder). - > Solution: Move the mod/mod project to a different location and try relaunching the game again. - -## lockdown_check.rpy -This file checks to see if you are running the mod template on a prohibited Ren'Py version, Ren'Py 8 or a untested Ren'Py version and warns you about it. - -## renpy_patches.rpy -This file contains patches necessary for DDLC to run properly on higher versions of Ren'Py. \ No newline at end of file +## Moved Files/Contents +- **[0imports.rpy](./py/0imports_ren.py)**: The `python early` imports were moved from the `game/core` folder to the `game/core/py` folder for organizational purposes. Contains the imports for the core features of DDLC and the mod template to import at bootstrap. +- **[renpy_patches.rpy](./py/renpy_patches_ren.py)**: This file was moved from the `game/core` folder to the `game/core/py` folder as this is purely a Ren'Py file with Python patches. Contains several patches in order for DDLC to run properly on Ren'Py 8. +- **[exceptions.rpy](./py/template_checks_ren.py)** and **lockdown_check.rpy** - These files were combined into *template_checks_ren.py* file as they were purely Ren'Py files with Python code and exceptions. Contains custom exceptions and checks for the Mod Template to look for before DDLc boots up. diff --git a/game/core/depreciated.rpy b/game/core/depreciated.rpy deleted file mode 100644 index 27a78297..00000000 --- a/game/core/depreciated.rpy +++ /dev/null @@ -1,24 +0,0 @@ - -## depreciated.rpy -## This file stores old code from DDLC that is now depreciated from use in -## the mod template. -## This file is temporary and may disappear in the near future. -############################################################################### - -# Former Monika Console Calls -label updateconsole(text="", history=""): - call screen dialog(message="{b}Warning{/b}\nThis feature has been depreciated. Use the following in order to\nregain this back.\n {i}$ run_input(\"[text]\", \"[history]\"){/i}." - ok_action=Hide("dialog")) - return - -# This label adds certain text to the console history. -label updateconsolehistory(text=""): - call screen dialog(message="{b}Warning{/b}\nThis feature has been depreciated and requires an additional argurment.\nUse the following in order to regain this back.\n {i}$ add_to_history((\"[text]\", \"output\")){/i}." - ok_action=Hide("dialog")) - return - -# This label hides the console in-game. -label hideconsole: - call screen dialog(message="{b}Warning{/b}\nThis feature has been depreciated. Use the following in order to\nregain this back.\n {i}hide screen console_screen{/i}." - ok_action=Hide("dialog")) - return \ No newline at end of file diff --git a/game/core/exceptions.rpy b/game/core/exceptions.rpy deleted file mode 100644 index 82cafd3c..00000000 --- a/game/core/exceptions.rpy +++ /dev/null @@ -1,21 +0,0 @@ - -# exceptions.rpy -# This file contains the exceptions for certain DDLC/Template errors -# DO NOT MODIFY THIS FILE! - -python early: - - class NotRenPyEight(Exception): - def __str__(self): - return "This version of the mod template is designed for Ren'Py 8.\nEither build/run your mod on Ren'Py 8, or install the 'py2' mod template instead from scratch." - - class DDLCRPAsMissing(Exception): - def __init__(self, archive): - self.archive = archive - - def __str__(self): - return "'" + self.archive + ".rpa' was not found in the game folder. Check your DDLC installation for missing RPAs and try again." - - class IllegalModLocation(Exception): - def __str__(self): - return "DDLC mods/mod projects cannot be run from this folder as it is a OneDrive or another cloud folder.\nMove your mod/mod project to another location and try again." diff --git a/game/core/lockdown_check.rpy b/game/core/lockdown_check.rpy deleted file mode 100644 index f2d38d52..00000000 --- a/game/core/lockdown_check.rpy +++ /dev/null @@ -1,36 +0,0 @@ -## Copyright 2019-2024 Azariel Del Carmen (bronya_rand). All rights reserved. - -## lockdown_check.rpy -# This file is mainly designed to warn new modders about bugs with certain Ren'Py -# versions or warn them about QA issues with running Ren'Py versions higher than -# the one the mod template was tested for. -# New in 4.0.0: Add lockout for Ren'Py 6/7 on Py 3 templates. - -## DO NOT MODIFY THIS FILE! ## - -# Checks if we are on Ren'Py 8 -python early: - - if renpy.version_tuple < (8, 0, 0, 22062402): - raise NotRenPyEight - -label lockdown_check: - - $ version = renpy.version() - - if renpy.version_tuple > (8, 1, 0, 23051307): - - scene black - "{b}Warning:{/b} The version of Ren'Py you are trying to mod DDLC on has not been tested for modding compatibility." - "The last recent version of Ren'Py 8 that works for DDLC mods is \"{i}Ren'Py 8.1.0{/i}\"." - "Running DDLC or your DDLC mod on a higher version than the one tested may introduce bugs and other game breaking features." - - menu: - "By continuing to run your mod on [version!q], you acknoledge this disclaimer and the possible problems that can happen on a untested Ren'Py version." - "I agree.": - $ persistent.lockdown_warning = True - return - - else: - $ persistent.lockdown_warning = True - return diff --git a/game/core/py/0imports_ren.py b/game/core/py/0imports_ren.py new file mode 100644 index 00000000..346fde7b --- /dev/null +++ b/game/core/py/0imports_ren.py @@ -0,0 +1,27 @@ +# This file contains the Python imports needed for DDLC and the Mod Template. + +# These imports are imported at bootstrap prior to any initialization of python code. + +"""renpy +python early: +""" + +# For Effects +import math + +# For the Credits Screen +import datetime + +# For Glitchtext +import random + +# For Splash +import re +import os + +# For BSOD +import subprocess +import platform + +# For Poem Responses +import typing diff --git a/game/core/py/README.md b/game/core/py/README.md new file mode 100644 index 00000000..39577728 --- /dev/null +++ b/game/core/py/README.md @@ -0,0 +1,11 @@ +# Contents of the `core/py` folder + +## Folders +- **\_\_pycache\_\_**: Contains the compiled Python files for the code used in the core features of DDLC and the mod template. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. + +## Files +- **README.md**: This file, which provides an overview of the contents of the `py` folder. +- **\_\_init\_\_.py**: An empty file used for testing purposes. +- **[0imports_ren.py](./0imports_ren.py)**: Contains the imports for the core features of DDLC and the mod template to import at bootstrap. +- **[renpy_patches_ren.py](./renpy_patches_ren.py)**: Contains several patches in order for DDLC to run properly on Ren'Py 8. +- **[template_checks_ren.py](./template_checks_ren.py)**: Contains custom exceptions and checks for the Mod Template to look for before DDLc boots up. This file combines the contents of *exceptions.rpy* and *lockdown_check.rpy* in older versions of the Mod Template. \ No newline at end of file diff --git a/game/core/py/renpy_patches_ren.py b/game/core/py/renpy_patches_ren.py new file mode 100644 index 00000000..788af4d8 --- /dev/null +++ b/game/core/py/renpy_patches_ren.py @@ -0,0 +1,21 @@ +# This file contains the Python code that is needed for DDLC to function correctly on Ren'Py 8. + +## These imports are not used when the game is running, but exists so IDEs reports +## one warning than multiple. +import os +import renpy # type: ignore + +"""renpy +python early: +""" + +# Patches old DDLC Windows commands to use PowerShell for compatibility with Windows 11. +if renpy.windows: + os.environ['wmic process get Description'] = "powershell (Get-Process).ProcessName" + os.environ['wmic os get version'] = "powershell (Get-WmiObject -class Win32_OperatingSystem).Version" + +## An ATL displayable will now start its animation when it first +## appears, rather than when the screen itself is shown. +## We will disable this for DDLC's transform's sakes. +if renpy.version_tuple >= (7, 4, 7, 1862): + renpy.config.atl_start_on_show = False diff --git a/game/core/py/template_checks_ren.py b/game/core/py/template_checks_ren.py new file mode 100644 index 00000000..20e970d2 --- /dev/null +++ b/game/core/py/template_checks_ren.py @@ -0,0 +1,39 @@ +# This file contains the Python code to handle Mod Template specific exceptions +# and checks for Ren'Py 8 at bootstrap. + +## This import is not used when the game is running, but exists so IDEs reports +## one warning than multiple. +import renpy # type: ignore + +"""renpy +python early: +""" + + +class NotRenPyEight(Exception): + def __str__(self): + return "This version of the mod template is designed for Ren'Py 8.\nEither build/run your mod on Ren'Py 8, or install the 'py2' mod template using Ren'Py 6/7." + + +class DDLCRPAsMissing(Exception): + def __init__(self, archive: str): + self.archive = archive + + def __str__(self): + return ( + "'" + + self.archive + + ".rpa' was not found in the game folder. Check your DDLC installation for missing RPAs and try again." + ) + + +class IllegalModLocation(Exception): + def __str__(self): + return ( + "DDLC mods/mod projects cannot be run from this folder as it is a OneDrive or another cloud folder.\n" + "Move your mod/mod project to another location and try again." + ) + + +if renpy.version_tuple < (8, 0, 0, 22062402): + raise NotRenPyEight diff --git a/game/core/renpy_patches.rpy b/game/core/renpy_patches.rpy deleted file mode 100644 index 33fcaa3b..00000000 --- a/game/core/renpy_patches.rpy +++ /dev/null @@ -1,17 +0,0 @@ -## Copyright 2019-2024 Azariel Del Carmen (bronya_rand). All rights reserved. - -## renpy_patches.rpy -# This file is mainly designed to patch certain versions of Ren'Py that break -# DDLC/DDLC mods by patching the Ren'Py engine at startup. - -python early: - import os - ## Readds WMIC using Powershell's Get-WmiObject class (for Win 11) - os.environ['wmic process get Description'] = "powershell (Get-Process).ProcessName" - os.environ['wmic os get version'] = "powershell (Get-WmiObject -class Win32_OperatingSystem).Version" - - ## An ATL displayable will now start its animation when it first - ## appears, rather than when the screen itself is shown. - ## We will disable this for DDLC's transform's sakes. - if renpy.version_tuple >= (7, 4, 7, 1862): - config.atl_start_on_show = False diff --git a/game/definitions/definitions.rpy b/game/definitions/definitions.rpy index cacb5666..afeddd54 100644 --- a/game/definitions/definitions.rpy +++ b/game/definitions/definitions.rpy @@ -1,46 +1,16 @@ -## definitions.rpy - # This file defines important stuff for DDLC and your mod! -# This variable declares if the mod is a demo or not. -# Leftover from DDLC. +# This variable declares if the mod is a demo or not. A leftover from DDLC. define persistent.demo = False -# This variable declares whether the mod is in the 'steamapps' folder. +# This variable declares whether the mod is in the 'steamapps' folder (Steam Version of DDLC) define persistent.steam = ("steamapps" in config.basedir.lower()) -# This variable declares whether Developer Mode is on or off in the mod. -define config.developer = False - -# This python statement starts singleton to make sure only one copy of the mod -# is running. -python early: - import singleton - me = singleton.SingleInstance() - -init -3 python: - ## Dynamic Super Position (DSP) - # DSP is a feature in where the game upscales the positions of assets - # with higher resolutions (1080p). - # This is just simple division from Adobe, implemented in Python. - def dsp(orig_val): - ceil = not isinstance(orig_val, float) - dsp_scale = config.screen_width / 1280.0 - if ceil: return math.ceil(orig_val * dsp_scale) - # since `absolute * float` -> `float` - # we wanna keep the same type - return type(orig_val)(orig_val * dsp_scale) - - # This makes evaluating the value faster - renpy.pure(dsp) - - ## Dynamic Super Resolution - # DSR is a feature in where the game upscales asset sizes to higher - # resolutions (1080p) and sends back a modified transform. - # (Recommend that you just make higher res assets than upscale lower res ones) - def dsr(path): - img_bounds = renpy.image_size(path) - return Transform(path, size=(dsp(img_bounds[0]), dsp(img_bounds[1]))) +# This variable declares whether to enable Developer Tools from Ren'Py. +define config.developer = True + +# Whether to allow underfilled grids in the game. +define config.allow_underfull_grids = True ## Android Gestures (provided by Tulkas) ## These gestures allow players to access different settings using the touch screen. @@ -50,92 +20,12 @@ init -3 python: # Swipe Right - Skip Dialogue define config.gestures = { "n" : 'game_menu', "s" : "hide_windows", "e" : 'toggle_skip', "w" : "history" } -# This init python statement sets up the functions, keymaps and channels -# for the game. init python: ## More Android Gestures - # This variable makes a keymap for the history screen. + # Create a keymap for the history screen. if renpy.android: config.underlay.append(renpy.Keymap(history = ShowMenu("history"))) - # These commented variables sets all keybinds from Rollback to History. - # config.keymap["rollback"] = [] - # config.keymap["history"] = [ 'K_PAGEUP', 'repeat_K_PAGEUP', 'K_AC_BACK', 'mousedown_4' ] - - # These variable declarations adjusts the mapping for certain actions in-game. - config.keymap['game_menu'].remove('mouseup_3') - config.keymap['hide_windows'].append('mouseup_3') - config.keymap['self_voicing'] = [] - config.keymap['clipboard_voicing'] = [] - config.keymap['toggle_skip'] = [] - - # This variable declaration registers the music poem channel for the poem sharing music. - renpy.music.register_channel("music_poem", mixer="music", tight=True) - - # This function gets the postition of the music playing in a given channel. - def get_pos(channel='music'): - pos = renpy.music.get_pos(channel=channel) - if pos: return pos - return 0 - - # This function deletes all the saves made in the mod. - def delete_all_saves(): - for savegame in renpy.list_saved_games(fast=True): - renpy.unlink_save(savegame) - - # This function deletes a given character name from the characters folder. - def delete_character(name): - if renpy.android: - try: os.remove(os.environ['ANDROID_PUBLIC'] + "/characters/" + name + ".chr") - except: pass - else: - try: os.remove(config.basedir + "/characters/" + name + ".chr") - except: pass - - # These functions restores all the character CHR files to the characters folder - # given the playthrough number in the mod and list of characters to restore. - def restore_character(names): - if not isinstance(names, list): - raise Exception("'names' parameter must be a list. Example: [\"monika\", \"sayori\"].") - - for x in names: - if renpy.android: - try: renpy.file(os.environ['ANDROID_PUBLIC'] + "/characters/" + x + ".chr") - except: open(os.environ['ANDROID_PUBLIC'] + "/characters/" + x + ".chr", "wb").write(renpy.file("chrs/" + x + ".chr").read()) - else: - try: renpy.file(config.basedir + "/characters/" + x + ".chr") - except: open(config.basedir + "/characters/" + x + ".chr", "wb").write(renpy.file("chrs/" + x + ".chr").read()) - - def restore_all_characters(): - if persistent.playthrough == 0: - restore_character(["monika", "sayori", "natsuki", "yuri"]) - elif persistent.playthrough == 1 or persistent.playthrough == 2: - restore_character(["monika", "natsuki", "yuri"]) - elif persistent.playthrough == 3: - restore_character(["monika"]) - else: - restore_character(["sayori", "natsuki", "yuri"]) - - # This function is obsolete as all characters now restores only - # relevant characters to the characters folder. - def restore_relevant_characters(): - restore_all_characters() - - # This function pauses the time for a certain amount of time or indefinite. - def pause(time=None): - global _windows_hidden - - if not time: - _windows_hidden = True - renpy.ui.saybehavior(afm=" ") - renpy.ui.interact(mouse='pause', type='pause', roll_forward=None) - _windows_hidden = False - return - if time <= 0: return - _windows_hidden = True - renpy.pause(time) - _windows_hidden = False - ## Music # This section declares the music available to be played in the mod. # Syntax: @@ -193,6 +83,23 @@ define audio.closet_open = "sfx/closet-open.ogg" define audio.closet_close = "sfx/closet-close.ogg" define audio.page_turn = "sfx/pageflip.ogg" define audio.fall = "sfx/fall.ogg" +define audio.s_kill_glitch1 = "sfx/s_kill_glitch1.ogg" +define audio.fall2 = "sfx/fall2.ogg" +define audio.giggle = "sfx/giggle.ogg" +define audio.glitch1 = "sfx/glitch1.ogg" +define audio.glitch2 = "sfx/glitch2.ogg" +define audio.glitch3 = "sfx/glitch3.ogg" +define audio.gnid = "sfx/gnid.ogg" +define audio.interference = "sfx/interference.ogg" +define audio.monikapound = "sfx/monikapound.ogg" +define audio.mscare = "sfx/mscare.ogg" +define audio.run = "sfx/run.ogg" +define audio.slap = "sfx/slap.ogg" +define audio.smack = "sfx/smack.ogg" +define audio.stab = "sfx/stab.ogg" +define audio.yuri_kill = "sfx/yuri-kill.ogg" +define audio.crack = "sfx/crack.ogg" +define audio.eyes = "sfx/eyes.ogg" ## Backgrounds # This section declares the backgrounds available to be shown in the mod. @@ -1447,27 +1354,9 @@ define ny = Character('Nat & Yuri', what_prefix='"', what_suffix='"', ctc="ctc", # once you packaged your mod. define _dismiss_pause = config.developer -## [BETA] Pronoun Variables -# This section adds the feature to use player pronouns within the game text easily. -# To use this feature, simply ask the user for their pronoun and use it here. -# For capitalization, use heC, himC, areC and hesC -default persistent.he = "" -default persistent.him = "" -default persistent.are = "" -default persistent.hes = "" -default he = persistent.he -default him = persistent.him -default are = persistent.are -default hes = persistent.hes -default he_capital = he.capitalize() -default him_capital = him.capitalize() -default are_capital = are.capitalize() -default hes_capital = hes.capitalize() - ## Extra Settings Variables # This section controls whether the mod is censored or is in let's play mode. default persistent.uncensored_mode = False -default persistent.lets_play = False ## Variables # This section declares variables when the mod runs for the first time on all saves. @@ -1489,7 +1378,11 @@ default persistent.seen_ghost_menu = None default seen_eyes_this_chapter = False default persistent.anticheat = 0 default persistent.clear = [False, False, False, False, False, False, False, False, False, False] -default persistent.special_poems = None +default persistent.special_poems = { + 0: None, + 1: None, + 2: None, +} default persistent.clearall = None default persistent.menu_bg_m = None default persistent.first_load = None @@ -1517,27 +1410,28 @@ default n_name = "Natsuki" default y_name = "Yuri" # Poem Variables -# This section records how much each character likes your poem in-game. -# Syntax: -# -1 - Bad -# 0 - Neutral -# 1 - Good -# To add a new poem person, make a poem array like in this example: -# default e_poemappeal = [0, 0, 0] - -default n_poemappeal = [0, 0, 0] -default s_poemappeal = [0, 0, 0] -default y_poemappeal = [0, 0, 0] -default m_poemappeal = [0, 0, 0] - -# This variable keeps tracks on which person won the poem session after each day. -default poemwinner = ['sayori', 'sayori', 'sayori'] - -# These variables keep track on who has read your poem during poem sharing -default s_readpoem = False -default n_readpoem = False -default y_readpoem = False -default m_readpoem = False +# This section stores a character's appeal towards the player's poem and poem winner. +# For DDLC, since there are three poems written, each character has three values +# representing each respective chapter poem as well as the person who likes the poem the most. + +default poemappeal = { + "sayori": {0: 0, 1: 0, 2: 0}, + "natsuki": {0: 0, 1: 0, 2: 0}, + "yuri": {0: 0, 1: 0, 2: 0}, +} + +default poemwinner = { + 0: "sayori", + 1: "sayori", + 2: "sayori", +} + +default readpoem = { + "sayori": False, + "natsuki": False, + "yuri": False, + "monika": False +} # This variable keeps track on how many people have read your poem. default poemsread = 0 diff --git a/game/definitions/py/0core_ren.py b/game/definitions/py/0core_ren.py new file mode 100644 index 00000000..965ffe55 --- /dev/null +++ b/game/definitions/py/0core_ren.py @@ -0,0 +1,82 @@ +# This file handles locking the game to ensure that only one instance is running at a time. +# This is basically a revamped version of `singleton.py` to allow enabling/disabling singleton behavior. + +"""renpy +python early: +""" +import os +import tempfile +import sys + +if sys.platform == "win32": + import msvcrt # For Windows systems +else: + import fcntl # For Unix/Linux systems + +# Enables/Disables running only one instance of the game at a time. +# Set to False to allow multiple instances. +# Note: This is not recommended for most DDLC mods. +enable_singleton = True + + +class SingleInstance: + """ + A class to ensure that only one instance of the game is running at a time. + """ + + def __init__(self): + lock_file = "ddlc.lock" + temp_dir = os.path.join(tempfile.gettempdir(), lock_file) + + self.lock_file = temp_dir + self.lock_fd = None + + if not self.acquire_lock(): + sys.exit(-1) + + def acquire_lock(self): + """ + Acquire a lock on the lock file to ensure only one instance runs. + + :return bool: True if the lock was acquired, False otherwise. + """ + if not enable_singleton: + return True + + try: + self.lock_fd = open(self.lock_file, "w+") + if sys.platform == "win32": + msvcrt.locking(self.lock_fd.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except (IOError, OSError): + if self.lock_fd: + self.lock_fd.close() + self.lock_fd = None + return False + + def release_lock(self): + """ + Release the lock on the lock file. + """ + if not enable_singleton: + return + + if self.lock_fd: + if sys.platform == "win32": + msvcrt.locking(self.lock_fd.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(self.lock_fd, fcntl.LOCK_UN) + self.lock_fd.close() + try: + os.remove(self.lock_file) + except OSError: # Someone removed it... + pass + self.lock_fd = None + + def __del__(self): + self.release_lock() + + +SingleInstance() diff --git a/game/definitions/py/core_ren.py b/game/definitions/py/core_ren.py new file mode 100644 index 00000000..324e7e27 --- /dev/null +++ b/game/definitions/py/core_ren.py @@ -0,0 +1,390 @@ +import os +import subprocess +import sys +import platform +import renpy # type: ignore + +"""renpy +init -3 python: +""" +# These are used so that IDEs don't report errors and Python code can access Ren'Py's store and persistent. +persistent = renpy.store.persistent +store = renpy.store + +# The default splash message for the game that players will see when launching your mod. +splash_message_default = ( + "This mod is an unofficial fan game that is unaffiliated with Team Salvato." +) + +# Stores multiple splash messages that can be used in the game. +splash_messages = [ + ":o", + "Bronya... :o", +] + + +## DDLC Functions + + +def get_characters_folder(): + """ + Returns the path to the characters folder. + + :return: The path to the characters folder or None if it cannot be determined. + :rtype: str | None + """ + characters_folder = None + if renpy.android: + android_public_directory = os.environ.get("ANDROID_PUBLIC_DIRECTORY") + if android_public_directory: + characters_folder = os.path.join(android_public_directory, "characters") + else: + characters_folder = os.path.join(renpy.config.basedir, "characters") + + return characters_folder + + +def restore_character(characters: list[str]): + """ + Restores the specified characters to the 'characters' folder. + + :param characters: A list of character names to restore. + :type characters: list[str] + """ + characters_folder = get_characters_folder() + if characters_folder: + for character in characters: + character_file_path = os.path.join(characters_folder, f"{character}.chr") + try: + renpy.open_file(character_file_path) + except OSError: + open(character_file_path, "wb").write( + renpy.open_file(os.path.join("chrs", f"{character}.chr")).read() + ) + + +def restore_characters(): + """ + Restores all characters depending on the current playthrough. + """ + if renpy.store.persistent.playthrough == 0: + restore_character(["monika", "natsuki", "sayori", "yuri"]) + elif ( + renpy.store.persistent.playthrough == 1 + or renpy.store.persistent.playthrough == 2 + ): + restore_character(["monika", "natsuki", "yuri"]) + elif renpy.store.persistent.playthrough == 3: + restore_character(["monika"]) + else: + restore_character(["natsuki", "sayori", "yuri"]) + + +def delete_character(name: str): + """ + Deletes a character file from the 'characters' folder. + + :param name: The name of the character to delete. + :type name: str + """ + characters_folder = get_characters_folder() + if characters_folder: + try: + os.remove(os.path.join(characters_folder, f"{name}.chr")) + except OSError: + pass # Ignore if the file does not exist + + +def initialize_characters_folder(): + """ + Initializes the characters folder by creating it if it does not exist. + + :return: The path to the characters folder. + :rtype: str + """ + characters_folder = get_characters_folder() + if characters_folder and not os.path.exists(characters_folder): + os.makedirs(characters_folder) + + restore_characters() + + +def delete_all_saves(): + """ + Deletes all save files in the game. + """ + for savegame in renpy.list_saved_games(fast=True): + renpy.unlink_save(savegame) + renpy.loadsave.location.unlink_persistent() + renpy.persistent.should_save_persistent = False + + +def get_pos(channel: str = "music"): + """ + Returns the current position of the specified music channel. + + :param channel: The name of the music channel. + :type channel: str + + :return: The current position of the music channel or 0 if not playing. + :rtype: int + """ + pos = renpy.music.get_pos(channel) + if pos is not None: + return pos + return 0 + + +def pause(time=None): + """ + Pauses the game for a specified amount of time or indefinitely. + + :param time: The time to pause in seconds. If None, pauses indefinitely. + """ + global _windows_hidden + + if not time: + _windows_hidden = True + renpy.ui.saybehavior(afm=" ") + renpy.ui.interact(mouse="pause", type="pause", roll_forward=None) + _windows_hidden = False + return + if time <= 0: + return + _windows_hidden = True + renpy.pause(time) + _windows_hidden = False + + +## OS Functions + + +def get_process_list(): + """ + Retrieves the list of currently running processes on the system. + + :return: A list of process names. + :rtype: set[str] + """ + process_list: set[str] = set() + if renpy.windows: + try: + subprocess_list = subprocess.run( + "powershell (Get-Process).ProcessName", + shell=True, + capture_output=True, + text=True, + ).stdout.splitlines() + + for _, process in enumerate(subprocess_list): + process_list.add(process.strip().lower() + ".exe") + except subprocess.CalledProcessError: + pass + else: + try: + subprocess_list = subprocess.run( + "ps -eo comm=", shell=True, capture_output=True, text=True + ).stdout.splitlines() + + for process in subprocess_list: + process = process.strip().split()[ + 0 + ] # Get the first part of the command + if process: # Ensure it's not empty + process_list.add(process.lower()) + except subprocess.CalledProcessError: + pass + + return process_list + + +def process_check(stream_list: list[str]): + """ + Checks whether the given application stream list is running on the current system. + + :param stream_list: A list of application streams to check. + + :type stream_list: list[str] + + :return bool: True if the application is running, False otherwise. + """ + if not renpy.windows: + # Adjust for non-Windows systems + for index, process in enumerate(stream_list): + stream_list[index] = process.replace(".exe", "") + + process_list = get_process_list() + for process in stream_list: + for running_process in process_list: + # Check if the process name matches exactly or is a prefix of the running process (followed by '/') [Linux/macOS] + if running_process == process or running_process.startswith(process + "/"): + return True + return False + +def is_user_streaming() -> bool: + """ + Checks if any known streaming applications are currently running. + + :return: True if a streaming application is running, False otherwise. + :rtype: bool + """ + # List of common streaming application process names + streaming_apps = [ + "obs.exe", + "obs64.exe", + "streamlabsobs.exe", + "xsplit.core.exe", + "xsplit.broadcaster.exe", + "twitchstudio.exe", + "elgato.streamdeck.exe", + "nvidia.share.exe", # NVIDIA ShadowPlay + "amd.raptr.exe", # AMD ReLive + "zoom.exe", # Zoom (for video conferencing) + "teams.exe", # Microsoft Teams (for video conferencing) + ] + return process_check(streaming_apps) + +def get_user_account_name(): + """ + Retrieves the current user's account name. + + :return: The username of the current user or None if it cannot be determined. + :rtype: str | None + """ + # Reject if streaming to protect privacy + if is_user_streaming(): + return None + + if renpy.windows: + # `whoami` and split name (DOMAIN\Username -> Username) + return ( + subprocess.run("whoami", shell=True, capture_output=True, text=True) + .stdout.strip() + .split("\\")[-1] + or None + ) + else: + return ( + subprocess.run( + "id -un", shell=True, capture_output=True, text=True + ).stdout.strip() + or None + ) + +def get_windows_version() -> tuple[int, int, int] | None: + """ + Retrieves the current Windows version. + + :return: The Windows version or None if it cannot be determined or not on Windows. + :rtype: tuple[int, int, int] | None + """ + if not renpy.windows: + return None + + version = sys.getwindowsversion() + return (version.major, version.minor, version.build) + +def get_macos_version() -> tuple[int, int, int] | None: + """ + Retrieves the current macOS version. + + :return: A tuple containing the major, minor, and patch version numbers or None if it cannot be determined or not on macOS. + :rtype: tuple[int, int, int] | None + """ + if not renpy.macintosh: + return None + + release, _, _ = platform.mac_ver() + if release != "": + version_parts = release.split(".") + if len(version_parts) >= 2: + major = int(version_parts[0]) + minor = int(version_parts[1]) + patch = int(version_parts[2]) if len(version_parts) > 2 else 0 + return (major, minor, patch) + + return None # Unknown or unsupported version + +currentuser = get_user_account_name() + +## Template Functions + + +## TODO: Adjust to maybe Transform and MatrixColor +def recolorize( + path: str, blackCol: str = "#ffbde1", whiteCol: str = "#ffe6f4", contr: float = 1.29 +): + """ + Recolorizes the image at the given path with the specified colors and contrast. + + :param path: The path to the image file. + :param blackCol: The color to use for black areas. + :param whiteCol: The color to use for white areas. + :param contr: The contrast level to apply. + + :type path: str + :type blackCol: str + :type whiteCol: str + :type contr: float + + :return: The recolorized image. + """ + return renpy.im.MatrixColor( + renpy.im.MatrixColor( + renpy.im.MatrixColor( + path, renpy.im.matrix.desaturate() * renpy.im.matrix.contrast(contr) + ), + renpy.im.matrix.colorize("#00f", "#fff") * renpy.im.matrix.saturation(120), + ), + renpy.im.matrix.desaturate() * renpy.im.matrix.colorize(blackCol, whiteCol), + ) + + +### Dynamic Super Positioning +def dsp(original_position_value: int | float) -> int: + """ + Dynamically adjusts the position value of an element based on the + original game's screen size (1280x720) against the set screen size. + + This assumes that the original position value is set for a 1280x720 resolution. + """ + valueIsInt = isinstance(original_position_value, int) + scale_position_by = renpy.config.screen_width / 1280.0 + if valueIsInt: + return int(original_position_value * scale_position_by) + return original_position_value * scale_position_by + + +### Dynamic Super Resolution +def dsr(image_path: str): + """ + Dynamically adjusts the size of the image based on the original game's + screen size (1280x720) against the set screen size. + + (It is recommended to use high-res images and use DSP than DSR.) + """ + image_bounds = renpy.image_size(image_path) + return renpy.Transform( + image_path, size=(dsp(image_bounds[0]), dsp(image_bounds[1])) + ) + + +## Initialize Core Code + +# Setup mapping for the game menu and hide windows. +renpy.config.keymap["game_menu"].remove("mouseup_3") +renpy.config.keymap["hide_windows"].append("mouseup_3") +renpy.config.keymap["self_voicing"] = [] +renpy.config.keymap["clipboard_voicing"] = [] +renpy.config.keymap["toggle_skip"] = [] + +# Register the music channel for the poem game. +renpy.music.register_channel("music_poem", mixer="music", tight=True) + +# If using 'More Android Gestures', uncomment the following lines to initialize the gesture mapping. +# if renpy.android: +# # Initialize the gesture mapping for Android devices. +# renpy.config.keymap["rollback"] = [] +# renpy.config.keymap["history"] = [ 'K_PAGEUP', 'repeat_K_PAGEUP', 'K_AC_BACK', 'mousedown_4' ] + +renpy.pure(dsp) diff --git a/game/definitions/py/splash_ren.py b/game/definitions/py/splash_ren.py new file mode 100644 index 00000000..ad26ec2f --- /dev/null +++ b/game/definitions/py/splash_ren.py @@ -0,0 +1,23 @@ +# This file checks that 'audio.rpa', 'fonts.rpa' and 'images.rpa' are in the +# game folder and if the project is in a cloud folder (OneDrive). +# Note: For building a mod for PC/Android, you must keep the DDLC RPAs +# and decompile them for the builds to work. + +import os +from game.core.py.template_checks_ren import DDLCRPAsMissing, IllegalModLocation +import renpy # type: ignore + +"""renpy +init -100 python: +""" + +if not renpy.android: + for archive in ["audio", "images", "fonts"]: + if archive not in renpy.config.archives: + raise DDLCRPAsMissing(archive) + + if renpy.windows: + onedrive_path = os.environ.get("OneDrive") + if onedrive_path is not None: + if onedrive_path in renpy.config.basedir: + raise IllegalModLocation diff --git a/game/definitions/splash.rpy b/game/definitions/splash.rpy index cfa2b973..b00655f9 100644 --- a/game/definitions/splash.rpy +++ b/game/definitions/splash.rpy @@ -1,57 +1,5 @@ -## splash.rpy - # This is where the splashscreen, disclaimer and menu code reside in. -# This python statement checks that 'audio.rpa', 'fonts.rpa' and 'images.rpa' -# are in the game folder and if the project is in a cloud folder (OneDrive). -# Note: For building a mod for PC/Android, you must keep the DDLC RPAs -# and decompile them for the builds to work. -init -100 python: - if not renpy.android: - for archive in ['audio','images','fonts']: - if archive not in config.archives: - raise DDLCRPAsMissing(archive) - - if renpy.windows: - onedrive_path = os.environ.get("OneDrive") - if onedrive_path is not None: - if onedrive_path in config.basedir: - raise IllegalModLocation - -## Splash Message -# This python statement is where the splash messages reside in. -init python: - # This variable is the default splash message that people will see when - # the game launches. - splash_message_default = "This game is an unofficial fan game that is unaffiliated with Team Salvato." - # This array variable stores different kinds of splash messages you can use - # to show to the player on startup. - splash_messages = [ - "Please support Doki Doki Literature Club.", - "Monika is watching you code." - ] - - ### New in 3.0.0 - ## This recolor function allows you to recolor the GUI of DDLC easily without replacing - ## the in-game assets. - ## - ## Syntax to use: recolorize("path/to/your/image", "#color1hex", "#color2hex", contrast value) - ## Example: recolorize("gui/menu_bg.png", "#bdfdff", "#e6ffff", 1.25) - def recolorize(path, blackCol="#ffbde1", whiteCol="#ffe6f4", contr=1.29): - return im.MatrixColor(im.MatrixColor(im.MatrixColor(path, im.matrix.desaturate() * im.matrix.contrast(contr)), - im.matrix.colorize("#00f", "#fff") * im.matrix.saturation(120)), im.matrix.desaturate() * im.matrix.colorize(blackCol, whiteCol)) - - def process_check(stream_list): - if not renpy.windows: - for index, process in enumerate(stream_list): - stream_list[index] = process.replace(".exe", "") - - for x in stream_list: - for y in process_list: - if re.match(r"^" + x + r"\b", y): - return True - return False - # This image text shows the splash message when the game loads. image splash_warning = ParameterizedText(style="splash_text", xalign=0.5, yalign=0.5) @@ -265,18 +213,6 @@ image warning: "white" with Dissolve(0.5, alpha=True) 0.5 -## This init python statement checks if the character files are present in-game -## and writes them to the characters folder depending on the playthrough. -init python: - if not persistent.do_not_delete: - if renpy.android: - if not os.path.exists(os.path.join(os.environ['ANDROID_PUBLIC'], "characters")): - os.mkdir(os.path.join(os.environ['ANDROID_PUBLIC'], "characters")) - else: - if not os.path.exists(os.path.join(config.basedir, "characters")): - os.mkdir(os.path.join(config.basedir, "characters")) - restore_all_characters() - ## These images are the background images shown in-game during the disclaimer. image tos = "bg/warning.png" image tos2 = "bg/warning2.png" @@ -287,42 +223,11 @@ default persistent.has_chosen_language = False ## This sets the first run variable to False to show the disclaimer. default persistent.first_run = False -## This sets the lockdown check variable to False to show the warning for developers. -default persistent.lockdown_warning = False - ## Startup Disclaimer ## This label calls the disclaimer screen that appears when the game starts. label splashscreen: - ## This python statement grabs the username and process list of the PC. - python: - process_list = [] - currentuser = "" - - if renpy.windows: - try: process_list = subprocess.run("wmic process get Description", check=True, shell=True, stdout=subprocess.PIPE).stdout.lower().decode("utf-8").replace("\r", "").replace(" ", "").strip().split("\n") - except subprocess.CalledProcessError: - try: - process_list = subprocess.run("powershell (Get-Process).ProcessName", check=True, shell=True, stdout=subprocess.PIPE).stdout.lower().decode("utf-8").replace("\r", "").strip().split("\n") # For W10/11 builds > 22000 - - for i, x in enumerate(process_list): - process_list[i] = x + ".exe" - except: - pass - else: - try: process_list = subprocess.run("ps -A --format cmd", check=True, shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8").strip().split("\n") # Linux - except subprocess.CalledProcessError: process_list = subprocess.run("ps -A -o command", check=True, shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8").strip().split("\n") # MacOS - - process_list.pop(0) - - for name in ('LOGNAME', 'USER', 'LNAME', 'USERNAME'): - user = os.environ.get(name) - if user: - currentuser = user - - ## This if statement checks if we have passed the disclaimer and that the - ## current version of the mod equals the old one or the autoload is set to - ## the post-credit loop. - if persistent.first_run and (config.version == persistent.oldversion or persistent.autoload == "postcredits_loop"): + ## Shows the option to delete existing save data if conditions are met. + if not persistent.first_run and len(renpy.list_saved_games(fast=True)) > 0: $ quick_menu = False scene black @@ -332,17 +237,11 @@ label splashscreen: "Deleting save data...{nw}" python: delete_all_saves() - renpy.loadsave.location.unlink_persistent() - renpy.persistent.should_save_persistent = False renpy.utter_restart() "No, continue where I left off.": - $ restore_relevant_characters() - - if not persistent.lockdown_warning: - if config.developer: - call lockdown_check - else: - $ persistent.lockdown_warning = True + python: + restore_relevant_characters() + persistent.first_run = True if not persistent.first_run: $ quick_menu = False @@ -352,18 +251,18 @@ label splashscreen: with Dissolve(1.0) pause 1.0 - ## Switch to language selector. Borrowed from Ren'Py + # Switch to the language selector before showing the disclaimer if translations + # are available and the player hasn't chosen a language yet. if not persistent.has_chosen_language and translations: - if _preferences.language is None: - call choose_language - - $ persistent.has_chosen_language = True - - ## You can edit this message but you MUST declare that your mod is - ## unaffiliated with Team Salvato, requires that the player must - ## finish DDLC before playing, has spoilers for DDLC, and where to - ## get DDLC's files." + call screen language_selector + + # You can edit this message but you MUST declare that your mod is + # unaffiliated with Team Salvato, requires that the player must + # finish DDLC before playing, has spoilers for DDLC, and where to + # get DDLC (preferably https://ddlc.moe). + # + # ...Yes this even applies if your mod has no spoilers whatsoever. "[config.name] is a Doki Doki Literature Club fan mod that is not affiliated in anyway with Team Salvato." "It is designed to be played only after the official game has been completed, and contains spoilers for the official game." "Game files for Doki Doki Literature Club are required to play this mod and can be downloaded for free at: https://ddlc.moe or on Steam." @@ -371,26 +270,22 @@ label splashscreen: menu: "By playing [config.name] you agree that you have completed Doki Doki Literature Club and accept any spoilers contained within." "I agree.": - pass + $ persistent.first_run = True - $ persistent.first_run = True scene tos2 with Dissolve(1.5) pause 1.0 - ## This if statement checks if we are running any common streaming/recording - ## software so the game can enable Let's Play Mode automatically and notify - ## the user about it if extra settings are enabled. - if extra_settings: - if process_check(["obs32.exe", "obs64.exe", "obs.exe", "xsplit.core.exe", "livehime.exe", "pandatool.exe", "yymixer.exe", "douyutool.exe", "huomaotool.exe"]): - $ persistent.lets_play = True - call screen dialog("Let's Play Mode has been enabled automatically.\nThis mode allows you to skip content that\ncontains sensitive information or apply alternative\nstory options.\n\nThis setting will be dependent on the modder\nif they programmed these checks in their story.\n\n To turn off Let's Play Mode, visit Settings and\nuncheck Let's Play Mode.", - [Hide("dialog"), Return()]) + # Check if a streaming/recording program is running and let the player know. + if is_user_streaming(): + call screen dialog("A streaming/recording program has been detected. Let's Play Mode has been enabled to protect your privacy.", + [Hide("dialog"), Return()]) scene white - ## This python statement controls whether the Sayori Kill Early screen shows - ## in-game. This feature has been commented out for mod safety reasons but can - ## be used if needed. + # This python statement controls whether the Sayori Kill Early screen shows + # in-game. This feature has been commented out for mod safety reasons but can + # be used if needed. + # python: # s_kill_early = None # if persistent.playthrough == 0: @@ -409,41 +304,32 @@ label splashscreen: # try: renpy.file("../characters/sayori.chr") # except IOError: open(config.basedir + "/characters/sayori.chr", "wb").write(renpy.file("sayori.chr").read()) - ## This if statement controls which special poems are shown to the player in-game. + # Sets up the random special poems that appears during Act 2 of the game. if not persistent.special_poems: python hide: - # This variable sets a array of zeroes to assign poem numbers. persistent.special_poems = [0,0,0] - # This sets the range of poem numbers to pick from. + # This sets the range of poem numbers to pick from. In base DDLC, + # there are 11 special poems. a = list(range(1,12)) - # This for loop loops 3 times (array number of special_poems) and - # assigns a random number to the array. + # Set three unique random poems to appear in Act 2. for i in range(3): b = renpy.random.choice(a) persistent.special_poems[i] = b - # This line makes sure we remove the number chosen from the range - # list to avoid duplicates. a.remove(b) - ## This variable makes sure the path of the base directory is Linux/macOS/Unix - ## based than Windows as Python/Ren'Py prefers this placement. + # Stores the path to the base directory of the game. Used in Act 3. $ basedir = config.basedir.replace('\\', '/') - ## This if statement checks whether we have a auto-load set to load it than - ## start the game screen as-new. + # Load the autoload label if the variable is set. if persistent.autoload: jump autoload - ## This variable sets skipping to False for the splash screen. $ config.allow_skipping = False - - ## This if statement checks if we are in Act 2, have not seen the ghost menu - ## before and a random number is 0 from 0-63. + # Shows the ghost menu if the player is in Act II and conditions are met. if persistent.playthrough == 2 and not persistent.seen_ghost_menu and renpy.random.randint(0, 63) == 0: show black - # These variables set the splash and menu screen to be a ghost menu. $ config.main_menu_music = audio.ghostmenu $ persistent.seen_ghost_menu = True $ persistent.ghost_menu = True @@ -454,9 +340,10 @@ label splashscreen: $ config.allow_skipping = True return - ## This if statement checks if 'sayori.chr' was deleted after the disclaimer - ## was made. This feature has been commented out for mod safety reasons but - ## can be used if needed. + # This checks if 'sayori.chr' was deleted after the disclaimer page and if so, + # show a premature death scene. This feature has been commented out for mod safety reasons but can + # be used if needed. + # if s_kill_early: # show black # play music "bgm/s_kill_early.ogg" @@ -526,16 +413,10 @@ label splashscreen: $ config.allow_skipping = True return -## This label is a left-over from DDLC's development that hides the Team Salvato -## logo and shows the splash message. -label warningscreen: - hide intro - show warning - pause 3.0 +# This label script is used when 'monika.chr' is deleted from the game after the +# at the beginning of a new game. This feature has been commented out for mod safety +# reasons but can be used if needed. -## This label is used when 'monika.chr' is deleted when the game starts Day 1 of -## Act 1. This feature has been commented out for mod safety reasons but can be -## used if needed. # label ch0_kill: # $ s_name = "Sayori" # show sayori 1b zorder 2 at t11 @@ -561,16 +442,19 @@ label warningscreen: # $ renpy.quit() # return -## This label checks if the save loaded matches the anti-cheat stored in the save. +## This label handles special logic that should happen after a save is loaded. label after_load: - $ restore_all_characters() + $ restore_characters() $ config.allow_skipping = allow_skipping $ _dismiss_pause = config.developer $ persistent.ghost_menu = False $ style.say_dialogue = style.normal - ## This 'if' statement makes sure if we are in Yuri's death CG in - ## Act 2 to bring us back to the scene at a given time. + + # Check if we are in the Yuri Death CG scene in Act 2 and if so, redirect + # back to the scene. This feature has been commented out for mod safety reasons + # but can be used if needed. + # if persistent.yuri_kill > 0 and persistent.autoload == "yuri_kill_2": # if persistent.yuri_kill >= 1380: # $ persistent.yuri_kill = 1440 @@ -594,9 +478,9 @@ label after_load: # $ persistent.yuri_kill = 200 # jump expression persistent.autoload - ## use a 'elif' here than 'if' if you uncommented the code above. - ## This statement checks if the anticheat number is equal to the - ## anticheat number in the save file, else it errors out. + # [NOTE: If you uncommented the Yuri Death CG redirect above, add a `elif` statement here.] + # This checks if the local anti-cheat variable matches the persistent one and + # if not, block the load and show a special message. if anticheat != persistent.anticheat: stop music scene black @@ -610,6 +494,7 @@ label after_load: m "You're so funny, [persistent.playername]." $ renpy.utter_restart() else: + # Show a hint about the skip button if it's the player's first playthrough. if persistent.playthrough == 0 and not persistent.first_load and not config.developer: $ persistent.first_load = True call screen dialog("Hint: You can use the \"Skip\" button to\nfast-forward through text you've already read.", ok_action=Return()) @@ -638,8 +523,10 @@ label autoload: $ renpy.pop_call() jump expression persistent.autoload -## This label is used when the game starts to direct back to -## Yuri's Death CG from the main menu. +# This label is used when the game starts to direct back to +# Yuri's Death CG from the main menu. This feature has been commented out for mod +# safety reasons but can be used if needed. + # label autoload_yurikill: # if persistent.yuri_kill >= 1380: # $ persistent.yuri_kill = 1440 @@ -663,14 +550,13 @@ label autoload: # $ persistent.yuri_kill = 200 # jump expression persistent.autoload -## This label sets the main menu music to Doki Doki Literature Club before the -## menu starts. +# This label sets the main menu music to Doki Doki Literature Club before the +# menu starts. label before_main_menu: $ config.main_menu_music = audio.t1 return -## This label is a left-over from DDLC's development that quits the game but shows -## a close-up Monika face before doing so. +# This label handles special logic that should happen when the game quits. label quit: if persistent.ghost_menu: hide screen main_menu diff --git a/game/mod_assets/mod_extra_images/bsod_qr_code.png b/game/mod_assets/mod_extra_images/bsod_qr_code.png index ec0f00b2..7a4d66f3 100644 Binary files a/game/mod_assets/mod_extra_images/bsod_qr_code.png and b/game/mod_assets/mod_extra_images/bsod_qr_code.png differ diff --git a/game/options.rpy b/game/options.rpy index a474193d..3d0d3501 100644 --- a/game/options.rpy +++ b/game/options.rpy @@ -13,7 +13,7 @@ define config.name = "DDLC Mod Template – Python 3 Edition" define gui.show_name = True # This controls the version number of your mod. -define config.version = "4.2.4–Py3" +define config.version = "4.3/5.0-BETA" # This adds information about your mod in the About screen. # DDLC does not have a 'About' screen so you can leave this blank. diff --git a/game/poem_game/README.md b/game/poem_game/README.md index e7b090b0..2ab734cb 100644 --- a/game/poem_game/README.md +++ b/game/poem_game/README.md @@ -1,10 +1,12 @@ -# Contents +# Contents of the `poem_game` folder -## poemwords.txt -This file contains the words that can be used to write your poem and the like value to the words for each character from 1 (Bad) to 3 (Good). - > Syntax: `word, s_like, n_like, y_like` - - > `word`: string, `s_like`: integer, `n_like`: integer, `y_like`: integer +## Folders +- **[py](./py)**: Contains the Python code for the Poem Game in DDLC. +- **\_\_pycache\_\_**: Contains the compiled Python files for the Poem Game. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. -## script-poemgame.rpy -This file contains the code for the poemgame at the end of each day in Act One to Act Two and a corrupted poemgame in Act Three. \ No newline at end of file +## Files +- **README.md**: This file, which provides an overview of the contents of the `poem_game` folder. +- **[script-poemgame.rpy](./script-poemgame.rpy)**: Contains the Ren'Py code for the Poem Game in DDLC. + +## Moved Files +- **[poemwords.txt](./py/poemwords_ren.py)**: This file was moved and replaced from the `game/poem_game` folder to the `game/poem_game/py` folder. The wordlist of the poem game now is now stored in a Python file. \ No newline at end of file diff --git a/game/poem_game/__init__.py b/game/poem_game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/poem_game/poemwords.txt b/game/poem_game/poemwords.txt deleted file mode 100644 index b0439b61..00000000 --- a/game/poem_game/poemwords.txt +++ /dev/null @@ -1,248 +0,0 @@ -## poemwords.txt - -# This file declares the poem words for each character for -# the poem writing mini-game. -# -# Syntax: -# word - This is where you put the poem word that you want to use for the -# poem game. -# sPoint - This is where you put how much Sayori likes this word from 1-3. -# nPoint - This is where you put how much Natsuki likes this word from 1-3. -# yPoint - This is where you put how much Yuri likes this word from 1-3. -# Points: -# -1 - Dislikes this word | 2 - Okay with this word | 3 - Loves this word -# Example: pizza, 3, 2, 1 - -## Sayori's favorite words -happiness,3,2,1 -sadness,3,2,1 -death,3,1,2 -tragedy,3,1,2 -alone,3,1,2 -love,3,2,1 -adventure,3,2,1 -sweet,3,2,1 -excitement,3,2,1 -fireworks,3,2,1 -romance,3,2,1 -tears,3,1,2 -depression,3,1,2 -heart,3,2,1 -marriage,3,2,1 -passion,3,2,1 -childhood,3,2,1 -fun,3,2,1 -color,3,2,1 -hope,3,1,2 -friends,3,2,1 -family,3,2,1 -party,3,2,1 -vacation,3,2,1 -lazy,3,2,1 -daydream,3,1,2 -pain,3,1,2 -holiday,3,2,1 -bed,3,2,1 -feather,3,2,1 -shame,3,1,2 -fear,3,1,2 -warm,3,2,1 -flower,3,2,1 -comfort,3,2,1 -dance,3,2,1 -sing,3,2,1 -cry,3,1,2 -laugh,3,2,1 -dark,3,1,2 -sunny,3,2,1 -raincloud,3,2,1 -calm,3,1,2 -silly,3,2,1 -flying,3,2,1 -wonderful,3,2,1 -unrequited,3,1,2 -rose,3,1,2 -together,3,2,1 -promise,3,2,1 -charm,3,2,1 -beauty,3,2,1 -cheer,3,2,1 -smile,3,2,1 -broken,3,1,2 -precious,3,2,1 -prayer,3,1,2 -clumsy,3,2,1 -forgive,3,1,2 -nature,3,2,1 -ocean,3,2,1 -dazzle,3,2,1 -special,3,2,1 -music,3,2,1 -lucky,3,2,1 -misfortune,3,1,2 -loud,3,2,1 -peaceful,3,1,2 -joy,3,1,2 -sunset,3,2,1 -fireflies,3,2,1 -rainbow,3,2,1 -hurt,3,1,2 -play,3,2,1 -sparkle,3,2,1 -scars,3,1,2 -empty,3,1,2 -amazing,3,2,1 -grief,3,1,2 -embrace,3,1,2 -extraordinary,3,2,1 -awesome,3,2,1 -defeat,3,1,2 -hopeless,3,1,2 -misery,3,1,2 -treasure,3,2,1 -bliss,3,2,1 -memories,3,2,1 - -## Natsuki's favorite words -cute,2,3,1 -fluffy,2,3,1 -pure,1,3,2 -candy,2,3,1 -shopping,2,3,1 -puppy,2,3,1 -kitty,2,3,1 -clouds,2,3,1 -lipstick,1,3,2 -parfait,2,3,1 -strawberry,2,3,1 -pink,2,3,1 -chocolate,2,3,1 -heartbeat,1,3,2 -kiss,1,3,2 -melody,2,3,1 -ribbon,2,3,1 -jumpy,2,3,1 -doki-doki,2,3,1 -kawaii,2,3,1 -skirt,2,3,1 -cheeks,2,3,1 -email,2,3,1 -sticky,2,3,1 -bouncy,2,3,1 -shiny,2,3,1 -nibble,2,3,1 -fantasy,1,3,2 -sugar,2,3,1 -giggle,2,3,1 -marshmallow,2,3,1 -hop,2,3,1 -skipping,2,3,1 -peace,2,3,1 -spinning,2,3,1 -twirl,2,3,1 -lollipop,2,3,1 -poof,2,3,1 -bubbles,2,3,1 -whisper,2,3,1 -summer,2,3,1 -waterfall,1,3,2 -swimsuit,2,3,1 -vanilla,2,3,1 -headphones,2,3,1 -games,2,3,1 -socks,2,3,1 -hair,2,3,1 -playground,2,3,1 -nightgown,1,3,2 -blanket,1,3,2 -milk,2,3,1 -pout,2,3,1 -anger,2,3,1 -papa,2,3,1 -valentine,2,3,1 -mouse,1,3,2 -whistle,2,3,1 -boop,2,3,1 -bunny,2,3,1 -anime,2,3,1 -jump,2,3,1 - -## Yuri's favorite words -determination,1,1,3 -suicide,2,1,3 -imagination,2,1,3 -secretive,2,1,3 -vitality,1,1,3 -existence,2,1,3 -effulgent,1,1,3 -crimson,1,1,3 -whirlwind,1,1,3 -afterimage,1,1,3 -vertigo,1,1,3 -disoriented,1,1,3 -essence,2,1,3 -ambient,2,1,3 -starscape,2,1,3 -disarray,1,1,3 -contamination,1,1,3 -intellectual,1,1,3 -analysis,1,1,3 -entropy,1,1,3 -vivacious,1,1,3 -uncanny,2,1,3 -incongruent,1,1,3 -wrath,2,1,3 -heavensent,2,1,3 -massacre,2,1,3 -philosophy,1,1,3 -fickle,1,1,3 -tenacious,1,1,3 -aura,2,1,3 -unstable,1,1,3 -inferno,2,1,3 -incapable,2,1,3 -destiny,2,1,3 -infallible,1,1,3 -agonizing,2,1,3 -variance,1,1,3 -uncontrollable,2,1,3 -extreme,1,1,3 -flee,2,1,3 -dream,2,2,3 -disaster,2,1,3 -vivid,2,1,3 -vibrant,1,2,3 -question,1,2,3 -fester,2,1,3 -judgment,1,1,3 -cage,1,2,3 -explode,1,2,3 -pleasure,1,2,3 -lust,1,2,3 -sensation,1,2,3 -climax,1,2,3 -electricity,1,2,3 -disown,1,1,3 -despise,2,1,3 -infinite,2,1,3 -eternity,2,1,3 -time,2,1,3 -universe,2,1,3 -unending,2,1,3 -raindrops,2,1,3 -covet,1,1,3 -unrestrained,1,1,3 -landscape,2,1,3 -portrait,2,1,3 -journey,2,1,3 -meager,1,1,3 -anxiety,2,1,3 -frightening,2,1,3 -horror,2,1,3 -melancholy,2,1,3 -insight,2,1,3 -atone,2,1,3 -breathe,1,2,3 -captive,2,1,3 -desire,1,2,3 -graveyard,2,1,3 \ No newline at end of file diff --git a/game/poem_game/py/README.md b/game/poem_game/py/README.md new file mode 100644 index 00000000..bcd8ba2a --- /dev/null +++ b/game/poem_game/py/README.md @@ -0,0 +1,9 @@ +# Contents of the `poem_game/py` folder + +## Folders +- **\_\_pycache\_\_**: Contains the compiled Python files for the Poem Game. This should normally not appear in your mod nor in packaged mod templates, but if it exists, ignore it. + +## Files +- **[poemgame_chibi_ren.py](./poemgame_ren.py)**: Contains the Python code for the Poem Game Chibis. +- **[poemgame_ren.py](./poemgame_ren.py)**: Contains the Python code for the main Poem Game. +- **[poemwords_ren.py](./poemwords_ren.py)**: Contains the wordlist for the Poem Game, now stored in a Python file than the prior *poemwords.txt* file. \ No newline at end of file diff --git a/game/poem_game/py/__init__.py b/game/poem_game/py/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/poem_game/py/poemgame_chibi_ren.py b/game/poem_game/py/poemgame_chibi_ren.py new file mode 100644 index 00000000..43fd09c9 --- /dev/null +++ b/game/poem_game/py/poemgame_chibi_ren.py @@ -0,0 +1,200 @@ +# This file contains the transform code for the Chibi animations in the DDLC poem game. + +# The code is designed to work with Ren'Py 8 and uses the `_ren.py` approach for Python code. + +## Not included in the original game, but used for IDEs to avoid multiple warnings. +from typing import Literal +import renpy # type: ignore + +"""renpy +init python: +""" + + +class ChibiTransform(object): + """ + This class handles the transform animations for the Chibi characters in the poem game. + """ + + def __init__(self) -> None: + """ + Initializes the animation of the Poem Game Chibi characters. + """ + self.charTime: float = renpy.random.random() * 4 + 4 + self.charPos: int = 0 + self.charOffset: float = 0 + self.charZoom: float = 1 + + def produce_random(self) -> float: + """ + Produces a random time for the character animation. + """ + return renpy.random.random() * 4 + 4 + + def reset_trans(self) -> None: + """ + Resets the character animation to its initial state. + """ + self.charTime = self.produce_random() + self.charPos = 0 + self.charOffset = 0 + self.charZoom = 1 + + def randomPauseTime(self, trans, st, at) -> Literal[None, 0]: + """ + Randomly pauses the character animation based on the specified time. + """ + if st > self.charTime: + self.charTime = self.produce_random() + return None + return 0 + + def randomMoveTime(self, trans, st, at) -> Literal[None, 0]: + """ + Randomly moves the character based on the specified time. + """ + if st > 0.16: + if self.charPos > 0: + self.charPos = renpy.random.randint(-1, 0) + elif self.charPos < 0: + self.charPos = renpy.random.randint(0, 1) + else: + self.charPos = renpy.random.randint(-1, 1) + if trans.xoffset * self.charPos > 5: + self.charPos *= -1 + return None + if self.charPos > 0: + trans.xzoom = -1 + elif self.charPos < 0: + trans.xzoom = 1 + trans.xoffset += 0.16 * 10 * self.charPos + self.charOffset = trans.xoffset + self.charZoom = trans.xzoom + return 0 + + +class Chibi(ChibiTransform): + """ + This class defines a Poem Game Chibi character that is used in the poem game. + """ + + def __init__( + self, name: str, poem_dislike_threshold: int = 29, poem_like_threshold: int = 45 + ) -> None: + """ + Initializes the Chibi character + + :param name: The name of the character. + :param poem_dislike_threshold: The threshold for a character to dislike a word in the poem. + :param poem_like_threshold: The threshold for a character to like a word in the poem. + + :type name: str + :type poem_dislike_threshold: int + :type poem_like_threshold: int + """ + super().__init__() + self.name = name + self.poem_dislike_threshold = poem_dislike_threshold + self.poem_like_threshold = poem_like_threshold + + self.charPointTotal = 0 + + def reset(self) -> None: + """ + Resets the Chibi character's point total and animation state. + """ + self.charPointTotal = 0 + self.reset_trans() + + def add_points(self, points: int) -> None: + """ + Adds points to the Chibi character's total. + + :param points: The number of points to add. + :type points: int + """ + self.charPointTotal += points + + def calculate_appeal(self) -> Literal[-1, 0, 1]: + """ + Calculates the appeal of the Chibi character based on their point total. + + If the total points are below the dislike threshold, the appeal is -1. + If the total points are above the like threshold, the appeal is 1 and the character wins. + If the total points are between the dislike and like thresholds, the appeal is 0 + + :return appeal: The appeal of the character towards the player's poem. + :rtype: int + """ + if self.charPointTotal < self.poem_dislike_threshold: + return -1 + elif self.charPointTotal > self.poem_like_threshold: + return 1 + return 0 + + def __call__(self) -> str: + """ + Returns the name of the Chibi character. + """ + return self.name + + +class ChibiDB(object): + """ + This class defines a database of Chibi characters used in the poem game. + """ + + def __init__(self) -> None: + """ + Initializes the ChibiDB instance with an empty list of characters. + """ + self.chibis: list[Chibi] = [] + + def add_chibi(self, name: str) -> None: + """ + Adds a Chibi character to the database. + + :param name: The name of the character to add. + + :type name: str + """ + self.chibis.append(Chibi(name)) + + def get_chibi(self, name: str) -> Chibi: + """ + Retrieves a Chibi character by name. + + :param name: The name of the character to retrieve. + :type name: str + :return: The Chibi character if found, otherwise None. + :rtype: Chibi + + :raises ValueError: If the character with the given name does not exist in the database. + """ + for chibi in self.chibis: + if chibi.name == name: + return chibi + + raise ValueError(f"Chibi character '{name}' not found in the database.") + + def reset(self) -> None: + """ + Resets all Chibi characters in the database. + """ + for chibi in self.chibis: + chibi.reset() + + +# Initialize the Chibi database and characters. + +chibis = ChibiDB() +chibis.add_chibi("sayori") +chibis.add_chibi("natsuki") +chibis.add_chibi("yuri") + +chibi_s = chibis.get_chibi("sayori") +chibi_n = chibis.get_chibi("natsuki") +chibi_y = chibis.get_chibi("yuri") +chibi_m = ( + ChibiTransform() +) # Monika does not participate in the poem game. She only moves around. diff --git a/game/poem_game/py/poemgame_ren.py b/game/poem_game/py/poemgame_ren.py new file mode 100644 index 00000000..093b2485 --- /dev/null +++ b/game/poem_game/py/poemgame_ren.py @@ -0,0 +1,283 @@ +# This file contains the Ren'Py code for DDLC's poem game. + +# The code logic has been rewritten to use the Ren'Py `_ren.py` approach for Python code. + +# For the Ren'Py code, see `script-poemgame.rpy` in the `poem_game` directory. + +## Not included in the game, but used for IDEs to avoid multiple warnings. +from game.poem_game.py.poemgame_chibi_ren import chibis, chibi_s, chibi_n, chibi_y +from game.poem_game.py.poemwords_ren import poem_word_db, glitch_word, monika_word +from game.definitions.py.core_ren import persistent, store +import renpy # type: ignore + +poemwinner: dict[int, str] = { + 0: "sayori", + 1: "sayori", + 2: "sayori", +} + +poemappeal: dict[str, dict[int, int]] = { + "sayori": {0: 0, 1: 0, 2: 0}, + "natsuki": {0: 0, 1: 0, 2: 0}, + "yuri": {0: 0, 1: 0, 2: 0}, +} + +"""renpy +init python: +""" + +POEM_CLICK_GLITCH_SOUND = store.audio.t4g +POEM_CLICK_SOUND = store.gui.activate_sound + + +class PoemGame: + """ + This class handles the logic for the poem game in DDLC. + """ + + def __init__(self, testing: bool = False): + """ + Initializes the poem game with default values. + + :param testing: If True, bypasses Ren'Py functions and screens for testing purposes. Unused in DDLC. Used for Github Actions to test code logic. + :type testing: bool + """ + self.played_baa = False + self.poemgame_glitch = False + self.poem_progress = 1 + + self.testing = testing + + def reset(self): + """ + Resets the poem game to its initial state. + """ + self.played_baa = False + self.poemgame_glitch = False + self.poem_progress = 1 + + def start(self): + """ + Starts the poem game. + This method should be called to initiate the poem game logic. + """ + self.reset() + + # Resets the points for each character. + chibis.reset() + + wordList = poem_word_db.get_words() + if len(wordList) == 0: + raise ValueError( + "No words found in the poem word database. Please check `poemwords_ren.py` for poem word declarations." + ) + + while self.poem_progress <= 20: + random_words: list[str] = [] + for _ in range(10): + try: + word = renpy.random.choice(wordList) + except IndexError: + raise IndexError( + "Not enough words in the poem word database. Add more words to `poemwords_ren.py`." + ) + random_words.append(word.__str__()) + wordList.remove( + word + ) # Remove the word to avoid duplicates in the same poem game. + + # Display the poem game screen with the random words. + if self.testing: + if renpy.persistent.playthrough == 2: + act_two_words = random_words[:9] + act_two_words.append(glitch_word.word) + poemword_str = renpy.random.choice(act_two_words) + elif renpy.persistent.playthrough == 3: + act_three_words = [] + for _ in range(10): + act_three_words.append(monika_word.word) + poemword_str = renpy.random.choice(act_three_words) + else: + poemword_str = renpy.random.choice(random_words) + else: + poemword_str = renpy.call_screen( + "poem_test", + words=random_words, + progress=self.poem_progress, + poemgame_glitch=self.poemgame_glitch, + ) + + # Checks if the word exists in the word database. + if poemword_str in poem_word_db.get_words_str(): + selected_poemword = poem_word_db.get_word(poemword_str) + else: + if renpy.persistent.playthrough == 2: + selected_poemword = glitch_word + else: + selected_poemword = monika_word + + if not self.testing: + if not self.poemgame_glitch: + if selected_poemword.glitch_word: + self.poemgame_glitch = True + renpy.music.play(POEM_CLICK_GLITCH_SOUND) + renpy.show("white") + # renpy.show("y_sticker_glitch", at_list=[sticker_glitch], zorder=10) + elif persistent.playthrough != 3: + renpy.play(POEM_CLICK_SOUND) + + # Act 1 + if persistent.playthrough == 0: + if selected_poemword.sPoint >= 3: + renpy.show("s_sticker hop") + elif selected_poemword.nPoint >= 3: + renpy.show("n_sticker hop") + elif selected_poemword.yPoint >= 3: + renpy.show("y_sticker hop") + else: + # Act 2 + if ( + persistent.playthrough == 2 + and store.chapter == 2 + and renpy.random.randint(0, 10) == 0 + ): + renpy.show( + "m_sticker hop" + ) # 1/10 chance to see Monika hopping under the game screen. + elif selected_poemword.nPoint > selected_poemword.yPoint: + renpy.show( + "n_sticker hop" + ) # In Act 2, Natsuki hops if she has more points than Yuri. + elif ( + persistent.playthrough == 2 + and not persistent.seen_sticker + and renpy.random.randint(0, 100) == 0 + ): + renpy.show( + "y_sticker hopg" + ) # "y_sticker_2g.png". 1/100 chance to see it, if we haven't seen it already. + renpy.persistent.seen_sticker = True + elif persistent.playthrough == 2 and store.chapter == 2: + renpy.show( + "y_sticker_cut hop" + ) # Yuri's cut arms sticker + else: + renpy.show("y_sticker hop") + else: + r = renpy.random.randint( + 0, 10 + ) # 1/10 chance to hear a "baa" sound. + if r == 0 and not self.played_baa: + renpy.play("gui/sfx/baa.ogg") + self.played_baa = True + elif r <= 5: + renpy.play(store.gui.activate_sound_glitch) + + chibi_s.add_points(selected_poemword.sPoint) + chibi_n.add_points(selected_poemword.nPoint) + chibi_y.add_points(selected_poemword.yPoint) + self.poem_progress += 1 + + def finish(self): + """ + Finishes the poem game. + This method should be called to conclude the poem game logic. + """ + chapter = store.chapter + + if persistent.playthrough == 0: + # Add 5 points to whoever we side with in Act 1 - Chapter 1. + if chapter == 1: + chibi = chibis.get_chibi(store.ch1_choice) + chibi.add_points(5) + + # Determine the poem winner. + if persistent.playthrough == 0: + # Act 1 Calculations + poemwinner[chapter] = max( + chibis.chibis, key=lambda c: c.charPointTotal + ).name + else: + # Act 2 Calculations + if chibi_n.charPointTotal > chibi_y.charPointTotal: + poemwinner[chapter] = "natsuki" + else: + poemwinner[chapter] = "yuri" + + # Add appeal point based on poem winner. + poemwinner_chibi = chibis.get_chibi(poemwinner[chapter]) + + # Set poem appeal + if persistent.playthrough == 0 and poemwinner_chibi.name != "sayori": + poemappeal["sayori"][chapter] += chibi_s.calculate_appeal() + if poemwinner_chibi.name != "natsuki": + poemappeal["natsuki"][chapter] += chibi_n.calculate_appeal() + if poemwinner_chibi.name != "yuri": + poemappeal["yuri"][chapter] += chibi_y.calculate_appeal() + + # Poem winner always gets +1 appeal. + poemappeal[poemwinner_chibi.name][chapter] += 1 + + +poem_game = PoemGame() + + +def get_appeal(chibi_name: str) -> int: + """ + Returns the appeal of the specified character. + :param chibi_name: The name of the character. + :type chibi_name: str + :return: The appeal of the character. + :rtype: int + """ + chibi = chibis.get_chibi(chibi_name) + appeal = 0 + for a in poemappeal[chibi.name].values(): + appeal += a + return appeal + + +def get_exclusive_scene(chapter: int) -> str: + """ + Returns the exclusive scene string based on the poem winner and their appeal. + + :param chapter: The current chapter number. + :type chapter: int + :return: The exclusive scene string. + :rtype: str + """ + winner = chibis.get_chibi(poemwinner[chapter]) + name = winner.name + + # Normally in DDLC Act II, Sayori code redirects to + # Yuri so we do it here. + if persistent.playthrough == 2 and winner.name == "sayori": + name = "yuri" + + exclusive_scene = f"{name}_exclusive" + if persistent.playthrough == 2: + exclusive_scene += "2" + exclusive_scene += f"_{get_appeal(name)}" + return exclusive_scene + + +def get_monika_scene(chapter: int) -> str: + """ + Returns the Monika scene string based on the chapter number. + + :param chapter: The current chapter number. + :type chapter: int + :return: The Monika scene string. + :rtype: str + """ + winner = chibis.get_chibi(poemwinner[chapter]) + monika_scene = "m" + + name = winner.name + if persistent.playthrough == 2: + monika_scene += "2" + if winner.name == "sayori": + name = "yuri" + + monika_scene += f"_{name}_{get_appeal(name)}" + return monika_scene diff --git a/game/poem_game/py/poemwords_ren.py b/game/poem_game/py/poemwords_ren.py new file mode 100644 index 00000000..d976bc5f --- /dev/null +++ b/game/poem_game/py/poemwords_ren.py @@ -0,0 +1,380 @@ +# This file contains the Python code of assigning words to characters in the poem game of DDLC. + +# This file replaces the original `poemwords.txt` file and defines the words used in the poem game +# using a Python class structure alongside Ren'Py 8 `_ren.py` approach for Python code. + +"""renpy +init python: +""" + + +class PoemWord: + """ + A class to represent a word in the poem game. + """ + + def __init__( + self, + word: str, + sayori_points: int, + natsuki_points: int, + yuri_points: int, + glitch_word: bool = False, + ): + """ + Initializes a PoemWord instance. + + :param word: The word itself. + :param sayori_points: The total points the word gives to Sayori. + :param yuri_points: The total points the word gives to Yuri. + :param natsuki_points: The total points the word gives to Natsuki. + :param glitch_word: Whether the word is a glitch word or not. + + :type word: str + :type sayori_points: int + :type yuri_points: int + :type natsuki_points: int + :type glitch_word: bool + + :raises ValueError: If any of the points are negative. + """ + self.word = word + + if sayori_points < 0 or yuri_points < 0 or natsuki_points < 0: + if sayori_points < 0: + raise ValueError("Sayori's preference points must be 0 or greater.") + elif yuri_points < 0: + raise ValueError("Yuri's preference points must be 0 or greater.") + elif natsuki_points < 0: + raise ValueError("Natsuki's preference points must be 0 or greater.") + + self.sPoint = sayori_points + self.yPoint = yuri_points + self.nPoint = natsuki_points + self.glitch_word = glitch_word + + def __str__(self): + """ + Returns a string representation of the PoemWord instance. + + :return str: The word itself. + """ + return self.word + + +class PoemWordDB: + """ + A class to handle the database of words used in the poem game. + """ + + def __init__(self): + """ + Initializes the PoemWordDB instance with an empty list of words. + """ + self.words: list[PoemWord] = [] + + def add_word( + self, + word: str, + sayori_points: int, + natsuki_points: int, + yuri_points: int, + glitch_word: bool = False, + ): + """ + Adds a new word to the PoemWord database. + + :param word: The word to add. + :param sayori_points: Sayori's preference towards the word. + :param yuri_points: Yuri's preference towards the word. + :param natsuki_points: Natsuki's preference towards the word. + :param glitch_word: Whether the word is a glitch word. + + :type word: str + :type sayori_points: int + :type yuri_points: int + :type natsuki_points: int + :type glitch_word: bool + + :raises ValueError: If any of the points are negative. + """ + new_word = PoemWord( + word, sayori_points, natsuki_points, yuri_points, glitch_word + ) + self.words.append(new_word) + + def get_words(self): + """ + Returns the list of words in the PoemWord database. + + :return list[PoemWord]: List of PoemWord instances. + """ + return self.words.copy() + + def get_words_str(self) -> list[str]: + """ + Returns a list of words as strings from the PoemWord database. + + :return list[str]: List of words as strings. + """ + return [word.word for word in self.words] + + def get_word(self, word: str) -> PoemWord: + """ + Retrieves a word from the PoemWord database by its string representation. + + :param word: The word to retrieve. + :type word: str + :return: The PoemWord instance if found. + :rtype: PoemWord + + :raises ValueError: If the word is not found in the database. + """ + for poem_word in self.words: + if poem_word.word == word: + return poem_word + + raise ValueError(f"Word '{word}' not found in the poem word database.") + + +## Adds the words to the database. +poem_word_db = PoemWordDB() + +## Glitch Word +glitch_word = PoemWord("", 0, 0, 0, glitch_word=True) # Empty word for glitch purposes +## Monika Word +monika_word = PoemWord("", 0, 0, 0) # Empty word for Monika purposes + +## Sayori Words +poem_word_db.add_word("happiness", 3, 2, 1) +poem_word_db.add_word("sadness", 3, 2, 1) +poem_word_db.add_word("death", 3, 1, 2) +poem_word_db.add_word("tragedy", 3, 1, 2) +poem_word_db.add_word("alone", 3, 1, 2) +poem_word_db.add_word("love", 3, 2, 1) +poem_word_db.add_word("adventure", 3, 2, 1) +poem_word_db.add_word("sweet", 3, 2, 1) +poem_word_db.add_word("excitement", 3, 2, 1) +poem_word_db.add_word("fireworks", 3, 2, 1) +poem_word_db.add_word("romance", 3, 2, 1) +poem_word_db.add_word("tears", 3, 1, 2) +poem_word_db.add_word("depression", 3, 1, 2) +poem_word_db.add_word("heart", 3, 2, 1) +poem_word_db.add_word("marriage", 3, 2, 1) +poem_word_db.add_word("passion", 3, 2, 1) +poem_word_db.add_word("childhood", 3, 2, 1) +poem_word_db.add_word("fun", 3, 2, 1) +poem_word_db.add_word("color", 3, 2, 1) +poem_word_db.add_word("hope", 3, 1, 2) +poem_word_db.add_word("friends", 3, 2, 1) +poem_word_db.add_word("family", 3, 2, 1) +poem_word_db.add_word("party", 3, 2, 1) +poem_word_db.add_word("vacation", 3, 2, 1) +poem_word_db.add_word("lazy", 3, 2, 1) +poem_word_db.add_word("daydream", 3, 1, 2) +poem_word_db.add_word("pain", 3, 1, 2) +poem_word_db.add_word("holiday", 3, 2, 1) +poem_word_db.add_word("bed", 3, 2, 1) +poem_word_db.add_word("feather", 3, 2, 1) +poem_word_db.add_word("shame", 3, 1, 2) +poem_word_db.add_word("fear", 3, 1, 2) +poem_word_db.add_word("warm", 3, 2, 1) +poem_word_db.add_word("flower", 3, 2, 1) +poem_word_db.add_word("comfort", 3, 2, 1) +poem_word_db.add_word("dance", 3, 2, 1) +poem_word_db.add_word("sing", 3, 2, 1) +poem_word_db.add_word("cry", 3, 1, 2) +poem_word_db.add_word("laugh", 3, 2, 1) +poem_word_db.add_word("dark", 3, 1, 2) +poem_word_db.add_word("sunny", 3, 2, 1) +poem_word_db.add_word("raincloud", 3, 2, 1) +poem_word_db.add_word("calm", 3, 1, 2) +poem_word_db.add_word("silly", 3, 2, 1) +poem_word_db.add_word("flying", 3, 2, 1) +poem_word_db.add_word("wonderful", 3, 2, 1) +poem_word_db.add_word("unrequited", 3, 1, 2) +poem_word_db.add_word("rose", 3, 1, 2) +poem_word_db.add_word("together", 3, 2, 1) +poem_word_db.add_word("promise", 3, 2, 1) +poem_word_db.add_word("charm", 3, 2, 1) +poem_word_db.add_word("beauty", 3, 2, 1) +poem_word_db.add_word("cheer", 3, 2, 1) +poem_word_db.add_word("smile", 3, 2, 1) +poem_word_db.add_word("broken", 3, 1, 2) +poem_word_db.add_word("precious", 3, 2, 1) +poem_word_db.add_word("prayer", 3, 1, 2) +poem_word_db.add_word("clumsy", 3, 2, 1) +poem_word_db.add_word("forgive", 3, 1, 2) +poem_word_db.add_word("nature", 3, 2, 1) +poem_word_db.add_word("ocean", 3, 2, 1) +poem_word_db.add_word("dazzle", 3, 2, 1) +poem_word_db.add_word("special", 3, 2, 1) +poem_word_db.add_word("music", 3, 2, 1) +poem_word_db.add_word("lucky", 3, 2, 1) +poem_word_db.add_word("misfortune", 3, 1, 2) +poem_word_db.add_word("loud", 3, 2, 1) +poem_word_db.add_word("peaceful", 3, 1, 2) +poem_word_db.add_word("joy", 3, 1, 2) +poem_word_db.add_word("sunset", 3, 2, 1) +poem_word_db.add_word("fireflies", 3, 2, 1) +poem_word_db.add_word("rainbow", 3, 2, 1) +poem_word_db.add_word("hurt", 3, 1, 2) +poem_word_db.add_word("play", 3, 2, 1) +poem_word_db.add_word("sparkle", 3, 2, 1) +poem_word_db.add_word("scars", 3, 1, 2) +poem_word_db.add_word("empty", 3, 1, 2) +poem_word_db.add_word("amazing", 3, 2, 1) +poem_word_db.add_word("grief", 3, 1, 2) +poem_word_db.add_word("embrace", 3, 1, 2) +poem_word_db.add_word("extraordinary", 3, 2, 1) +poem_word_db.add_word("awesome", 3, 2, 1) +poem_word_db.add_word("defeat", 3, 1, 2) +poem_word_db.add_word("hopeless", 3, 1, 2) +poem_word_db.add_word("misery", 3, 1, 2) +poem_word_db.add_word("treasure", 3, 2, 1) +poem_word_db.add_word("bliss", 3, 2, 1) +poem_word_db.add_word("memories", 3, 2, 1) + +## Natsuki Words +poem_word_db.add_word("cute", 2, 3, 1) +poem_word_db.add_word("fluffy", 2, 3, 1) +poem_word_db.add_word("pure", 1, 3, 2) +poem_word_db.add_word("candy", 2, 3, 1) +poem_word_db.add_word("shopping", 2, 3, 1) +poem_word_db.add_word("puppy", 2, 3, 1) +poem_word_db.add_word("kitty", 2, 3, 1) +poem_word_db.add_word("clouds", 2, 3, 1) +poem_word_db.add_word("lipstick", 1, 3, 2) +poem_word_db.add_word("parfait", 2, 3, 1) +poem_word_db.add_word("strawberry", 2, 3, 1) +poem_word_db.add_word("pink", 2, 3, 1) +poem_word_db.add_word("chocolate", 2, 3, 1) +poem_word_db.add_word("heartbeat", 1, 3, 2) +poem_word_db.add_word("kiss", 1, 3, 2) +poem_word_db.add_word("melody", 2, 3, 1) +poem_word_db.add_word("ribbon", 2, 3, 1) +poem_word_db.add_word("jumpy", 2, 3, 1) +poem_word_db.add_word("doki-doki", 2, 3, 1) +poem_word_db.add_word("kawaii", 2, 3, 1) +poem_word_db.add_word("skirt", 2, 3, 1) +poem_word_db.add_word("cheeks", 2, 3, 1) +poem_word_db.add_word("email", 2, 3, 1) +poem_word_db.add_word("sticky", 2, 3, 1) +poem_word_db.add_word("bouncy", 2, 3, 1) +poem_word_db.add_word("shiny", 2, 3, 1) +poem_word_db.add_word("nibble", 2, 3, 1) +poem_word_db.add_word("fantasy", 1, 3, 2) +poem_word_db.add_word("sugar", 2, 3, 1) +poem_word_db.add_word("giggle", 2, 3, 1) +poem_word_db.add_word("marshmallow", 2, 3, 1) +poem_word_db.add_word("hop", 2, 3, 1) +poem_word_db.add_word("skipping", 2, 3, 1) +poem_word_db.add_word("peace", 2, 3, 1) +poem_word_db.add_word("spinning", 2, 3, 1) +poem_word_db.add_word("twirl", 2, 3, 1) +poem_word_db.add_word("lollipop", 2, 3, 1) +poem_word_db.add_word("poof", 2, 3, 1) +poem_word_db.add_word("bubbles", 2, 3, 1) +poem_word_db.add_word("whisper", 2, 3, 1) +poem_word_db.add_word("summer", 2, 3, 1) +poem_word_db.add_word("waterfall", 1, 3, 2) +poem_word_db.add_word("swimsuit", 2, 3, 1) +poem_word_db.add_word("vanilla", 2, 3, 1) +poem_word_db.add_word("headphones", 2, 3, 1) +poem_word_db.add_word("games", 2, 3, 1) +poem_word_db.add_word("socks", 2, 3, 1) +poem_word_db.add_word("hair", 2, 3, 1) +poem_word_db.add_word("playground", 2, 3, 1) +poem_word_db.add_word("nightgown", 1, 3, 2) +poem_word_db.add_word("blanket", 1, 3, 2) +poem_word_db.add_word("milk", 2, 3, 1) +poem_word_db.add_word("pout", 2, 3, 1) +poem_word_db.add_word("anger", 2, 3, 1) +poem_word_db.add_word("papa", 2, 3, 1) +poem_word_db.add_word("valentine", 2, 3, 1) +poem_word_db.add_word("mouse", 1, 3, 2) +poem_word_db.add_word("whistle", 2, 3, 1) +poem_word_db.add_word("boop", 2, 3, 1) +poem_word_db.add_word("bunny", 2, 3, 1) +poem_word_db.add_word("anime", 2, 3, 1) +poem_word_db.add_word("jump", 2, 3, 1) + +## Yuri Words +poem_word_db.add_word("determination", 1, 1, 3) +poem_word_db.add_word("suicide", 2, 1, 3) +poem_word_db.add_word("imagination", 2, 1, 3) +poem_word_db.add_word("secretive", 2, 1, 3) +poem_word_db.add_word("vitality", 1, 1, 3) +poem_word_db.add_word("existence", 2, 1, 3) +poem_word_db.add_word("effulgent", 1, 1, 3) +poem_word_db.add_word("crimson", 1, 1, 3) +poem_word_db.add_word("whirlwind", 1, 1, 3) +poem_word_db.add_word("afterimage", 1, 1, 3) +poem_word_db.add_word("vertigo", 1, 1, 3) +poem_word_db.add_word("disoriented", 1, 1, 3) +poem_word_db.add_word("essence", 2, 1, 3) +poem_word_db.add_word("ambient", 2, 1, 3) +poem_word_db.add_word("starscape", 2, 1, 3) +poem_word_db.add_word("disarray", 1, 1, 3) +poem_word_db.add_word("contamination", 1, 1, 3) +poem_word_db.add_word("intellectual", 1, 1, 3) +poem_word_db.add_word("analysis", 1, 1, 3) +poem_word_db.add_word("entropy", 1, 1, 3) +poem_word_db.add_word("vivacious", 1, 1, 3) +poem_word_db.add_word("uncanny", 2, 1, 3) +poem_word_db.add_word("incongruent", 1, 1, 3) +poem_word_db.add_word("wrath", 2, 1, 3) +poem_word_db.add_word("heavensent", 2, 1, 3) +poem_word_db.add_word("massacre", 2, 1, 3) +poem_word_db.add_word("philosophy", 1, 1, 3) +poem_word_db.add_word("fickle", 1, 1, 3) +poem_word_db.add_word("tenacious", 1, 1, 3) +poem_word_db.add_word("aura", 2, 1, 3) +poem_word_db.add_word("unstable", 1, 1, 3) +poem_word_db.add_word("inferno", 2, 1, 3) +poem_word_db.add_word("incapable", 2, 1, 3) +poem_word_db.add_word("destiny", 2, 1, 3) +poem_word_db.add_word("infallible", 1, 1, 3) +poem_word_db.add_word("agonizing", 2, 1, 3) +poem_word_db.add_word("variance", 1, 1, 3) +poem_word_db.add_word("uncontrollable", 2, 1, 3) +poem_word_db.add_word("extreme", 1, 1, 3) +poem_word_db.add_word("flee", 2, 1, 3) +poem_word_db.add_word("dream", 2, 2, 3) +poem_word_db.add_word("disaster", 2, 1, 3) +poem_word_db.add_word("vivid", 2, 1, 3) +poem_word_db.add_word("vibrant", 1, 2, 3) +poem_word_db.add_word("question", 1, 2, 3) +poem_word_db.add_word("fester", 2, 1, 3) +poem_word_db.add_word("judgment", 1, 1, 3) +poem_word_db.add_word("cage", 1, 2, 3) +poem_word_db.add_word("explode", 1, 2, 3) +poem_word_db.add_word("pleasure", 1, 2, 3) +poem_word_db.add_word("lust", 1, 2, 3) +poem_word_db.add_word("sensation", 1, 2, 3) +poem_word_db.add_word("climax", 1, 2, 3) +poem_word_db.add_word("electricity", 1, 2, 3) +poem_word_db.add_word("disown", 1, 1, 3) +poem_word_db.add_word("despise", 2, 1, 3) +poem_word_db.add_word("infinite", 2, 1, 3) +poem_word_db.add_word("eternity", 2, 1, 3) +poem_word_db.add_word("time", 2, 1, 3) +poem_word_db.add_word("universe", 2, 1, 3) +poem_word_db.add_word("unending", 2, 1, 3) +poem_word_db.add_word("raindrops", 2, 1, 3) +poem_word_db.add_word("covet", 1, 1, 3) +poem_word_db.add_word("unrestrained", 1, 1, 3) +poem_word_db.add_word("landscape", 2, 1, 3) +poem_word_db.add_word("portrait", 2, 1, 3) +poem_word_db.add_word("journey", 2, 1, 3) +poem_word_db.add_word("meager", 1, 1, 3) +poem_word_db.add_word("anxiety", 2, 1, 3) +poem_word_db.add_word("frightening", 2, 1, 3) +poem_word_db.add_word("horror", 2, 1, 3) +poem_word_db.add_word("melancholy", 2, 1, 3) +poem_word_db.add_word("insight", 2, 1, 3) +poem_word_db.add_word("atone", 2, 1, 3) +poem_word_db.add_word("breathe", 1, 2, 3) +poem_word_db.add_word("captive", 2, 1, 3) +poem_word_db.add_word("desire", 1, 2, 3) +poem_word_db.add_word("graveyard", 2, 1, 3) diff --git a/game/poem_game/script-poemgame.rpy b/game/poem_game/script-poemgame.rpy index 9f1254a3..1272a05b 100644 --- a/game/poem_game/script-poemgame.rpy +++ b/game/poem_game/script-poemgame.rpy @@ -1,215 +1,6 @@ -## script-poemgame.rpy - -# This file contains the code to the DDLC poem game (now improved [finally...]) -# Still commented a bit by Terra. - -init python: - # This dictionary stores every poemword and the class preference values of each character. - full_wordlist = {} - - # This class holds a word, and point values for each of the four heroines - class PoemWord: - def __init__(self, s, n, y, glitch=False): - self.sPoint = s - self.nPoint = n - self.yPoint = y - self.glitch = glitch - - with renpy.file("poem_game/poemwords.txt") as pf: - for line in pf: - line = line.decode("utf-8").strip() - - # Ignore lines beginning with '#' and empty lines - if line == '' or '#' in line: continue - - # File format: word,sPoint,nPoint,yPoint - x = line.split(',') - - full_wordlist[x[0]] = PoemWord(int(x[1]), int(x[2]), int(x[3])) - - # For use with Act 2-3 words - glitch_word = PoemWord(0, 0, 0, True) - monika_word = PoemWord(0, 0, 0, False) - - # This class handles Chibi Movement in a better way - class ChibiTrans(object): - def __init__(self): - self.charTime = renpy.random.random() * 4 + 4 - self.charPos = 0 - self.charOffset = 0 - self.charZoom = 1 - - def produce_random(self): - return renpy.random.random() * 4 + 4 - - def reset_trans(self): - self.charTime = self.produce_random() - self.charPos = 0 - self.charOffset = 0 - self.charZoom = 1 - - def randomPauseTime(self, trans, st, at): - if st > self.charTime: - self.charTime = self.produce_random() - return None - return 0 - - def randomMoveTime(self, trans, st, at): - if st > .16: - if self.charPos > 0: - self.charPos = renpy.random.randint(-1,0) - elif self.charPos < 0: - self.charPos = renpy.random.randint(0,1) - else: - self.charPos = renpy.random.randint(-1,1) - if trans.xoffset * self.charPos > 5: self.charPos *= -1 - return None - if self.charPos > 0: - trans.xzoom = -1 - elif self.charPos < 0: - trans.xzoom = 1 - trans.xoffset += .16 * 10 * self.charPos - self.charOffset = trans.xoffset - self.charZoom = trans.xzoom - return 0 - - # This dictionary stores every poemgame character and their points. - chibis = {} - - # This class supers' ChibiTrans and is used to store poem point data. - class Chibi(ChibiTrans): - POEM_DISLIKE_THRESHOLD = 29 - POEM_LIKE_THRESHOLD = 45 - - def __init__(self, name): - if not isinstance(name, str): - raise Exception("'name' argurment must be a string, not " + type(name)) - - self.charPointTotal = 0 - self.appeal = 0 - super().__init__() - chibis[name] = self - - def reset(self): - self.charPointTotal = 0 - self.reset_trans() - - def add(self, point): - self.charPointTotal += point - - def calculate_appeal(self): - if self.charPointTotal < self.POEM_DISLIKE_THRESHOLD: - return -1 - elif self.charPointTotal > self.POEM_LIKE_THRESHOLD: - self.win = True - return 1 - return 0 - - seen_eyes_this_chapter = False - - # Declare Chibi variables for transforms and points (cept Monika), she only needs to move around. - chibi_s = Chibi('sayori') - chibi_n = Chibi('natsuki') - chibi_m = ChibiTrans() - chibi_y = Chibi('yuri') - - # Start of the poem game in python - def poem_game_start(): - played_baa = False - poemgame_glitch = False - - # Resets points of every character - for c in chibis: - chibis[c].reset() - - # Makes a copy of the full dictionary for editing purposes. - wordList = full_wordlist.copy() - - # A way better while loop than Dan did - progress = 1 - while progress <= 20: - # This section grabs 10 random words and stores the word in a list. - random_words = [] - for w in range(10): - word = random.choice(list(wordList.keys())) - random_words.append(word) - # Remove the word once its picked and added from the local copy. - del wordList[word] - - # Display the poem game - poemword = renpy.call_screen("poem_test", words=random_words, progress=progress, poemgame_glitch=poemgame_glitch) - # Checks if the word is in the game and not a unique Act 2-3 bugged word. - if poemword in full_wordlist: - t = full_wordlist[poemword] - else: - if persistent.playthrough == 2: - t = glitch_word - else: - t = monika_word - - # If we are not in a bugged poem game state, do normal stuff, else do buggy stuff - if not poemgame_glitch: - if t.glitch: #This conditional controls what happens when the glitch word is selected. - poemgame_glitch = True - renpy.music.play(audio.t4g) - renpy.show("white") - renpy.show("y_sticker glitch", at_list=[sticker_glitch], zorder=10) - elif persistent.playthrough != 3: - renpy.play(gui.activate_sound) - # Act 1 - if persistent.playthrough == 0: - if t.sPoint >= 3: - renpy.show("s_sticker hop") - if t.nPoint >= 3: - renpy.show("n_sticker hop") - if t.yPoint >= 3: - renpy.show("y_sticker hop") - else: - # Act 2 - if persistent.playthrough == 2 and chapter == 2 and random.randint(0,10) == 0: renpy.show("m_sticker hop") #1/10 chance for Monika's sticker to show. - elif t.nPoint > t.yPoint: renpy.show("n_sticker hop") #Since there's just Yuri and Natsuki in Act 2, whoever has the higher value for the word hops. - elif persistent.playthrough == 2 and not persistent.seen_sticker and random.randint(0,100) == 0: - renpy.show("y_sticker hopg") #"y_sticker_2g.png". 1/100 chance to see it, if we haven't seen it already. - persistent.seen_sticker = True - elif persistent.playthrough == 2 and chapter == 2: renpy.show("y_sticker_cut hop") #Yuri's cut arms sticker. - else: renpy.show("y_sticker hop") - else: - r = random.randint(0, 10) #1/10 chance to hear "baa", one time. - if r == 0 and not played_baa: - renpy.play("gui/sfx/baa.ogg") - played_baa = True - elif r <= 5: renpy.play(gui.activate_sound_glitch) - - # Adds points to the characters and progress by 1. - chibi_s.charPointTotal += t.sPoint - chibi_n.charPointTotal += t.nPoint - chibi_y.charPointTotal += t.yPoint - progress += 1 - - # End of the game - def poem_game_finish(): - # Act 1 - if persistent.playthrough == 0: - # For chapter 1, add 5 points to whomever we sided with - if chapter == 1: - chibis[ch1_choice].charPointTotal += 5 - - poemwinner[chapter] = max(chibis, key=lambda c: chibis[c].charPointTotal) - else: - # Act 2 - if chibi_n.charPointTotal > chibi_y.charPointTotal: poemwinner[chapter] = "natsuki" - else: poemwinner[chapter] = "yuri" - - # Add appeal point based on poem winner - chibis[poemwinner[chapter]].appeal += 1 - - # Set poem appeal - s_poemappeal[chapter] = chibi_s.calculate_appeal() - n_poemappeal[chapter] = chibi_n.calculate_appeal() - y_poemappeal[chapter] = chibi_y.calculate_appeal() +# This file contains the Ren'Py code for the Poem Game in DDLC. - # Poem winner always has appeal 1 (loves poem) - exec(poemwinner[chapter][0] + "_poemappeal[chapter] = 1") +# For the Python code, see `poemgame_ren.py` in the `py` directory. screen poem_test(words, progress, poemgame_glitch): default numWords = 20 @@ -324,8 +115,8 @@ label poem(transition=True): if persistent.playthrough == 0 and chapter == 0: #Shows the below dialogue the first time the minigame is played. call screen dialog("It's time to write a poem!\n\nPick words you think your favorite club member\nwill like. Something good might happen with\nwhoever likes your poem the most!", ok_action=Return()) - $ poem_game_start() - $ poem_game_finish() + $ poem_game.start() + $ poem_game.finish() # Call the new poem eye scare label if we are in Act 2 and we yet seen eyes if persistent.playthrough == 2 and persistent.seen_eyes == None and renpy.random.randint(0,5) == 0: diff --git a/game/poem_responses/__init__.py b/game/poem_responses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/poem_responses/poems.rpy b/game/poem_responses/poems.rpy index 5be30369..d8523dea 100644 --- a/game/poem_responses/poems.rpy +++ b/game/poem_responses/poems.rpy @@ -1,728 +1,6 @@ -## poems.rpy +# This file contains the Ren'Py code for displaying poems in DDLC. -# This file defines all the poems in the game that can be shown to the player -# by the girls in the poem sharing mini-game. - -init 1 python: - - class Author(object): - """ - A class used to default values of a `Poem` instance. - - `name`: str - The auhtor's name - - See the `Poem` class for more information. - """ - - def __init__(self, name, style=True, paper="images/bg/poem.jpg", separate_title_from_text=True, music=None): - self.name = name - self.style = style - self.paper = paper - self.separate_title_from_text = separate_title_from_text - self.music = music - - author_s = Author("sayori", music=audio.tsayori) - author_m = Author("monika", music=audio.tmonika) - author_n = Author("natsuki", music=audio.tnatsuki) - author_y = Author("yuri", music=audio.tyuri) - - class Poem(renpy.text.text.Text): - """ - `author`: str | Author - The author (no way!!!) of the poem. Either a string or an `Author` instance, and if it's the case, - the `style`, `paper`, `separate_title_from_text` and `music` arguments are set to the object's respectives attributes - if no value was passed, after what `author` will take `author.name`. - - `text`: str - The text to be displayed. - - `title`: str - The title of the poem. - - `style`: bool | str - Either the name of a style as string or a boolean. - If passed as `False`, will take `"default"`. - If passed as `True`: - Will first take `author.style` if `author` is an instance of `Author`. - Then, if author isn't an empty string, will take `author + "_text"`, or take `"default"` otherwise. - - `paper`: renpy.Displayable | None - A displayable to use as background. If `None` is passed, a `Null` is created. - - `separate_title_from_text`: bool - If true and that the title isn't an empty string, will add 2 newlines after the title. - - `music`: str | None - A music to be played when showing the poem. - - Additionnal text properties can be passed as keyword arguments. - """ - - def __init__(self, author, text, title="", style=True, paper=None, separate_title_from_text=False, music=None, **properties): - if isinstance(author, Author): - paper = paper or author.paper - separate_title_from_text = separate_title_from_text or author.separate_title_from_text - music = music or author.music - - if style is True: - style = author.style - - author = author.name - - for t in (author, title, text): - if not isinstance(t, basestring): - raise TypeError("'author', 'title' and 'text' must all be strings.\n(if 'author' is an instance of 'Author', 'author.name' must be a string)") - - if style is True: - if author: - style = author + "_text" - else: - style = "default" - - elif style is False: - style = "default" - - poem = title + ("\n\n" + text if separate_title_from_text and title else text) - - super(Poem, self).__init__(poem, style=style, **properties) - - self.author = author - self.paper = renpy.easy.displayable_or_none(paper) or Null() - self.music = music - - def format_music_string(music, pos=0): - """ - Given a filename `music` and a position `pos`, returns a string that will make the music start from `pos`, - replacing the previous `from XXX` should it be found in `music`. - - 3 possible cases: - ``` - format_music_string("music/song_1.ogg", 3.0) - >>> "music/song_1.ogg" - - format_music_string("music/song_1.ogg", 3.0) - >>> "music/song_1.ogg" - - format_music_string("music/song_1.ogg", 3.0) - >>> "music/song_1.ogg" - ``` - """ - if re.match(r"^<.*?>", music): # if the string looks like "<...>music/song_1.ogg" - PATTERN = re.compile(r"from( *)((\d+\.\d*)|(\d+)|(\.\d+))") # "..." or "..." or "..." or "..." - info, gt, path = music.partition(">") - - if PATTERN.search(info): - info = PATTERN.sub("from {}".format(pos), info) - music = info + gt + path - else: - music = "") + track_partition_pattern = re.compile(r"from( *)((\d+\.\d*)|(\d+)|(\.\d+))") + + if music_match_pattern.match(music): + info, gt, path = music.partition(">") + + if track_partition_pattern.search(info): + info = track_partition_pattern.sub("from %s" % pos, info) + music = info + gt + path + else: + music = "" % (pos, music[1:]) + else: + music = "" % (pos, music) + + return music + + def show( + self, + img: str | None = None, + at_list: list = [store.i11], + paper_sound: str | None = store.audio.page_turn, + music: str | bool = True, + from_current: bool = True, + revert_music: bool = True, + testing: bool = False, + ): + """ + Displays the poem to the Poem Response screen. + + :param paper_sound: The sound to play when the poem is displayed. + :param music: Whether to play the music associated with the poem. + :param from_current: Whether to start the music from the current position of the previous music track. + :param revert_music: Whether to revert the music to the previous track after the poem is displayed. + :param testing: Unused in DDLC. Used for GitHub Actions testing purposes. + + :type paper_sound: str | None + :type music: str | bool + :type from_current: bool + :type revert_music: bool + :type testing: bool + """ + if not testing: + previous_music = None + + if paper_sound is not None: + renpy.sound.play(paper_sound, channel="page_turn", loop=False) + + _window_hide() # type: ignore # noqa: F821 + + if music is True: + poem_track = self.music or None + else: + poem_track = music or None + + if poem_track: + previous_music = renpy.music.get_playing() + music = ( + self.format_music_str(poem_track, renpy.music.get_pos()) + if from_current + else poem_track + ) + renpy.music.play(music, channel="poem", loop=True, fadeout=0.5) + renpy.music.stop(fadeout=2.0) + + allow_skipping = renpy.config.allow_skipping + renpy.config.allow_skipping = False + skipping = store._skipping + store._skipping = False + + renpy.transition(store.dissolve) + renpy.show_screen("poem", self) + pause() + + if img: + if isinstance(self.author, PoemAuthor): + renpy.hide(self.author.name) + else: + renpy.hide(self.author) + renpy.show(img, at_list=at_list) + + renpy.hide_screen("poem") + renpy.transition(store.dissolve) + + renpy.config.allow_skipping = allow_skipping + store._skipping = skipping + + if poem_track and revert_music: + if previous_music: + previous_music = ( + self.format_music_str(previous_music, renpy.music.get_pos()) + if from_current + else previous_music + ) + renpy.music.play(previous_music, loop=True, fadein=2.0) + + renpy.music.stop("music", fadeout=2.0) + + renpy._window_auto = True + + if not persistent.first_poem: + persistent.first_poem = True + + +class PoemResponseDB(object): + """ + A class used to represent a database of poems. + """ + + def __init__(self): + """ + Initializes the poem response database. + """ + self.poems: dict[str, Poem] = {} + + def add_poem( + self, + identifier: str, + author: PoemAuthor, + title: str, + text: str, + style: bool | str = True, + paper: str = "images/bg/poem.jpg", + separate_title_from_text: bool = True, + music: str | None = None, + translate: typing.Literal["all", "title", "text", "none"] = "all", + ): + """ + Adds a poem to the database. + + :param identifier: The unique identifier for the poem. + :param author: The author of the poem. + :param title: The title of the poem. + :param text: The text of the poem. + :param style: Whether to apply a specific style to the poem. + :param paper: The background image for the poem. + :param separate_title_from_text: Whether to separate the title from the text. + :param music: The music to play during the poem. + :param translate: Whether to let Ren'Py translate the poem text. + + :type identifier: str + :type author: PoemAuthor + :type title: str + :type text: str + :type style: bool | str + :type paper: str + :type separate_title_from_text: bool + :type music: str | None + :type translate: typing.Literal["all", "title", "text", "none"] + """ + self.poems[identifier] = Poem( + author=author, + title=store._(title) if translate in ["all", "title"] else title, + text=store._(text) if translate in ["all", "text"] else text, + style=style, + paper=paper, + separate_title_from_text=separate_title_from_text, + music=music, + ) + + def get_poem(self, identifier: str) -> Poem: + """ + Retrieves a poem from the database by its identifier. + + :param identifier: The unique identifier for the poem. + + :type identifier: str + + :return poem: The poem if found + :rtype: Poem + :raise ValueError: If the poem with the given identifier does not exist. + """ + if identifier in self.poems: + return self.poems[identifier] + raise ValueError(f"Poem with identifier '{identifier}' not found.") + + def get_poems(self) -> list[str]: + """ + Returns a list of all poems in the database. + + :return: A list of all poems. + :rtype: list[Poem] + """ + return list(self.poems.keys()) + + def show_poem(self, identifier: str, img: str | None = None, **kwargs): + """ + Displays a poem from the database by its identifier. + + :param identifier: The unique identifier for the poem. + :param kwargs: Additional keyword arguments to pass to the `show` method of the Poem class. + + :type identifier: str + """ + poem = self.get_poem(identifier) + if poem: + poem.show(img=img, **kwargs) + else: + raise ValueError(f"Poem with identifier '{identifier}' not found.") + + +# Initialize the Poem database and authors. +poem_db = PoemResponseDB() + +author_s = PoemAuthor("sayori", music=store.audio.tsayori) +author_n = PoemAuthor("natsuki", music=store.audio.tnatsuki) +author_y = PoemAuthor("yuri", music=store.audio.tyuri) +author_m = PoemAuthor("monika", music=store.audio.tmonika) + +## Yuri's Poems +poem_db.add_poem( + "poem_y1", + author_y, + title="Ghost Under the Light", + text="""\ +The tendrils of my hair illuminate beneath the amber glow. +Bathing. +It must be this one. +The last remaining streetlight to have withstood the test of time. +the last yet to be replaced by the sickening blue-green hue of the future. +I bathe. Calm; breathing air of the present but living in the past. +The light flickers. +I flicker back.""", +) + +poem_db.add_poem( + "poem_y2", + author_y, + title="The Raccoon", + text="""\ +It happened in the dead of night while I was slicing bread for a guilty snack. +My attention was caught by the scuttering of a raccoon outside my window. +That was, I believe, the first time I noticed my strange tendencies as an unordinary human. +I gave the raccoon a piece of bread, my subconscious well aware of the consequences. +Well aware that a raccoon that is fed will always come back for more. +The enticing beauty of my cutting knife was the symptom. +The bread, my hungry curiosity. +The raccoon, an urge. + +The moon increments its phase and reflects that much more light off of my cutting knife. +The very same light that glistens in the eyes of my raccoon friend. +I slice the bread, fresh and soft. The raccoon becomes excited. +Or perhaps I'm merely projecting my emotions onto the newly-satisfied animal. + +The raccoon has taken to following me. +You could say that we've gotten quite used to each other. +The raccoon becomes hungry more and more frequently, so my bread is always handy. +Every time I brandish my cutting knife, the raccoon shows me its excitement. +A rush of blood. Classic Pavlovian conditioning. I slice the bread. +And I feed myself again.""", +) + +poem_db.add_poem( + "poem_y3", + author_y, + title="Beach", + text="""\ +A marvel millions of years in the making. +Where the womb of Earth chaotically meets the surface. +Under a clear blue sky, an expanse of bliss-- +But beneath gray rolling clouds, an endless enigma. +The easiest world to get lost in +Is one where everything can be found. + +One can only build a sand castle where the sand is wet. +But where the sand is wet, the tide comes. +Will it gently lick at your foundations until you give in? +Or will a sudden wave send you crashing down in the blink of an eye? +Either way, the outcome is the same. +Yet we still build sand castles. + +I stand where the foam wraps around my ankles. +Where my toes squish into the sand. +The salty air is therapeutic. +The breeze is gentle, yet powerful. +I sink my toes into the ultimate boundary line, tempted by the foamy tendrils. +Turn back, and I abandon my peace to erode at the shore. +Drift forward, and I return to Earth forevermore.""", +) + +poem_db.add_poem( + "poem_y3b", + author_y, + title="Ghost Under the Light pt. 2", + text="""\ +The tendrils of my hair illuminate beneath the amber glow. +Bathing. +In the distance, a blue-green light flickers. +A lone figure crosses its path - a silhouette obstructing the eerie glow. +My heart pounds. The silhouette grows. Closer. Closer. +I open my umbrella, casting a shadow to shield me from visibility. +But I am too late. +He steps into the streetlight. I gasp and drop my umbrella. +The light flickers. My heart pounds. He raises his arm. + +Time stops. + +The only indication of movement is the amber light flickering against his outstretched arm. +The flickering light is in rhythm with the pounding of my heart. +Teasing me for succumbing to this forbidden emotion. +Have you ever heard of a ghost feeling warmth before? +Giving up on understanding, I laugh. +Understanding is overrated. +I touch his hand. The flickering stops. +Ghosts are blue-green. My heart is amber.""", +) + +## Yuri's Act 2 Poems +poem_db.add_poem( + "poem_y22", + author_y, + title="Wheel", + text="""\ +A rotating wheel. Turning an axle. Grinding. Bolthead. Linear gearbox. Falling sky. Seven holy stakes. \ +A docked ship. A portal to another world. A thin rope tied to a thick rope. A torn harness. Parabolic gearbox. \ +Expanding universe. Time controlled by slipping cogwheels. Existence of God. Swimming with open water in all directions. \ +Drowning. A prayer written in blood. A prayer written in time-devouring snakes with human eyes. \ +A thread connecting all living human eyes. A kaleidoscope of holy stakes. Exponential gearbox. \ +A sky of exploding stars. God disproving the existence of God. A wheel rotating in six dimensions. \ +Forty gears and a ticking clock. A clock that ticks one second for every rotation of the planet. \ +A clock that ticks forty times every time it ticks every second time. A bolthead of holy stakes tied to \ +the existence of a docked ship to another world. A kaleidoscope of blood written in clocks. A time-devouring \ +prayer connecting a sky of forty gears and open human eyes in all directions. Breathing gearbox. Breathing bolthead. \ +Breathing ship. Breathing portal. Breathing snakes. Breathing God. Breathing blood. Breathing holy stakes. \ +Breathing human eyes. Breathing time. Breathing prayer. Breathing sky. Breathing wheel.""", + paper="images/bg/poem_y1.jpg", +) + +poem_db.add_poem( + "poem_y23", + author_y, + title="mdpnfbo,jrfp", + text="""\ +ed,,zinger suivante,,tels handknits finish,,cagefuls basinlike bag octopodan,,imboss\ +ing vaporettos rorid easygoingnesses nalorphines,,benzol respond washerwomen bris\ +tlecone,,parajournalism herringbone farnarkeled,,episodically cooties,,initiallers \ +bimetallic,,leased hinters,,confidence teetotaller computerphobes,,pinnacle exotica\ +lly overshades prothallia,,posterior gimmickry brassages bediapers countertrades,,\ +haslet skiings sandglasses cannoli,,carven nis egomaniacal,,barminess gallivanted,,\ +southeastward,,oophoron crumped,,tapued noncola colposcopical,,dolente trebbiano re\ +vealment,,outworked isotropous monosynaptic excisional moans,,enterocentesis jacuz\ +zi preoccupations,,hippodrome outward googs,,tabbises undulators,,metathesizing,,sha\ +ria prepostor,,neuromast curmudgeons actability,,archaise spink reddening miscount\ +,,madmen physostigmin statecraft neurocoeles bammed,,tenderest barguests crusados \ +trust,,manshifts darzis aerophones,,reitboks discomposingly,,expandors,,monotasking \ +galabia,,pertinents expedients witty,,chirographies crachach unsatisfactoriness sw\ +erveless,,flawed sepulchred thanksgiver scrawl skug,,perorate stringers gelatine f\ +lagstones,,chuses conceptualization surrejoined,,counterblasts rache,,numerative,,de\ +lirifacients methylthionine,,mantram dynamist atomised,,eternization percalines hr\ +yvnias pragmatizing,,reproachfulnesses telework nowts demoded revealer,,burnettize\ + caryopteris subangular wirricows,,transvestites sinicized narcissus,,hikers meno,,\ +degassing,,postcrises alikenesses,,sycophancy seroconverting insure,,yantras raphid\ +es cliftiest bosthoon,,zootherapy chlorides nationwide schlub yuri,,timeshares cas\ +tanospermine backspaces reincite,,coactions cosignificative palafitte,,poofters su\ +bjunctions,,aquarian,,theralite revindicating,,cynosural permissibilities narcotisi\ +ng,,journeywork outkissed clarichords troutier,,myopias undiverting evacuations sn\ +arier superglue,,deaminise infirmaries teff hebephrenias,,brainboxes homonym lance\ +let,,lambitive stray,,inveigled,,acetabulums atenolol,,dekkos scarcer flensed,,abulia\ +s flaggers wammul boastfully,,galravitch happies interassociation multipara augme\ +ntations,,teratocarcinomata coopting didakai infrequently,,hairtails intricacy usu\ +als,,pillorise outrating,,cataphoresis,,furnishings leglen,,goethite deflate butterb\ +urs,,phoneticising winiest hyposulphuric campshirts,,chainfalls swimmings roadbloc\ +ked redone soliloquies,,broking mendaciousness parasitisms counterworld,,unravelli\ +ngs quarries passionately,,onomatopoesis repenting,,ramequin,,mopboard euphuistical\ +ly,,volta sycophantized allantoides,,bors bouclees raisings sustaining,,diabolist s\ +ticks dole liltingly,,curial bisexualisms siderations hemolysed,,damnabilities unk\ +enneling halters,,peripheral congaing,,diatomicity,,foolings repayments,,hereabouts \ +vamosed him,,slanters moonrock porridgy monstruous,,heartwood bassoonist predispos\ +itions jargoon dominances,,timidest inalienable rewearing inevitably,,entreating r\ +etiary tranquillizing,,uniparental droogs,,allotropous,,forzati abiogenetic,,obdurat\ +ion exempted unifaces,,epilating calisaya dispiteously coggles,,vestmented flukily\ + ignifying complished hiccupy municipalize,,pentagraphs parcels sutler excavates,,\ +stardust miscited thankfulness,,fouter pertused,,overpacks,,guarishes hylotheism,,pi +Fresh blood seeps through the line parting her skin and slowly colors her breast red.\ + I begin to hyperventilate as my compulsion grows. The images won’t go away. Images of\ + me driving the knife into her flesh continuously, fucking her body with the blade, \ +making a mess of her. My head starts going crazy as my thoughts start to return. \ +Shooting pain assaults my mind along with my thoughts. This is disgusting. Absolutely\ + disgusting. How could I ever let myself think these things? But it’s unmistakable. \ +The lust continues to linger through my veins. An ache in my muscles stems from the \ +unreleased tension experienced by my entire body. Her Third Eye is drawing me closer.""", + paper="images/bg/poem_y2.jpg", + style="yuri_text_3", + translate="none", +) + +## Natsuki's Poems +poem_db.add_poem( + "poem_n1", + author_n, + title="Eagles Can Fly", + text="""\ +Monkeys can climb +Crickets can leap +Horses can race +Owls can seek +Cheetahs can run +Eagles can fly +People can try +But that's about it.""", +) + +poem_db.add_poem( + "poem_n2", + author_n, + title="Amy Likes Spiders", + text="""\ +You know what I heard about Amy? +Amy likes spiders. +Icky, wriggly, hairy, ugly spiders! +That's why I'm not friends with her. + +Amy has a cute singing voice. +I heard her singing my favorite love song. +Every time she sang the chorus, my heart would pound to the rhythm of the words. +But she likes spiders. +That's why I'm not friends with her. + +One time, I hurt my leg really bad. +Amy helped me up and took me to the nurse. +I tried not to let her touch me. +She likes spiders, so her hands are probably gross. +That's why I'm not friends with her. + +Amy has a lot of friends. +I always see her talking to people. +She probably talks about spiders. +What if her friends start to like spiders too? +That's why I'm not friends with her. + +It doesn't matter if she has other hobbies. +It doesn't matter if she keeps it private. +It doesn't matter if it doesn't hurt anyone. + +It's gross. +She's gross. +The world is better off without spider lovers. + +And I'm gonna tell everyone.""", +) + +poem_db.add_poem( + "poem_n3", + author_n, + title="I'll Be Your Beach", + text="""\ +Your mind is so full of troubles and fears +That diminished your wonder over the years +But today I have a special place +A beach for us to go. + +A shore reaching beyond your sight +A sea that sparkles with brilliant light +The walls in your mind will melt away +Before the sunny glow. + +I'll be the beach that washes your worries away +I'll be the beach that you daydream about each day +I'll be the beach that makes your heart leap +In a way you thought had left you long ago. + +Let's bury your heavy thoughts in a pile of sand +Bathe in sunbeams and hold my hand +Wash your insecurities in the salty sea +And let me see you shine. + +Let's leave your memories in a footprint trail +Set you free in my windy sail +And remember the reasons you're wonderful +When you press your lips to mine. + +I'll be the beach that washes your worries away +I'll be the beach that you daydream about each day +I'll be the beach that makes your heart leap +In a way you thought had left you long ago. + +But if you let me by your side +Your own beach, your own escape +You'll learn to love yourself again.""", +) + +poem_db.add_poem( + "poem_n3b", + author_n, + title="Because You", + text="""\ +Tomorrow will be brighter with me around +But when today is dim, I can only look down. +My looking is a little more forward +Because you look at me. + +When I want to say something, I say it with a shout! +But my truest feelings can never come out. +My words are a little less empty +Because you listen to me. + +When something is above me, I reach for the stars. +But when I feel small, I don't get very far. +My standing is a little bit taller +Because you sit with me. + +I believe in myself with all of my heart. +But what do I do when it's torn all apart? +My faith is a little bit stronger +Because you trusted me. + +My pen always puts my feelings to the test. +I'm not a good writer, but my best is my best. +My poems are a little bit dearer +Because you think of me. + +Because you, because you, because you.""", +) + +## Natsuki's Act 2 Poems +poem_db.add_poem( + "poem_n2b", + author_n, + title="T3BlbiBZb3VyIFRoaXJkIEV5ZQ==", + text="""\ +SSBjYW4gZmVlbCB0aGUgdGVuZGVybmVz +cyBvZiBoZXIgc2tpbiB0aHJvdWdoIHRo +ZSBrbmlmZSwgYXMgaWYgaXQgd2VyZSBh +biBleHRlbnNpb24gb2YgbXkgc2Vuc2Ug +b2YgdG91Y2guIE15IGJvZHkgbmVhcmx5 +IGNvbnZ1bHNlcy4gVGhlcmUncyBzb21l +dGhpbmcgaW5jcmVkaWJseSBmYWludCwg +ZGVlcCBkb3duLCB0aGF0IHNjcmVhbXMg +dG8gcmVzaXN0IHRoaXMgdW5jb250cm9s +bGFibGUgcGxlYXN1cmUuIEJ1dCBJIGNh +biBhbHJlYWR5IHRlbGwgdGhhdCBJJ20g +YmVpbmcgcHVzaGVkIG92ZXIgdGhlIGVk +Z2UuIEkgY2FuJ3QuLi5JIGNhbid0IHN0 +b3AgbXlzZWxmLg==""", + translate="none", +) + +poem_db.add_poem( + "poem_n23", + author_n, + title="", + text="""\ +I don't know how else to bring this up. But there's been something I've been worried about. \ +Yuri has been acting kind of strange lately. You've only been here a few days, so you may \ +not know what I mean. But she's not normally like this. She's always been quiet and polite \ +and attentive...things like that. + +Okay... This is really embarrassing, but I'm forcing myself to suck it up. The truth is, I'm REALLY \ +worried about her. But if I try talking to her, she'll just get mad at me again. I don't \ +know what to do. I think you're the only person that she'll listen to. I don't know why. \ +But please try to do something. Maybe you can convince her to talk to a therapist. + +I've always wanted to try being better friends with Yuri, and it really hurts me to see \ +this happening. I know I'm going to hate myself later for admitting that, but right now \ +I don't care. I just feel so helpless. So please see if you can do something to help. \ +I don't want anything bad to happen to her. I'll make you cupcakes if I have to. Just please \ +try to do something. + +As for Monika... I don't know why, but she's been really dismissive about this. It's like she just wants us \ +to ignore it. So I'm mad at her right now, and that's why I'm coming to you about this. \ +DON'T LET HER KNOW I WROTE THIS!!!! Just pretend like I gave you a really good poem, okay? \ +I'm counting on you. Thanks for reading.""", + translate="text", +) + +## Sayori's Poems +poem_db.add_poem( + "poem_s1", + author_s, + title="Dear Sunshine", + text="""\ +The way you glow through my blinds in the morning +It makes me feel like you missed me. +Kissing my forehead to help me out of bed. +Making me rub the sleepy from my eyes. + +Are you asking me to come out and play? +Are you trusting me to wish away a rainy day? +I look above. The sky is blue. +It's a secret, but I trust you too. + +If it wasn't for you, I could sleep forever. +But I'm not mad. + +I want breakfast.""", +) + +poem_db.add_poem( + "poem_s2", + author_s, + title="Bottles", + text="""\ +I pop off my scalp like the lid of a cookie jar. +It's the secret place where I keep all my dreams. +Little balls of sunshine, all rubbing together like a bundle of kittens. +I reach inside with my thumb and forefinger and pluck one out. +It's warm and tingly. +But there's no time to waste! I put it in a bottle to keep it safe. +And I put the bottle on the shelf with all of the other bottles. +Happy thoughts, happy thoughts, happy thoughts in bottles, all in a row. + +My collection makes me lots of friends. +Each bottle a starlight to make amends. +Sometimes my friend feels a certain way. +Down comes a bottle to save the day. + +Night after night, more dreams. +Friend after friend, more bottles. +Deeper and deeper my fingers go. +Like exploring a dark cave, discovering the secrets hiding in the nooks and crannies. +Digging and digging. +Scraping and scraping. + +I blow dust off my bottle caps. +It doesn't feel like time elapsed. +My empty shelf could use some more. +My friends look through my locked front door. + +Finally, all done. I open up, and in come my friends. +In they come, in such a hurry. Do they want my bottles that much? +I frantically pull them from the shelf, one after the other. +Holding them out to each and every friend. +Each and every bottle. +But every time I let one go, it shatters against the tile between my feet. +Happy thoughts, happy thoughts, happy thoughts in shards, all over the floor. + +They were supposed to be for my friends, my friends who aren't smiling. +They're all shouting, pleading. Something. +But all I hear is echo, echo, echo, echo, echo +Inside my head.""", +) + +poem_db.add_poem( + "poem_s3", + author_s, + title="%", + text="""\ +Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of my head. Get out of +Get. +Out. +Of. +My. +Head.\n\n\n +Get out of my head before I do what I know is best for you. +Get out of my head before I listen to everything she said to me. +Get out of my head before I show you how much I love you. +Get out of my head before I finish writing this poem.\n\n\n\n\n\n\n +But a poem is never actually finished. +It just stops moving.""", + translate="text", +) + +## Monika's Poems +poem_db.add_poem( + "poem_m1", + author_m, + title="Hole in Wall", + text="""\ +It couldn't have been me. +See, the direction the spackle protrudes. +A noisy neighbor? An angry boyfriend? I'll never know. I wasn't home. +I peer inside for a clue. +No! I can't see. I reel, blind, like a film left out in the sun. +But it's too late. My retinas. +Already scorched with a permanent copy of the meaningless image. +It's just a little hole. It wasn't too bright. +It was too deep. +Stretching forever into everything. +A hole of infinite choices. +I realize now, that I wasn't looking in. +I was looking out. +And he, on the other side, was looking in.""", +) + +poem_db.add_poem( + "poem_m2", + author_m, + title="Save Me", + text="""\ +The colors, they won't stop. +Bright, beautiful colors +Flashing, expanding, piercing +Red, green, blue +An endless +cacophony +Of meaningless +noise + + +The noise, it won't stop. +Violent, grating waveforms +Squeaking, screeching, piercing +Sine, cosine, tangent + Like playing a chalkboard on a turntable + Like playing a vinyl on a pizza crust +An endless +poem +Of meaningless\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n +Load Me + """, +) + +poem_db.add_poem( + "poem_m3", + author_m, + title="The Lady who Knows Everything", + text="""\ +An old tale tells of a lady who wanders Earth. +The Lady who Knows Everything. +A beautiful lady who has found every answer, +All meaning, +All purpose, +And all that was ever sought. + +And here I am, + + + a feather + + +Lost adrift the sky, victim of the currents of the wind. + +Day after day, I search. +I search with little hope, knowing legends don't exist. +But when all else has failed me, +When all others have turned away, +The legend is all that remains - the last dim star glimmering in the twilit sky. + +Until one day, the wind ceases to blow. +I fall. +And I fall and fall, and fall even more. +Gentle as a feather. +A dry quill, expressionless. + +But a hand catches me between the thumb and forefinger. +The hand of a beautiful lady. +I look at her eyes and find no end to her gaze. + +The Lady who Knows Everything knows what I am thinking. +Before I can speak, she responds in a hollow voice. +"I have found every answer, all of which amount to nothing. +There is no meaning. +There is no purpose. +And we seek only the impossible. +I am not your legend. +Your legend does not exist." + +And with a breath, she blows me back afloat, and I pick up a gust of wind.""", +) + +poem_db.add_poem( + "poem_m4", + author_m, + title="Happy End", + text="""\ +Pen in hand, I find my strength. +The courage endowed upon me by my one and only love. +Together, let us dismantle this crumbling world +And write a novel of our own fantasies. + +With a flick of her pen, the lost finds her way. +In a world of infinite choices, behold this special day. + +After all, +Not all good times must come to an end.""", +) + +## Monika's Act 2 Poems +poem_db.add_poem( + "poem_m21", + author_m, + title="Hole in Wall", + text="""\ +But he wasn't looking at me. +Confused, I frantically glance at my surroundings. +But my burned eyes can no longer see color. +Are there others in this room? Are they talking? +Or are they simply poems on flat sheets of paper, +The sound of frantic scrawling playing tricks on my ears? +The room begins to crinkle. +Closing in on me. +The air I breathe dissipates before it reaches my lungs. +I panic. There must be a way out. +It's right there. He's right there. + +Swallowing my fears, I brandish my pen.""", +) + +poem_db.add_poem( + "poem_m22", + author_m, + title="Save Me", + text="""\ +The colors, they won't +Bright, bea t ful c l rs +Flash ng, exp nd ng, piercing +Red, green, blue +An ndless +CACOPHONY +Of meaningless +noise + + +The noise, it won't STOP. +Viol nt, grating w vef rms +Sq e king, screech ng, piercing +SINE, COSINE, TANGENT + Like play ng a ch lkboard on a t rntable + Like playing a KNIFE on a BREATHING RIBCAGE + n ndl ss +p m +Of m n ngl ss\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n +Delete Her + """, +) diff --git a/game/poem_responses/script-poemresponses.rpy b/game/poem_responses/script-poemresponses.rpy index a09f00f5..1050b50e 100644 --- a/game/poem_responses/script-poemresponses.rpy +++ b/game/poem_responses/script-poemresponses.rpy @@ -35,13 +35,13 @@ label poemresponse_start: label poemresponse_start2: $ skip_poem = False - - # This if/else statement checks if we are in Act 2 to show Act 2 specific - # poems. - if persistent.playthrough == 2: - $ pt = "2" - else: - $ pt = "" + + # Stores whether a character has read your poem. + python: + s_readpoem = readpoem["sayori"] + n_readpoem = readpoem["natsuki"] + y_readpoem = readpoem["yuri"] + m_readpoem = readpoem["monika"] # This if/else statement determines what MC will say in the poem selection # menu depending on how many poems you have read. @@ -61,37 +61,37 @@ label poemresponse_start: # poem to her and you are in Act 1. "Sayori" if not s_readpoem and persistent.playthrough == 0: # This variable sets that you have read Sayori's poem. - $ s_readpoem = True + $ readpoem["sayori"] = True if chapter == 1 and poemsread == 0: "I'm definitely most comfortable sharing it with Sayori first." "She's my good friend, after all." # This call statement calls Sayori's poem response script. - call poemresponse_sayori + call poemresponse_callback("sayori") # This will show Natsuki as a menu option IF you haven't shared your # poem to her. "Natsuki" if not n_readpoem: - $ n_readpoem = True + $ readpoem["natsuki"] = True if chapter == 1 and poemsread == 0: "I told Natsuki I was interested in her poems yesterday." "It's probably only fair if I shared mine with her first." - call poemresponse_natsuki + call poemresponse_callback("natsuki") # This will show Yuri as a menu option IF you haven't shared your # poem to her and she didn't run away from you in Act 2. "Yuri" if not y_readpoem and not y_ranaway: - $ y_readpoem = True + $ readpoem["yuri"] = True if chapter == 1 and poemsread == 0: "Yuri seems the most experienced, so I should start with her." "I can trust her opinion to be fair." - call poemresponse_yuri + call poemresponse_callback("yuri") "Monika" if not m_readpoem: - $ m_readpoem = True + $ readpoem["monika"] = True if chapter == 1 and poemsread == 0: "I should start with Monika." "Yesterday she seemed eager to read my poem, and I want her to know I'm putting in effort." - call poemresponse_monika + call poemresponse_callback("monika") # This variable increases the poems read by 1. $ poemsread += 1 @@ -102,111 +102,70 @@ label poemresponse_start: jump poemresponse_loop # These variables resets the read poem variables back to normal. - $ s_readpoem = False - $ n_readpoem = False - $ y_readpoem = False - $ m_readpoem = False - $ poemsread = 0 + python: + poemsread = 0 + for key in readpoem: + readpoem[key] = False return -# These labels calls each characters' poem response result given how much they +# This label calls each characters' poem response result given how much they # liked your poem. -label poemresponse_sayori: +label poemresponse_callback(character): scene bg club_day - show sayori 1a zorder 2 at t11 - with wipeleft_scene - # This variable sets the default opinion to OK. - $ poemopinion = "med" - - # This if/elif statement checks if Sayori's opinion of your poem was bad - # or good. - if s_poemappeal[chapter - 1] < 0: - $ poemopinion = "bad" - elif s_poemappeal[chapter - 1] > 0: - $ poemopinion = "good" - # These variables sets the next scene chapter to be called based off the - # chapter and poem opinion and calls it. - $ nextscene = "ch" + pt + str(chapter) + "_s_" + poemopinion - call expression nextscene - - # This if statement checks if we are not skipping the poems to call the - # end of the poem responses for Sayori depending on the chapter. - if not skip_poem: - $ nextscene = "ch" + pt + str(chapter) + "_s_end" - call expression nextscene - return - -label poemresponse_natsuki: - scene bg club_day - show natsuki 1c zorder 2 at t11 - with wipeleft_scene - # This variable sets the default opinion to OK. - $ poemopinion = "med" - - # This if/elif statement checks if Natsuki's opinion of your poem was bad - # or good. - if n_poemappeal[chapter - 1] < 0: - $ poemopinion = "bad" - elif n_poemappeal[chapter - 1] > 0: - $ poemopinion = "good" - - # These variables sets the next scene chapter to be called based off the - # chapter and poem opinion and calls it. - $ nextscene = "ch" + pt + str(chapter) + "_n_" + poemopinion - call expression nextscene - - # This if statement checks if we are not skipping the poems to call the - # end of the poem responses for Natsuki depending on the chapter. - if not skip_poem: - $ nextscene = "ch" + pt + str(chapter) + "_n_end" - call expression nextscene - return - -label poemresponse_yuri: - scene bg club_day - show yuri 1a zorder 2 at t11 + if character == "sayori": + show sayori 1a zorder 2 at t11 + elif character == "natsuki": + show natsuki 1a zorder 2 at t11 + elif character == "yuri": + show yuri 1a zorder 2 at t11 + elif character == "monika": + show monika 1a zorder 2 at t11 with wipeleft_scene $ poemopinion = "med" - - if y_poemappeal[chapter - 1] < 0: - $ poemopinion = "bad" - elif y_poemappeal[chapter - 1] > 0: - $ poemopinion = "good" - - $ nextscene = "ch" + pt + str(chapter) + "_y_" + poemopinion - call expression nextscene - - if not skip_poem: - $ nextscene = "ch" + pt + str(chapter) + "_y_end" - call expression nextscene - return + python: + if persistent.playthrough == 2: + nextscene = f"ch2{chapter}_" + else: + nextscene = f"ch{chapter}_" -# NOTE: Monika does not use the good/bad/med poem opinion. Instead she just uses -# 'chX_m_start' and 'chX_m_end' instead. -label poemresponse_monika: - scene bg club_day - show monika 1a zorder 2 at t11 - with wipeleft_scene + startscene = nextscene + endscene = nextscene - if m_poemappeal[chapter - 1] < 0: - $ poemopinion = "bad" - elif m_poemappeal[chapter - 1] > 0: - $ poemopinion = "good" + if character == "monika": + # Monika doesn't use the same poem logic as the others. + $ startscene += "m_start" + $ endscene += "m_end" + else: + if character == "sayori": + $ appeal = poemappeal["sayori"][chapter - 1] + elif character == "natsuki": + $ appeal = poemappeal["natsuki"][chapter - 1] + elif character == "yuri": + $ appeal = poemappeal["yuri"][chapter - 1] + else: + python: + raise Exception("Invalid character for poemresponse_callback.") + + if appeal < 0: + $ poemopinion = "bad" + elif appeal > 0: + $ poemopinion = "good" - $ nextscene = "ch" + pt + str(chapter) + "_m_start" - call expression nextscene + $ startscene += character[0] + "_" + poemopinion + $ endscene += character[0] + "_end" + + call expression startscene if not skip_poem: - $ nextscene = "ch" + pt + str(chapter) + "_m_end" - call expression nextscene + call expression endscene return ## Poem End Labels # These labels define the end result of the poem sharing mini-game with the girls. label ch1_y_end: - call showpoem (poem_y1, img="yuri 3t") + $ poem_db.show_poem("poem_y1", img="yuri 3t") y 3t "..." y "I...I'm sorry I have such terrible handwriting!" mc "What??" @@ -265,7 +224,7 @@ label ch1_y_end: return label ch2_y_end: - call showpoem (poem_y2) + $ poem_db.show_poem("poem_y2") y 2m "Um..." y "I was a little more daring with this one than yesterday's..." mc "I can see that." @@ -282,7 +241,7 @@ label ch2_y_end: y "So, I sometimes enjoy writing about them." # This if/else statement checks if you shared your poem to Natsuki already and # if she loved the 1st or 2nd poem. - if n_readpoem and (n_poemappeal[0] >= 0 or n_poemappeal[1] >= 0): + if n_readpoem and (poemappeal["natsuki"][0] >= 0 or poemappeal["natsuki"][1] >= 0): mc "Huh, that's funny..." y 2e "...?" mc "Didn't Natsuki also write something about that?" @@ -318,7 +277,7 @@ label ch2_y_end: y 2u "I-I might be ranting a little bit now..." y "...But I'm glad that you're a good listener." # This if statement checks if Yuri's appeal to your poems is 2 or more. - if chibi_y.appeal >= 2: + if get_appeal("yuri") >= 2: y 2s "You're good at a lot of things..." y "Writing, listening..." y 2u "There really aren't many people like you, [player]..." @@ -338,15 +297,15 @@ label ch3_y_end: $ y_read3 = True # This if statement checks if Yuri's appeal is 3 or more to call her # special poem instead. - if chibi_y.appeal >= 3: + if get_appeal("yuri") >= 3: jump ch3_y_end_special - call showpoem (poem_y3, img="yuri 2v") + $ poem_db.show_poem("poem_y3", img="yuri 2v") y "Um..." y "I'm aware that the beach is kind of an inane thing to write about." y "But I did my best to take a metaphorical approach to it." # This if/else statement checks if you did not read Natsuki's special poem # or if her poem appeal is 3 or more. - if not n_read3 or chibi_n.appeal >= 3: + if not n_read3 or get_appeal("natsuki") >= 3: mc "You say that like you didn't even want to write about it..." y 2e "Oh, you haven't heard...?" y 2h "After yesterday, Natsuki and I...well..." @@ -382,7 +341,7 @@ label ch3_y_end: return label ch3_y_end_special: - call showpoem (poem_y3b, img="yuri 4b") + $ poem_db.show_poem("poem_y3b", img="yuri 4b") "Finishing the poem, I start to hand it back to Yuri." "But instead of taking it from me, she looks away." y "..." @@ -435,7 +394,7 @@ label ch3_y_end_special: return label ch1_n_end: - call showpoem (poem_n1, img="natsuki 2s") + $ poem_db.show_poem("poem_n1", img="natsuki 2s") n 2q "Yeah..." n "I told you that you weren't gonna like it." mc "I like it." @@ -469,7 +428,7 @@ label ch1_n_end: return label ch2_n_end: - call showpoem (poem_n2) + $ poem_db.show_poem("poem_n2") n 2a "Not bad, right?" mc "It's quite a bit longer than yesterday's." n 2w "Yesterday's was way too short..." @@ -490,7 +449,7 @@ label ch2_n_end: n 1e "...But that just makes people stupid!" n "Who cares what someone likes, as long as they're not hurting anyone, and it makes them happy?" n 1q "I think people really need to learn to respect others for liking weird things..." - if y_readpoem and (y_poemappeal[0] >= 0 or y_poemappeal[1] >= 0): + if y_readpoem and (poemappeal["yuri"][0] >= 0 or poemappeal["yuri"][1] >= 0): mc "Huh, that's funny..." mc "Yuri wrote about something similar today." n 1h "Huh?" @@ -517,7 +476,7 @@ label ch2_n_end: mc "Well, you're definitely right." mc "At least, I can relate to that." mc "And I'm sure a lot of other people can, too." - if chibi_n.appeal >= 2: + if get_appeal("natsuki") >= 2: n 4h "You know..." n "I'm glad that you can appreciate this kind of writing..." n 4q "I mean...I know I was talking about that yesterday." @@ -542,14 +501,14 @@ label ch2_n_end: label ch3_n_end: $ n_read3 = True - if chibi_n.appeal >= 3: + if get_appeal("natsuki") >= 3: jump ch3_n_end_special - call showpoem (poem_n3) + $ poem_db.show_poem("poem_n3") n 2a "Yeah..." n "I felt like I kept writing about negative things, so I wanted to write something with a nice message for once." n 2z "Besides...the beach is awesome!" n 2j "Kinda hard to write anything negative about the beach." - if not y_read3 or chibi_y.appeal >= 3: + if not y_read3 or get_appeal("yuri") >= 3: mc "So you decided to write about the beach first, and then came up with the message later?" n 2c "Yeah, well..." n "It's only because of what happened yesterday." @@ -578,7 +537,7 @@ label ch3_n_end: return label ch3_n_end_special: - call showpoem (poem_n3b) + $ poem_db.show_poem("poem_n3b") n 1q "..." n "...Why are you looking at me like that?" n "If you don't like it, then just say it." @@ -634,7 +593,7 @@ label ch3_n_end_special: return label ch1_s_end: - call showpoem (poem_s1) + $ poem_db.show_poem("poem_s1") mc "Sayori..." mc "This is just a guess, but..." mc "Did you wait until this morning to write this?" @@ -668,7 +627,7 @@ label ch1_s_end: return label ch2_s_end: - call showpoem (poem_s2) + $ poem_db.show_poem("poem_s2") mc "Holy crap..." mc "Sayori, did you really write this?" s 2j "Of course I did!" @@ -705,7 +664,7 @@ label ch3_s_end: return label ch1_m_end: - call showpoem (poem_m1) + $ poem_db.show_poem("poem_m1") label ch1_m_end2: m 1a "So...what do you think?" @@ -737,7 +696,7 @@ label ch1_m_end2: return label ch2_m_end: - call showpoem (poem_m2) + $ poem_db.show_poem("poem_m2") mc "Hm..." mc "It's even more abstract than your last one, huh?" m 5 "Ahaha..." @@ -770,7 +729,7 @@ label ch2_m_end: return label ch3_m_end: - call showpoem (poem_m3) + $ poem_db.show_poem("poem_m3") m 1a "You know..." m "I feel like learning and looking for answers are the sorts of things that give life meaning." m 1e "Not to get too philosophical or anything..." @@ -923,7 +882,7 @@ label ch1_n_good: label ch2_n_bad: # This if statement checks if Natsuki's opinion on your first poem was not good. - if n_poemappeal[0] < 0: + if poemappeal["natsuki"][0] < 0: n "...Hm." n 2k "Well, I can admit that it's better than the last one." n "It's nice to see that you're putting in some effort." @@ -978,7 +937,7 @@ label ch2_n_bad: return label ch2_n_med: - if n_poemappeal[0] < 0: + if poemappeal["natsuki"][0] < 0: n "...Hm." n 2k "Well, I can admit that it's better than the last one." n "It's nice to see that you're putting in some effort." @@ -999,7 +958,7 @@ label ch2_n_med: n "...Oh, yeah, I guess I'm supposed to show you my poem." n "Here." return - elif n_poemappeal[0] == 0: + elif poemappeal["natsuki"][0] == 0: n "...Hm." n 2k "Well, it's not really any worse than your last one." n "But I can't really say it's any better, either." @@ -1025,7 +984,7 @@ label ch2_n_med: label ch2_n_good: # This if statement checks if Natsuki's opinion on your first poem was not good. - if n_poemappeal[0] != 1: + if poemappeal["natsuki"][0] != 1: n 1h "..." "Natsuki reads my poem." "She keeps glancing at me, then back at the poem." @@ -1199,14 +1158,14 @@ label ch2_n_good: return label ch3_n_bad: - if n_poemappeal[0] < 0 and n_poemappeal[1] < 0: + if poemappeal["natsuki"][0] < 0 and poemappeal["natsuki"][1] < 0: label ch3_n_bad12_shared: n 5x "Yeah, no thanks." mc "Eh? You didn't even--" n 5w "{i}Next!{/i}" $ skip_poem = True return - elif n_poemappeal[0] < 0 or n_poemappeal[1] < 0: + elif poemappeal["natsuki"][0] < 0 or poemappeal["natsuki"][1] < 0: n "..." n 2c "...Meh." n "I guess you really haven't learned anything after all." @@ -1262,9 +1221,9 @@ label ch3_n_bad: label ch3_n_med: # This if statement checks in Natsuki hated both your 1st and 2nd poem. - if n_poemappeal[0] < 0 and n_poemappeal[1] < 0: + if poemappeal["natsuki"][0] < 0 and poemappeal["natsuki"][1] < 0: jump ch3_n_bad12_shared - elif n_poemappeal[1] != 0: + elif poemappeal["natsuki"][1] != 0: n "..." n 2k "...This one's alright." mc "Alright?" @@ -1286,10 +1245,10 @@ label ch3_n_med: jump ch3_n_shared label ch3_n_good: - if n_poemappeal[0] < 0 and n_poemappeal[1] < 0: + if poemappeal["natsuki"][0] < 0 and poemappeal["natsuki"][1] < 0: jump ch3_n_bad12_shared - elif n_poemappeal[0] > 0 and n_poemappeal[1] > 0: + elif poemappeal["natsuki"][0] > 0 and poemappeal["natsuki"][1] > 0: n 1l "Let's see, let's see!" mc "You're certainly enthusiastic today." n 2j "Of course." @@ -1377,7 +1336,7 @@ label ch3_n_good: n "I don't want you to...look at my face right now." mc "Okay, I will." return - elif n_poemappeal[0] > 0 or n_poemappeal[1] > 0: + elif poemappeal["natsuki"][0] > 0 or poemappeal["natsuki"][1] > 0: jump ch2_n_good_sharedwithch3 else: n "..." @@ -1553,7 +1512,7 @@ label ch2_s_bad: return label ch2_s_med: - if s_poemappeal[0] < 0: + if poemappeal["sayori"][0] < 0: s "..." s 4x "Ooh!" s "I like this one, [player]!" @@ -1571,7 +1530,7 @@ label ch2_s_med: mc "Yeah, maybe..." mc "Honestly, I don't even know what kind of writing you like in the first place." jump ch2_s_shared - elif s_poemappeal[0] == 0: + elif poemappeal["sayori"][0] == 0: s "..." s 4x "Ooh!" s "I like this one, [player]!" @@ -1607,7 +1566,7 @@ label ch2_s_med: label ch2_s_good: # This if statement checks if Sayori was OK or hated your first poem. - if s_poemappeal[0] < 1: + if poemappeal["sayori"][0] < 1: s 1n "..." s "...Oh my goodness!" s 4r "This is sooooo good, [player]!" @@ -1730,11 +1689,11 @@ label ch2_s_good: return label ch3_s_bad: - # This variable sets the character you wrote your poem to as Yuri. + # This variable sets the character you wrote your poem to as Yuri temporarily. $ currentname = "Yuri" # This if statement checks if Natsuki liked your poem more than Yuri to - # set 'currentname' to her. - if n_poemappeal[2] > y_poemappeal[2]: + # set the variable to Natsuki instead. + if poemappeal["natsuki"][2] > poemappeal["yuri"][2]: $ currentname = "Natsuki" s "..." s 1k "...Hm." @@ -1783,7 +1742,7 @@ label ch3_s_med: jump ch3_s_bad label ch3_s_good: - # This if statement checks if you didn't write your 1st and 2nd poems for Sayori. + # This if statement checks if the first or second poem didn't appeal to Sayori. if poemwinner[0] != "sayori" and poemwinner[1] != "sayori": jump ch3_s_bad s 1d "..." @@ -1960,7 +1919,7 @@ label ch1_y_good: jump ch1_y_shared label ch2_y_bad: - if y_poemappeal[0] < 0: + if poemappeal["yuri"][0] < 0: y "..." y 2h "Um..." y "...Are you still mad at me?" @@ -2043,7 +2002,7 @@ label ch2_y_bad: return label ch2_y_med: - if y_poemappeal[0] <= 0: + if poemappeal["yuri"][0] <= 0: y 1a "Let's see what you've written for today." y "..." y "Mm..." @@ -2069,7 +2028,7 @@ label ch2_y_med: jump ch2_y_shared label ch2_y_good: - if y_poemappeal[0] < 1: + if poemappeal["yuri"][0] < 1: y 1a "Let's see what you've written for today." y "..." y 2e "......" @@ -2129,7 +2088,7 @@ label ch2_y_good: jump ch2_y_good_shared label ch3_y_bad: - if y_poemappeal[0] < 0 and y_poemappeal[1] < 0: + if poemappeal["yuri"][0] < 0 and poemappeal["yuri"][1] < 0: label ch3_y_bad12_shared: y 4b "..." "Yuri doesn't look too enthusiastic about spending time with me..." @@ -2137,7 +2096,7 @@ label ch3_y_bad: "But I should leave her be for now." $ skip_poem = True return - elif y_poemappeal[1] < 0 or y_poemappeal[0] < 0: + elif poemappeal["yuri"][1] < 0 or poemappeal["yuri"][0] < 0: y 1i "..." y "...I see." y "I think you're improving at writing in general, [player]." @@ -2235,9 +2194,9 @@ label ch3_y_bad: label ch3_y_med: - if y_poemappeal[0] < 0 and y_poemappeal[1] < 0: + if poemappeal["yuri"][0] < 0 and poemappeal["yuri"][1] < 0: jump ch3_y_bad12_shared - elif y_poemappeal[0] < 1 or y_poemappeal[1] < 1: + elif poemappeal["yuri"][0] < 1 or poemappeal["yuri"][1] < 1: y "..." y 1a "Well done, [player]." y "You've definitely improved your writing over the course of these few days." @@ -2273,9 +2232,9 @@ label ch3_y_med: jump ch3_y_shared label ch3_y_good: - if y_poemappeal[0] < 0 and y_poemappeal[1] < 0: + if poemappeal["yuri"][0] < 0 and poemappeal["yuri"][1] < 0: jump ch3_y_bad12_shared - if y_poemappeal[1] < 1: + if poemappeal["yuri"][1] == 1: y "..." y 2u "[player]..." y "...This is wonderful." @@ -2431,7 +2390,7 @@ label ch1_m_start: m 2a "...Mhm!" # This variable and call expression statement sets the 'nextscene' variable to # the character you wrote your poem to and calls it. - $ nextscene = "m_" + poemwinner[0] + "_" + str(eval("chibi_" + poemwinner[0][0] + ".appeal")) + $ nextscene = get_monika_scene(0) call expression nextscene mc "I'm sure I'll end up trying different things a lot." @@ -2481,7 +2440,7 @@ label ch2_m_start: "I give my poem to Monika." m "..." m "...Alright!" - $ nextscene = "m_" + poemwinner[1] + "_" + str(eval("chibi_" + poemwinner[1][0] + ".appeal")) + $ nextscene = get_monika_scene(1) call expression nextscene m 1a "But anyway..." @@ -2509,7 +2468,7 @@ label ch3_m_start: mc "Sure..." "I let Monika take the poem I'm holding in my hands." m "..." - $ nextscene = "m_" + poemwinner[2] + "_" + str(eval("chibi_" + poemwinner[2][0] + ".appeal")) + $ nextscene = get_monika_scene(2) call expression nextscene m 1a "Anyway...!" diff --git a/game/screens.rpy b/game/screens.rpy index 71449bea..e83d6c01 100644 --- a/game/screens.rpy +++ b/game/screens.rpy @@ -1227,12 +1227,6 @@ screen template_preferences(): yes_action=[Hide("confirm"), ToggleField(persistent, "uncensored_mode")], no_action=Hide("confirm") )) - textbutton _("Let's Play Mode") action If(persistent.lets_play, - ToggleField(persistent, "lets_play"), - [ToggleField(persistent, "lets_play"), Show("dialog", - message="You have enabled Let's Play Mode.\nThis mode allows you to skip content that\ncontains sensitive information or apply alternative\nstory options.\n\nThis setting will be dependent on the modder\nif they programmed these checks in their story.", - ok_action=Hide("dialog") - )]) vbox: style_prefix "name" @@ -2088,7 +2082,7 @@ screen choose_language(): textbutton renpy.translate_string(_("{#in language font}Select"), local_lang): style "confirm_button" - action [Language(chosen_lang), Return()] + action [Language(chosen_lang), SetField(persistent, "has_chosen_language", True), Return()] translate None strings: old "{#language name and font}" diff --git a/game/script.rpy b/game/script.rpy index 1f2c7772..d51ecc5e 100644 --- a/game/script.rpy +++ b/game/script.rpy @@ -162,7 +162,7 @@ label start: # # This if statement calls either a special poem response game or play # # as normal. - # if chibi_y.appeal >= 3: + # if get_appeal("yuri") >= 3: # call poemresponse_start2 # else: # call poemresponse_start diff --git a/requirements.txt b/requirements.txt index 32576324..bce44bc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -pypresence \ No newline at end of file +pypresence +coverage \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/act_two/__init__.py b/tests/act_two/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/act_two/test_console.py b/tests/act_two/test_console.py new file mode 100644 index 00000000..9925e199 --- /dev/null +++ b/tests/act_two/test_console.py @@ -0,0 +1,88 @@ +from tests.utils.base_test import DDLCTest + +class TestConsole(DDLCTest): + def test_console_creation(self): + self.start_test_time() + + # Setup + console = self.create_console() + + # Assert + self.assertEqual(console.console_delay, 0.5) + self.assertEqual(console.console_cps, 30) + self.assertEqual(console.max_log_history, 5) + self.assertEqual(len(console.console_history), 0) + + self.end_test_time() + + def test_console_call(self): + self.start_test_time() + + # Setup + console = self.create_console() + input_text = 'os.remove("characters/yuri.chr")' + output_text = "yuri.chr deleted successfully." + + # Test + console(input_text, output_text) + + # Assert + self.assertEqual(len(console.console_history), 1) + self.assertIn(input_text, console.console_history) + self.assertEqual(console.console_history[input_text], output_text) + + self.end_test_time() + + def test_console_with_limits(self): + self.start_test_time() + + # Setup + console = self.create_console(max_log_history=2) + input_texts = [ + 'os.remove("characters/monika.chr")', + 'os.remove("characters/natsuki.chr")', + 'os.remove("characters/sayori.chr")', + ] + output_texts = [ + "monika.chr deleted successfully.", + "natsuki.chr deleted successfully.", + "sayori.chr deleted successfully.", + ] + + # Test + for input_text, output_text in zip(input_texts, output_texts): + console(input_text, output_text) + + # Assert + self.assertEqual(len(console.console_history), 2) + self.assertNotIn('os.remove("characters/monika.chr")', console.console_history) + self.assertIn('os.remove("characters/natsuki.chr")', console.console_history) + self.assertIn('os.remove("characters/sayori.chr")', console.console_history) + + self.assertEqual( + console.console_history['os.remove("characters/natsuki.chr")'], + "natsuki.chr deleted successfully.", + ) + self.assertEqual( + console.console_history['os.remove("characters/sayori.chr")'], + "sayori.chr deleted successfully.", + ) + + self.end_test_time() + + def test_clear_console_history(self): + self.start_test_time() + + # Setup + console = self.create_console() + input_text = 'os.remove("characters/monika.chr")' + output_text = "monika.chr deleted successfully." + + # Test + console(input_text, output_text) + console.console_history.clear() + + # Assert + self.assertEqual(len(console.console_history), 0) + + self.end_test_time() diff --git a/tests/act_two/test_glitchtext.py b/tests/act_two/test_glitchtext.py new file mode 100644 index 00000000..60049d70 --- /dev/null +++ b/tests/act_two/test_glitchtext.py @@ -0,0 +1,21 @@ +from tests.utils.base_test import DDLCTest + +class TestGlitchtext(DDLCTest): + def test_generate_glitchtext(self): + self.start_test_time() + + # Test + text = self.generate_glitchtext(20) + + # Assert + self.assertEqual(len(text), 20) + + def test_generate_glitchtext_with_different_lengths(self): + self.start_test_time() + + # Test with different lengths + for length in [10, 50, 100]: + text = self.generate_glitchtext(length) + self.assertEqual(len(text), length) + + self.end_test_time() diff --git a/tests/poem_game/__init__.py b/tests/poem_game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/poem_game/test_poemgame.py b/tests/poem_game/test_poemgame.py new file mode 100644 index 00000000..f1c59c9c --- /dev/null +++ b/tests/poem_game/test_poemgame.py @@ -0,0 +1,134 @@ +from game.poem_game.py.poemgame_ren import PoemGame +from tests.utils.base_test import DDLCTest +from tests.utils.mocks import ( + _fake_renpy_store, +) + + +class PoemGameTest(DDLCTest): + def test_poemgame_initialization(self): + """ + Test the initialization of the PoemGame class. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + + # Assert + self.assertIsInstance(poem_game, PoemGame) + self.assertFalse(poem_game.played_baa) + self.assertFalse(poem_game.poemgame_glitch) + self.assertEqual(poem_game.poem_progress, 1) + self.assertTrue(poem_game.testing) + + self.end_test_time() + + def test_poemgame(self): + """ + Test starting the PoemGame. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + _fake_renpy_store.chapter = 1 # type: ignore + + # Test + poem_game.start() + poem_game.finish() + + # Assert + self.assertEqual(poem_game.poem_progress, 21) + + self.end_test_time() + + def test_poemgame_start_act_one(self): + """ + Test starting the PoemGame in Act One. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + _fake_renpy_store.chapter = 0 # type: ignore + + # Test + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + _fake_renpy_store.chapter = 1 # type: ignore + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + _fake_renpy_store.chapter = 2 # type: ignore + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + self.end_test_time() + + def test_poemgame_start_act_two(self): + """ + Test starting the PoemGame in Act Two. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + _fake_renpy_store.chapter = 0 # type: ignore + _fake_renpy_store.persistent.playthrough = 2 + + # Test + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + _fake_renpy_store.chapter = 1 # type: ignore + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + _fake_renpy_store.chapter = 2 # type: ignore + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + def test_poemgame_start_act_three(self): + """ + Test starting the PoemGame in Act Three. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + _fake_renpy_store.persistent.playthrough = 3 # type: ignore + + # Test + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + def test_poemgame_reset(self): + """ + Test resetting the PoemGame. + """ + self.start_test_time() + + # Setup + poem_game = self.create_poem_game() + poem_game.start() + poem_game.finish() + self.assertEqual(poem_game.poem_progress, 21) + + # Test + poem_game.reset() + + # Assert + self.assertFalse(poem_game.played_baa) + self.assertFalse(poem_game.poemgame_glitch) + self.assertEqual(poem_game.poem_progress, 1) + + self.end_test_time() diff --git a/tests/poem_game/test_poemgame_chibi.py b/tests/poem_game/test_poemgame_chibi.py new file mode 100644 index 00000000..41325231 --- /dev/null +++ b/tests/poem_game/test_poemgame_chibi.py @@ -0,0 +1,217 @@ +from game.poem_game.py.poemgame_chibi_ren import Chibi, ChibiDB, ChibiTransform +from tests.utils.base_test import DDLCTest + + +class ChibiTest(DDLCTest): + def test_chibi_creation(self): + """ + Test the creation of a Chibi character. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + + # Assert + self.assertIsInstance(chibi, Chibi) + self.assertEqual(chibi.name, "bronya") + + self.end_test_time() + + def test_chibi_transform_creation(self): + """ + Test the creation of a ChibiTransform instance. + """ + self.start_test_time() + + # Setup + chibi_transform = self.create_chibi_transform() + + # Assert + self.assertIsInstance(chibi_transform, ChibiTransform) + + self.end_test_time() + + def test_chibi_add_points(self): + """ + Test adding points to a Chibi character. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + + # Test + chibi.add_points(10) + + # Assert + self.assertEqual(chibi.charPointTotal, 10) + + self.end_test_time() + + def test_positive_appeal(self): + """ + Test the appeal calculation for a Chibi character. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + chibi.add_points(50) + + # Test + appeal = chibi.calculate_appeal() + + # Assert + self.assertEqual(appeal, 1) + + self.end_test_time() + + def test_negative_appeal(self): + """ + Test the appeal calculation for a Chibi character with negative points. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + chibi.add_points(20) + + # Test + appeal = chibi.calculate_appeal() + + # Assert + self.assertEqual(appeal, -1) + + self.end_test_time() + + def test_neutral_appeal(self): + """ + Test the appeal calculation for a Chibi character with neutral points. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + chibi.add_points(30) + + # Test + appeal = chibi.calculate_appeal() + + # Assert + self.assertEqual(appeal, 0) + + self.end_test_time() + + def test_chibi_reset(self): + """ + Test resetting a Chibi character. + """ + self.start_test_time() + + # Setup + chibi = self.create_chibi("bronya") + chibi.add_points(10) + + # Test + chibi.reset() + + # Assert + self.assertEqual(chibi.charPointTotal, 0) + + self.end_test_time() + + +class ChibiDBTest(DDLCTest): + def test_chibi_db_creation(self): + """ + Test the creation of a ChibiDB instance. + """ + self.start_test_time() + + # Setup + chibi_db = self.create_chibi_db() + + # Assert + self.assertIsInstance(chibi_db, ChibiDB) + + self.end_test_time() + + def test_chibi_db_add_character(self): + """ + Test adding a character to the ChibiDB. + """ + self.start_test_time() + + # Setup + chibi_db = self.create_chibi_db() + + # Test + chibi_db.add_chibi("bronya") + + # Assert + self.assertEqual(len(chibi_db.chibis), 1) + self.assertEqual(chibi_db.chibis[0].name, "bronya") + + self.end_test_time() + + def test_get_chibi(self): + """ + Test retrieving a Chibi character by name from the ChibiDB. + """ + self.start_test_time() + + # Setup + chibi_db = self.create_chibi_db() + chibi_db.add_chibi("bronya") + chibi_db.add_chibi("carlotta") + + # Test + chibi = chibi_db.get_chibi("bronya") + + # Assert + self.assertEqual(len(chibi_db.chibis), 2) + self.assertEqual(chibi.name, "bronya") + + self.end_test_time() + + def test_get_chibi_not_found(self): + """ + Test retrieving a Chibi character that does not exist in the ChibiDB. + """ + self.start_test_time() + + # Setup + chibi_db = self.create_chibi_db() + chibi_db.add_chibi("bronya") + + # Assert + with self.assertRaises(ValueError): + chibi_db.get_chibi("bronie") + + self.end_test_time() + + def test_chibi_db_reset(self): + """ + Test resetting the ChibiDB. + """ + self.start_test_time() + + # Setup + chibi_db = self.create_chibi_db() + chibi_db.add_chibi("bronya") + chibi_db.add_chibi("carlotta") + bronya = chibi_db.get_chibi("bronya") + carlotta = chibi_db.get_chibi("carlotta") + bronya.add_points(10) + carlotta.add_points(20) + + # Test + chibi_db.reset() + + # Assert + self.assertEqual(len(chibi_db.chibis), 2) + self.assertEqual(bronya.charPointTotal, 0) + self.assertEqual(carlotta.charPointTotal, 0) + + self.end_test_time() diff --git a/tests/poem_game/test_poemwords.py b/tests/poem_game/test_poemwords.py new file mode 100644 index 00000000..9bb738b9 --- /dev/null +++ b/tests/poem_game/test_poemwords.py @@ -0,0 +1,116 @@ +from game.poem_game.py.poemwords_ren import PoemWord, PoemWordDB +from tests.utils.base_test import DDLCTest + +class TestPoemWordDB(DDLCTest): + def test_poemwords_creation(self): + self.start_test_time() + + # Setup + db = self.create_poem_words_db() + + # Assert + self.assertIsInstance(db, PoemWordDB) + self.assertEqual(len(db.words), 0) + + self.end_test_time() + + def test_poemwords_adding(self): + self.start_test_time() + + # Setup + db = self.create_poem_words_db() + db.add_word("happiness", 3, 2, 1) + + # Assert + self.assertEqual(len(db.words), 1) + self.assertEqual(db.words[0].word, "happiness") + self.assertEqual(db.words[0].sPoint, 3) + self.assertEqual(db.words[0].nPoint, 2) + self.assertEqual(db.words[0].yPoint, 1) + + self.end_test_time() + + def test_poemwords_get_words(self): + self.start_test_time() + + # Setup + db = self.create_poem_words_db() + db.add_word("happiness", 3, 2, 1) + db.add_word("sadness", 1, 3, 2) + + # Test + words = db.get_words() + + # Assert + self.assertEqual(len(words), 2) + self.assertIn("happiness", [word.word for word in words]) + self.assertIn("sadness", [word.word for word in words]) + + self.end_test_time() + + def test_poemwords_get_words_str(self): + self.start_test_time() + + # Setup + db = self.create_poem_words_db() + db.add_word("happiness", 3, 2, 1) + db.add_word("sadness", 1, 3, 2) + + # Test + words_str = db.get_words_str() + + # Assert + self.assertEqual(len(words_str), 2) + self.assertIn("happiness", words_str) + self.assertIn("sadness", words_str) + + self.end_test_time() + + def test_poemwords_get_word(self): + self.start_test_time() + + # Setup + db = self.create_poem_words_db() + db.add_word("happiness", 3, 2, 1) + db.add_word("sadness", 1, 3, 2) + db.add_word("anger", 2, 1, 3) + + # Test + word = db.get_word("sadness") + + # Assert + self.assertEqual(len(db.words), 3) + self.assertIsNotNone(word) + self.assertEqual(word.word, "sadness") + self.assertEqual(word.sPoint, 1) + self.assertEqual(word.nPoint, 3) + self.assertEqual(word.yPoint, 2) + + self.end_test_time() + +class TestPoemWord(DDLCTest): + def test_poemword_creation(self): + self.start_test_time() + + # Test + word = PoemWord("joy", 5, 3, 2) + + # Assert + self.assertIsNotNone(word) + self.assertEqual(word.word, "joy") + self.assertEqual(word.sPoint, 5) + self.assertEqual(word.nPoint, 3) + self.assertEqual(word.yPoint, 2) + + self.end_test_time() + + def test_poemword_str(self): + self.start_test_time() + + # Test + word = PoemWord("love", 4, 1, 3) + + # Assert + self.assertEqual(word.__str__(), "love") + + self.end_test_time() \ No newline at end of file diff --git a/tests/poem_responses/__init__.py b/tests/poem_responses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/poem_responses/test_poemresponses.py b/tests/poem_responses/test_poemresponses.py new file mode 100644 index 00000000..2d0769cb --- /dev/null +++ b/tests/poem_responses/test_poemresponses.py @@ -0,0 +1,144 @@ +from unittest import mock +from game.poem_responses.py.poems_ren import PoemAuthor, PoemResponseDB +from tests.utils.base_test import DDLCTest + + +class PoemResponsesTest(DDLCTest): + def test_poem_responses_initialization(self): + """ + Test the initialization of the poem responses. + """ + self.start_test_time() + + # Setup + with mock.patch("renpy.text.text.Text"): + poem_db = self.create_poem_db() + + # Assert + self.assertIsInstance(poem_db, PoemResponseDB) + self.assertEqual(len(poem_db.poems), 0) + + self.end_test_time() + + def test_poem_response_author_creation(self): + """ + Test the creation of a poem response with an author. + """ + self.start_test_time() + + # Test + author_b = PoemAuthor("bronya", music="audio/bronya_music.ogg") + + # Assert + self.assertIsInstance(author_b, PoemAuthor) + self.assertEqual(author_b.name, "bronya") + self.assertEqual(author_b.music, "audio/bronya_music.ogg") + + self.end_test_time() + + def test_poem_response_db_add_poem(self): + """ + Test adding a poem to the PoemResponseDB. + """ + self.start_test_time() + + # Setup + poem_db = self.create_poem_db() + author_b = PoemAuthor("bronya", music="audio/bronya_music.ogg") + poem_title = "Bronya's Poem" + poem_text = "This is a poem by Bronya." + + expected_poem_text = f"{poem_title}\n\n{poem_text}" + + # Test + poem_db.add_poem("b_poem1", author_b, poem_title, poem_text) + poem = poem_db.get_poem("b_poem1") + poem.text = expected_poem_text + poem.author = author_b.name + + # Assert + self.assertEqual(len(poem_db.poems), 1) + self.assertEqual(poem.text, expected_poem_text) # type: ignore + self.assertEqual(poem.author, author_b.name) # type: ignore + + self.end_test_time() + + def test_poem_response_db_get_poem(self): + """ + Test retrieving a poem from the PoemResponseDB. + """ + self.start_test_time() + + # Setup + poem_db = self.create_poem_db() + author_b = PoemAuthor("bronya", music="audio/bronya_music.ogg") + poem_title = "Bronya's Poem" + poem_text = "This is a poem by Bronya." + expected_poem_text = f"{poem_title}\n\n{poem_text}" + + poem_db.add_poem("b_poem1", author_b, poem_title, poem_text) + + # Test + poem = poem_db.get_poem("b_poem1") + poem.text = expected_poem_text + poem.author = author_b.name + + # Assert + self.assertIsNotNone(poem) + self.assertEqual(poem.text, f"{poem_title}\n\n{poem_text}") # type: ignore + self.assertEqual(poem.author, author_b.name) # type: ignore + + self.end_test_time() + + def test_poem_response_db_get_poem_not_found(self): + """ + Test retrieving a poem that does not exist in the PoemResponseDB. + """ + self.start_test_time() + + # Setup + poem_db = self.create_poem_db() + + # Test + with self.assertRaises(ValueError): + poem_db.get_poem("non_existent_poem") + + self.end_test_time() + + def test_poem_response_db_show_poem(self): + """ + Test showing a poem from the PoemResponseDB. + """ + self.start_test_time() + + # Setup + poem_db = self.create_poem_db() + author_b = PoemAuthor("bronya", music="audio/bronya_music.ogg") + poem_title = "Bronya's Poem" + poem_text = "This is a poem by Bronya." + + poem_db.add_poem("b_poem1", author_b, poem_title, poem_text) + + # Test + poem_db.show_poem("b_poem1", testing=True) + + # Assert + # Check if no ValueError is raised + self.assertTrue(True) + + self.end_test_time() + + def test_poem_response_db_show_poem_not_found(self): + """ + Test showing a poem that does not exist in the PoemResponseDB. + """ + self.start_test_time() + + # Setup + poem_db = self.create_poem_db() + + # Test + with self.assertRaises(ValueError): + poem_db.show_poem("non_existent_poem") + + self.end_test_time() diff --git a/tests/utils/base_test.py b/tests/utils/base_test.py new file mode 100644 index 00000000..760f9d8b --- /dev/null +++ b/tests/utils/base_test.py @@ -0,0 +1,116 @@ +import time +from unittest import TestCase +from .renpy_mock import install_mock + +# Install the RenPyMock to replace the Ren'Py module in sys.modules +install_mock() + + +class DDLCTest(TestCase): + """ + Base class for all DDLC `_ren.py` tests. + """ + + def start_test_time(self): + """ + Starts the timer for the test. + """ + self.start_time = time.time() + + def end_test_time(self): + """ + Ends the timer for the test and prints the time taken. + """ + end_time = time.time() + print(f"{self.id()} took {end_time - self.start_time:.2f} seconds") + + def create_console(self, **kwargs): + """ + Helper method to create a Console instance with the given parameters. + + :param kwargs: Parameters to pass to the Console constructor. + :return: An instance of Console. + """ + from game.act_two.py.console_ren import Console + + defaults = { + "console_delay": 0.5, + "console_cps": 30, + "max_log_history": 5, + "testing": True, + } + defaults.update(kwargs) + return Console(**defaults) + + def generate_glitchtext(self, length: int): + """ + Helper method to generate glitch text for testing. + + :param length: Length of the glitch text to generate. + :return: A string of glitch text. + """ + from game.act_two.py.glitchtext_ren import glitchtext + + return glitchtext(length) + + def create_poem_words_db(self): + """ + Helper method to create a PoemWordsDB instance for testing. + + :return: An instance of PoemWordsDB. + """ + from game.poem_game.py.poemwords_ren import PoemWordDB + + return PoemWordDB() + + def create_chibi_db(self): + """ + Helper method to create a ChibiDB instance for testing. + + :return: An instance of ChibiDB. + """ + from game.poem_game.py.poemgame_chibi_ren import ChibiDB + + return ChibiDB() + + def create_chibi(self, name: str): + """ + Helper method to create a Chibi character for testing. + + :param name: Name of the Chibi character. + :return: An instance of Chibi. + """ + from game.poem_game.py.poemgame_chibi_ren import Chibi + + return Chibi(name) + + def create_chibi_transform(self): + """ + Helper method to create a ChibiTransform instance for testing. + + :return: An instance of ChibiTransform. + """ + from game.poem_game.py.poemgame_chibi_ren import ChibiTransform + + return ChibiTransform() + + def create_poem_game(self): + """ + Helper method to create a PoemGame instance for testing. + + :param testing: If True, bypasses Ren'Py functions and screens for testing purposes. + :return: An instance of PoemGame. + """ + from game.poem_game.py.poemgame_ren import PoemGame + + return PoemGame(testing=True) + + def create_poem_db(self): + """ + Helper method to create a PoemDB instance for testing. + + :return: An instance of PoemDB. + """ + from game.poem_responses.py.poems_ren import PoemResponseDB + + return PoemResponseDB() diff --git a/tests/utils/mocks.py b/tests/utils/mocks.py new file mode 100644 index 00000000..66e7b11f --- /dev/null +++ b/tests/utils/mocks.py @@ -0,0 +1,280 @@ +# This file is part of the tests for the Mod Template +# It contains mock implementations of Ren'Py specific classes and functions +# to allow for testing without the need for a full Ren'Py environment. + +import random +import sys + +_fake_store_dicts = {} +_fake_store_modules = {} +_fake_initialized_store_dicts = set() + + +class _FakeRenpyPersistent(object): + """ + A fake persistent store to mimic Ren'Py's persistent store. + This is used to simulate the persistent data that Ren'Py would normally handle i.e. persistent.X = + """ + + def __setstate__(self, data): + """ + Mimics the behavior of setting state in a Ren'Py persistent store. + + :param data: The data to set in the persistent store. + """ + self.__dict__.update(data) + + def __getstate__(self) -> object: + """ + Mimics the behavior of getting state in a Ren'Py persistent store. + + :return object: The current state of the persistent store. + """ + return self.__dict__ + + def __getattr__(self, attr): + """ + Mimics the behavior of getting attributes in a Ren'Py persistent store. + + :param attr: The attribute to get. + :return any | None: The value of the attribute or None if it doesn't exist. + """ + if attr.startswith("__") and attr.endswith("__"): + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{attr}'" + ) + + return None + + def _hasattr(self, field_name): + """ + Mimics the behavior of checking if an attribute exists in a Ren'Py persistent store. + + :param field_name: The name of the field to check. + :return bool: True if the field exists, False otherwise. + """ + return field_name in self.__dict__ + + +class _FakeRenpyStoreModule(object): + def __reduce__(self): + """ + Mimics the behavior of reducing a Ren'Py store module for serialization. + This is used to ensure that the Ren'Py store module can be serialized correctly. + + :return: A tuple that can be used to reconstruct the module. + """ + return (_fake_get_store_module, (self.__name__,)) # type: ignore + + def __init__(self, d): + """ + Initializes the fake Ren'Py store module with a dictionary. + + :param d: The dictionary to use as the store module's namespace. + """ + object.__setattr__(self, "__dict__", d) + + def __setattr__(self, key, value): + """ + Mimics the behavior of setting attributes in a Ren'Py store. + + :param key: The name of the attribute to set. + :param value: The value to set for the attribute. + """ + self.__dict__[key] = value + + def __delattr__(self, key): + """ + Mimics the behavior of deleting attributes in a Ren'Py store. + + :param key: The name of the attribute to delete. + :raises AttributeError: If the attribute does not exist. + """ + if key in self.__dict__: + del self.__dict__[key] + + raise AttributeError(f"'{self.__name__}' object has no attribute '{key}'") # type: ignore + + +class _FakeRenpyStoreDict(dict): + """ + A fake Ren'Py store dictionary to mimic Ren'Py's store behavior. + This is used to simulate the Ren'Py store dictionary that holds game state. + """ + + def __init__(self): + self.old = {} + + +class _FakeRenpyRandom: + """ + A fake random module to mimic Ren'Py's random behavior. + This is used to simulate random choices and integer generation in tests. + """ + + def choice(self, seq): + return random.choice(seq) + + def randint(self, a, b): + return random.randint(a, b) + + def random(self): + return random.random() + + +class _FakeRenpyEasy: + """ + A fake easy module to mimic Ren'Py's easy behavior. + """ + + @staticmethod + def displayable_or_none(displayable): + """ + Mock implementation of renpy.easy.displayable_or_none. + + :param displayable: The displayable to check. + :return: The displayable if valid, None otherwise. + """ + if displayable is None: + return None + # Return a simple mock object instead of None + return _FakeRenpyDisplayable(displayable) + + +class _FakeRenpyDisplayable: + """ + A fake displayable to mimic Ren'Py's displayable behavior. + """ + + def __init__(self, path): + self.path = path + + +class _FakeRenpyNull: + """ + A fake Null displayable to mimic Ren'Py's Null behavior. + """ + + def __init__(self): + self.path = "null" + + +class _FakeRenpyTextTextText: + """ + A fake Ren'Py Text class to mimic the behavior of renpy.text.text.Text. + This is used to simulate text display in tests. + """ + + def __init__(self, text, style=None, **kwargs): + self._text = text + self.style = style + for key, value in kwargs.items(): + setattr(self, key, value) + + @property + def text(self): + return self._text + + @text.setter + def text(self, value): + self._text = value + + def __repr__(self): + return f"" + + +def _fake_renpy_(s): + """ + A fake Ren'Py function to mimic the behavior of `renpy._()` for translations. + This is used to simulate the translation of text in tests. + + :param s: The string to translate. + :return: The translated string (in this case, just the original string). + """ + return s + + +def _fake_renpy_window_hide(): + """ + Mock method to simulate hiding the window. + """ + print("Window hidden.") + pass + + +def _fake_get_store_module(name): + """ + Retrieves a fake Ren'Py store module by name. + + :param name: The name of the store module to retrieve. + + :return module: The fake Ren'Py store module. + :raises ImportError: If the store module does not exist. + """ + return sys.modules[name] + + +def _fake_create_store(name): + """ + Creates a fake Ren'Py store module. + + :param name: The name of the store module to create. + """ + parent, _, var = name.rpartition(".") + + if parent: + _fake_create_store(parent) + + name = str(name) + + if name in _fake_initialized_store_dicts: + return + + _fake_initialized_store_dicts.add(name) + + # Create the dict. + d = _fake_store_dicts.setdefault(name, _FakeRenpyStoreDict()) + + pyname = name + d.update(__name__=pyname, __package__=pyname) + eval("1", d) + + if name in _fake_store_modules: + sys.modules[pyname] = _fake_store_modules[name] + else: + _fake_store_modules[name] = sys.modules[pyname] = _FakeRenpyStoreModule(d) # type: ignore + + if parent: + _fake_store_dicts[parent][var] = sys.modules[pyname] + + +## Create the fake store module. +_fake_create_store("store") + +# Setup the fake Ren'Py random module. +_fake_renpy_random = _FakeRenpyRandom() + +# Setup the fake Ren'Py store module. +_fake_renpy_store = sys.modules["store"] +_fake_renpy_exports_store = _fake_renpy_store +sys.modules["renpy.store"] = sys.modules["store"] + +# Setup the fake Ren'Py persistent store and assign it to the fake store module i.e. `renpy.store.persistent` +_fake_renpy_store.persistent = _FakeRenpyPersistent() # type: ignore + +# Assign the fake store to the fake store module i.e. `renpy.store.store` +_fake_renpy_store.store = sys.modules["store"] # type: ignore + +# Declare fake Ren'Py store variables. +_fake_renpy_store._ = _fake_renpy_ # type: ignore + +_fake_renpy_store.chapter = 0 # type: ignore +_fake_renpy_store.ch1_choice = "sayori" # type: ignore +_fake_renpy_store.skipping = None # type: ignore +_fake_renpy_store._skipping = False # type: ignore +_fake_renpy_store.dissolve = None # type: ignore +_fake_renpy_store.i11 = None # type: ignore + +# Declare fake Ren'Py persistent variables. +_fake_renpy_store.persistent.playthrough = 0 # type: ignore +_fake_renpy_store.persistent.seen_sticker = False # type: ignore diff --git a/tests/utils/renpy_mock.py b/tests/utils/renpy_mock.py new file mode 100644 index 00000000..8ca2e224 --- /dev/null +++ b/tests/utils/renpy_mock.py @@ -0,0 +1,106 @@ +import sys +import random +import types +from unittest import mock +from .mocks import ( + _FakeRenpyEasy, + _FakeRenpyNull, + _FakeRenpyRandom, + _fake_renpy_store, + _FakeRenpyTextTextText, + _fake_renpy_, + _fake_renpy_window_hide, +) + + +class RenPyMock: + """ + A mock class to simulate specific Ren'Py behaviors for testing purposes. + """ + + def __init__(self): + # Initialize initial state for the mock + # Basically `renpy.X` modules + self.random = _FakeRenpyRandom() + self.easy = _FakeRenpyEasy() + self.store = _fake_renpy_store + self.persistent = _fake_renpy_store.persistent + self.windows = random.choice([True, False]) + self.pure = mock.MagicMock() + + # Create a mock for the renpy.text.text.Text class + self.text = mock.MagicMock() + self.text.text = mock.MagicMock() + self.text.text.Text = _FakeRenpyTextTextText + + # Create displayable and null mocks + self.Null = _FakeRenpyNull() + + # Add translation mock + self._ = _fake_renpy_ + + # Add window mock + self._window_hide = _fake_renpy_window_hide + + # Add a mock for other Ren'Py functions if needed + self.game = mock.MagicMock() + self.gui = mock.MagicMock() + self.config = mock.MagicMock() + self.audio = mock.MagicMock() + self.sound = mock.MagicMock() + self.audio.music = mock.MagicMock() + self.music = self.audio.music + + self.store.audio = mock.MagicMock() # type: ignore + self.store.config = self.config # type: ignore + self.store.gui = mock.MagicMock() # type: ignore + + def __getattr__(self, name): + # Try to get attribute from self first + if name in self.__dict__: + return self.__dict__[name] + # Try to get attribute from store + if hasattr(self.store, name): + return getattr(self.store, name) + # Attribute not found + raise AttributeError(f"'RenPyMock' object has no attribute '{name}'") + + def restart_interaction(self): + """ + Mock method to simulate restarting an interaction. + """ + print("Interaction restarted.") + pass + + def _window_hide(self): + """ + Mock method to simulate hiding the window. + """ + print("Window hidden.") + pass + + def _window_auto(self): + """ + Mock method to simulate auto-hiding the window. + """ + print("Window set to auto.") + pass + + def transition(self, transition=None): + """ + Mock method to simulate a transition. + """ + print("Transition called.") + pass + + +def install_mock(): + """ + Install the RenPyMock into the namespace. + """ + renpy_mock = RenPyMock() + renpy_module = types.ModuleType("renpy") + for attr in dir(renpy_mock): + if not attr.startswith("__"): + setattr(renpy_module, attr, getattr(renpy_mock, attr)) + sys.modules["renpy"] = renpy_module