diff --git a/game/gui/button/menubuttons/check.png b/game/gui/button/menubuttons/check.png new file mode 100644 index 0000000..1ea6b46 Binary files /dev/null and b/game/gui/button/menubuttons/check.png differ diff --git a/game/gui/button/menubuttons/checkbox.png b/game/gui/button/menubuttons/checkbox.png new file mode 100644 index 0000000..39f41e1 Binary files /dev/null and b/game/gui/button/menubuttons/checkbox.png differ diff --git a/game/gui/button/menubuttons/cross.png b/game/gui/button/menubuttons/cross.png new file mode 100644 index 0000000..2d4ed36 Binary files /dev/null and b/game/gui/button/menubuttons/cross.png differ diff --git a/game/gui/button/menubuttons/down.png b/game/gui/button/menubuttons/down.png new file mode 100644 index 0000000..64a5f78 Binary files /dev/null and b/game/gui/button/menubuttons/down.png differ diff --git a/game/gui/button/menubuttons/template_full_idle.png b/game/gui/button/menubuttons/template_full_idle.png new file mode 100644 index 0000000..6c1ecde Binary files /dev/null and b/game/gui/button/menubuttons/template_full_idle.png differ diff --git a/game/gui/button/menubuttons/up.png b/game/gui/button/menubuttons/up.png new file mode 100644 index 0000000..b1af7ac Binary files /dev/null and b/game/gui/button/menubuttons/up.png differ diff --git a/game/gui/mod_frame.png b/game/gui/mod_frame.png new file mode 100644 index 0000000..8d34ba6 Binary files /dev/null and b/game/gui/mod_frame.png differ diff --git a/game/mods/-DISABLEALLMODS.txt b/game/mods/-DISABLEALLMODS.txt new file mode 100644 index 0000000..e69de29 diff --git a/game/mods/-NOLOADORDER.txt b/game/mods/-NOLOADORDER.txt new file mode 100644 index 0000000..e69de29 diff --git a/game/mods/-NOMODS.txt b/game/mods/-NOMODS.txt new file mode 100644 index 0000000..e69de29 diff --git a/game/mods/README.md b/game/mods/README.md index 478b583..1808721 100644 --- a/game/mods/README.md +++ b/game/mods/README.md @@ -1,55 +1,120 @@ -To normal users installing multiple mods: It's recommended to only have one mod folder installed in the mods directory in case two mods have duplicate `green_fang_story` label, otherwise the game will error out if mods conflict each other, this includes not only labels but images & sound too. +--- MOD LOADER USAGE --- -The game loads an alternate storyline.rpy, this allows you to control the flow of the game's storytelling -Examples include: -- You want to inject more stuff inbetween chapters, maybe you hate time skip writing -- You want to have more of an after story kind of ordeal, for example expanding Ending 2 -- You want to replace the entire story route -You can still call the vanilla game's chapters like the intro (call chapter_1) for example but you might want to either copy the vanilla scripts and mix in your edits to have full control +If there's problems with installed mods - like if an enabled mod makes the game crash on startup - there's 3 file flags you can apply to work around it, checked only in the root of the mods folder: +- Any file starting with "DISABLEALLMODS". This will force disable all currently installed mods, but will still load all their metadata. Removing this flag will return the mod states to how it was previously. +- Any file starting with "NOLOADORDER". This will erase the load order/states and always load mods in folder alphabetical order, with mod states depending on the "Enable New Mods" option in the preferences menu. +- Any file starting with "NOMODS". This turns off mod loading entirely by making the game not find any metadata files to load. +These file flags already exist in the mods folder, but renamed to not trigger in-game. Enable them by renaming the file to take off the first hyphen. -You will need to learn bit of Ren'Py & bit of actual Python anyways but you don't have to think too hard in learning anything new other than familiarizing with this game's script.rpy's already defined Character objects and images, you can freely ignore most of the UI and so on for the inexperienced but passionate artist and/or writer. +When ordering the mods in the mod menu, know that not all mod code will be loaded according to the order. Ren'Py has a feature called 'init' that will run code at certain mod-defined stages (priorities) of the engine starting up, so if one mod's init block is set to run at an earlier priority than another mod's, it doesn't what order it is in the mod loader, it will always load that init first. The only time when the order comes into effect is if 2 mod's init blocks run at the same priority, or aren't running in an init block. -Textbox limitation: ~300 characters / four lines, the maximum in the vanilla game's script is around roughly ~278 and that only barely overflows to four lines, so <200 characters / three lines of text should be fine. ---- Ideal file structure of your mod --- +--- MOD CREATION PRE-REQUSITES --- + +Before modding, you may want to do either of these things: + +-Download Snoot Game as a renpy project and launch through the SDK so you can have easy access to debugging and other QoL tools, including dev mode. (Download the repo in this link: https://git.cavemanon.xyz/Cavemanon/SnootGame) +-Open script.rpy and put 'config.developer = True' somewhere in the 'init python' block to have access to renpy's dev mode. + +Pick the latter option if you're lazy, since you don't seriously need to use the SDK for most things. + + +--- MOD CRREATION --- + +When creating a mod, make a new folder within the 'mods' directory at the root of the game directory and name it whatever you want. Inside that folder, make a file called 'metadata.json', and follow the JSON file format to implement details about your mod. An example would be: + +``` +{ + "ID": "234234u9SDjjasdhl23", + "Name": "Test Mod", + "Label": "test_mod_label", + "Version": "1.0", + "Authors": [ "Author1", "Author2", "Author3" ], + "Links": "Link1", + "Description": "This contains the description of my mod" +} +``` + +Make sure there isn't a comma at the end of the last entry in your JSON. + +Below is all the possible entries you can put in, and explanations for what they do. Note that you don't need to put all of these in your metadata file, and infact the only hard requirement is the "ID" entry. + + "ID" : The ID of your mod. Required to be able to load your mod at all, as it is used by the mod loader for mod orders and enabling/disabling. Make this as unique of a string as you can, like a hash. Smash your keyboard if you must. This is how the game knows to differentiate your mod from others (And can be used by other mods to find if a user has your mod installed, if they so choose). + + "Name" : The name of your mod. If this doesn't exist, the game will assume the name of your mod folder. + + "Label" : The label to jump to start your mod story. If this doesn't exist, the button this mod will appear as will do nothing when clicked. Useful if you're only modifying something relating to the base game. + + "Version" : The version number of your mod. + + "Authors" : The authors of your mod. This can be a list of strings or just a string. If it's just a string, it will display in the mod details pane with only "Author:" instead of "Authors:" + + "Links" : The links to download your mod and/or advertisement. This can be a list of strings or just a string. If it's just a string, it will display in the mod details pane with only "Link:" instead of "Links:". In order for this to be useful, use the 'a' text tag to make these texts hyperlinks. + + "Description" : The description of your mod. + + "Mobile Description" : The description of your mod, but only appearing while playing Snoot on Android. If this doesn't exist, it will assume the contents of the description entry. Otherwise, you can copy your description text here and format it however you think it fits for Android. + + "Display" : How your mod button appears if there's an icon image detected. This can be set to "name" - which only displays the mod name - "icon" - which only displays the icon, taking up the entire button - or "both" - which displays the name and icon together, with the icon miniaturized and to the side of the name. This defaults to "both" if it doesn't exist, and if an icon image is not present, it will fall back to "name" mode. + + "Thumbnail Displayable" : What displayable to use for the thumbnail when the mod has loaded scripts. If this doesn't exist, the game will use the thumbnail image found alongside your metadata file + + "Icon Displayable" : What displayable to use for the icon when the mod has loaded scripts. If this doesn't exist, the game will use the icon image found alongside your metadata file + + "Screenshot Displayables" : What displayables to use for screenshots when the mod has loaded scripts. This should be a list of strings. The game will choose each displayable in the list over images found alongside the metadata file sequentially, so if there's 5 screenshot images and 3 screenshot displayables, the last 2 will still display the screenshot images, and the first 3 will display the screenshot displayables. If there's more displayables than images, then the mod will appear to gain screenshots in the mod details pane when the mod is enabled. If you enter empty strings in the list ( "" ), the game will interpret that as a deliberate skipping over to load screenshot images instead of displayables, so if your list consists of '[ "", "my_displayable" ]' and there's any number of screenshot images found, the first screenshot will still show a screenshot image, and only the next one will show a displayable. If this entry doesn't exist, it will just use the screenshot images found alongside your metadata file. + +In the same directory as your metadata file, there's image files you can put in the to make your mod more appealing. These can be any of Ren'Py's supported image file types, so the file extension here is just for demonstration: + + -'thumbnail.png' will appear as a banner for your mod, at the top of the mod details pane + -'icon.png' will show a small image next your mod name or take up the entire button depending on what your "Display" entry is set to. + -'screenshot(number).png' will show screenshots at the bottom of the mod details pane. The '(number)' is a placeholder for a number that represents what order your screenshots appear in. For example, you can have 'screenshot1.png', 'screenshot2.png', and 'screenshot3.png' in your mod directory, and they will all appear in the mod details pane in order. These numbers don't need to be strictly sequential, they can be any number as long as they are integers, and the order will be derived from ASCII ordering. + +As the game loads the metadata, it will also collect scripts for loading. When you make your mod scripts, ALL OF THEM NEED TO HAVE .rpym EXTENSIONS. This is important for being able to control mods with the mod loader, otherwise there would be mod conflicts galore. If you use .rpy extensions to make your mod scripts, they will work, but they will not be manageable by the mod loader. This means you should only use them for development, or if you're not using the mod loader anyway - such as adding a translation to the base game. + +Additionally, Ren'Py will not load any files from the mods folder automatically, so any and all audio/image/video files need to be manually defined to be usable. + +--- TRANSLATION --- + +For disambiguation, a "language code" refers to the code Ren'Py uses to refer to languages program-wide, and can be found on the folder names in "game/tl", with "None" being the default language (Representing a fallback language, but usually meaning English. Internally, it's an actual None variable) + +For translating mods, you should use the Ren'Py SDK to automatically generate translations from mod scripts. Make sure to use a language code that Snoot supports when you do so, so you don't accidentally create a new language. Put them into an organized directory in your mod and change them to use .rpym extensions so that translations don't activate unless your mod is as well (This prevents potential conflicts from other mods from interface/string translations). + +For assets, the easiest way is to put them in the "tl" folder and have the same filepath to your mod asset as it is from the "game" folder to your asset, just like how the officially supported translations are. This will make your mod less portable, but at least there's no namespace or file conflicts for translating images. If you're deadset on making your mod portable, you'll have to make conditionals in your mod to swap out images depending on the language, which means other people translating your mod can't just add a file somewhere and be done with it, they will need to edit your mod scripts as well. + +For translating metadata, you can translate the .json file by creating a new .json and naming it "metadata_(language code).json" (Ex: "metadata_es.json" for spanish). Fill out the .json how you would with the normal metadata file, but with your translated strings. The game will automatically pick this up and replace the strings (or add, if there's no 'None' variant of an entry) according to your language. Note that translating the ID or Label will do nothing, so you will always need a metadata.json file with at least an ID entry even if your mod isn't in english. + +For the images found alongisde your metadata, you can do the same thing as your .json to replace them. Simply append your language code to your image filename like "thumbnail_es.png", "icon_es.png", etc.. For screenshots, the number in your filename will determine if any given translated image will replace another image, or add it inbetween images. So if you have "screenshot4.png", and you have "screenshot2_es.png" and "screenshot5_es.png", all screenshots will show up if your language is in spanish. If there's "screenshot4_es.png" in the directory, then it will replace "screenshot4.png". The final screenshot display will always take the english screenshots as a base before replacing the images with whatever translated images that exist. + + +--- TIPS --- + +The Ren'Py documentation is your friend, but it is also a bitch-ass friend. It will sometimes be notoriously unhelpful, so consult other renpy dudes from the Ren'Py discord, your snoot communities, or youtube. This may also be of interest, as it will link to other documentation as well as many interesting libraries you can use: + https://github.com/methanoliver/awesome-renpy + +Link to Ren'Py documentation: + https://www.renpy.org/doc/html/ + +When making the file structure for your mod, this is the ideal. Keep it nice and organized, preferably keeping the root of your mods folder clear of eveything but metadata related stuff: +``` In the root of the mods folder: folder_of_your_mod_name - - name_of_storyline.rpy + - metadata.json + - Your various image metadatas -> asset_folder - asset.png -> script_folder - - script.rpy - - name_of_storyline.rpy + - script.rpym + -> etc. folders... + - etc. files... ``` -init python: - # Modding Support variables - # All mod rpy files must have title of their mod (this shows up on a button) - # and finally the label that controls the flow of dialogue - - mod_menu_access += [{ - 'Name': "Mod Name", - 'Label': "mod_storyline" - }]; - -image template_sample = Image("mods/folder_of_your_mod_name/asset_folder/asset.png") - -label mod_storyline: - call chapter_1_new +To start your mod, just make it under the label you defined in your metadata file in a mod script. An example in a newly created "mymodscript.rpym": +``` + label my_mod_label: + scene my_background + show my_sprite + "blah blah dialogue" ``` - script_folder/script.rpy -``` -label chapter_1_new: - show template_sample at scenter - "Sample Text" +Textbox limitation: ~300 characters / four lines, the maximum in the vanilla game's script is around roughly ~278 and that only barely overflows to four lines, so <200 characters / three lines of text should be fine. - hide template_sample - play music 'audio/OST/Those Other Two Weirdos.ogg' - show anon neutral flip at aright with dissolve - A "Sample Text" - - return -``` - -The funny thing is I don't even like 'fanfictions' to begin with but this mod support allows these 'fanfictions', ironic. +If a user has many mods installed, it would be annoying to need to keep track of which mods to enable or disable because of code conflicts, so make your variables/functions unique. Since Ren'Py's own scripting doesn't support namespaced stuff, a very simple way is to prefix all your variables/functions with a mod-unique name, similar to your mod ID. An example would be "mymodisfantastic2349234_function_or_variable". Kinda ugly, but manageable with Ctrl-F and string replacement if there's problems. If there must be code conflicts, let the user know in your mod description! \ No newline at end of file diff --git a/game/options.rpy b/game/options.rpy index 3150711..563679c 100644 --- a/game/options.rpy +++ b/game/options.rpy @@ -141,8 +141,13 @@ default persistent.enable_debug_scores = config.developer default persistent.enable_chapter_select = config.developer default persistent.lewd = False default persistent.autoup = False +default persistent.show_mod_screenshots = True default persistent.gallery_edgescroll = True +init -1000 python: + if persistent.newmods_default_state == None: + persistent.newmods_default_state = True + init python: # No idea what this does if persistent.scroll == True: diff --git a/game/screens.rpy b/game/screens.rpy index 1b519cf..c8b2019 100644 --- a/game/screens.rpy +++ b/game/screens.rpy @@ -1006,6 +1006,12 @@ screen preferences(): textbutton _("Enable Debug Scores") action ToggleVariable("persistent.enable_debug_scores", True, False) textbutton _("Enable Chapter Select") action ToggleVariable("persistent.enable_chapter_select", True, False) + vbox: + style_prefix "check" + label _("Mods") + textbutton _("Show Mod Screenshots") action [Function(onclick_audio, persistent.show_mod_screenshots), ToggleVariable("persistent.show_mod_screenshots", True, False)] + textbutton _("Enable New Mods") action [Function(onclick_audio, persistent.newmods_default_state), ToggleVariable("persistent.newmods_default_state", True, False)] + if not main_menu: if config.developer and persistent.enable_debug_scores: $ debug_story_variables(False) diff --git a/game/script.rpy b/game/script.rpy index 7032e3c..f2aae31 100644 --- a/game/script.rpy +++ b/game/script.rpy @@ -14,12 +14,6 @@ #Why yes all my code was formerly in one massive file called "script" thats 28k lines long, how could you tell? #Licensed under the GNU AGPL v3, for more information check snootgame.xyz or the LICENSE file that should have came with this work. -init -1 python: - # Modding Support variables - # All mod rpy files must run a small init python script - mod_dir = "mods/" - mod_menu_access = [] - init python: import webbrowser # This is for the ch2 "look the link up" choice import random @@ -54,6 +48,22 @@ init python: if renpy.seen_image("fang tail"): renpy.mark_image_seen("fang_tail_movie") + # Determine the splash type for the Snoot game logo + persistent.splashtype = random.randint(0,2000 - 1) + + +label before_main_menu: + # Force users pre-Patch 11 to use english if they were on 'None' + if preferences.language == None: + $ preferences.language = 'en' + + # Call initial language setup screen + if (persistent.languaged_up is None): + $ preferences.set_volume('ui', config.default_sfx_volume) + $ persistent.languaged_up = True + call screen lang_sel + + return label start: diff --git a/game/src/image_definitions.rpy b/game/src/image_definitions.rpy index 4ea237d..a5dfb2d 100644 --- a/game/src/image_definitions.rpy +++ b/game/src/image_definitions.rpy @@ -352,6 +352,18 @@ init 1 python: # aight, time for the stuff that isn't gallery required. +### SPLASHSCREEN + +image caveintrosequence: + "caveintro" + alpha 0 + time 0.5 + linear 3.5 alpha 1 + time 10 + linear 1 alpha 0 + + + ### OTHER diff --git a/game/src/mod_menu.rpy b/game/src/mod_menu.rpy index 338da1e..801f5e7 100644 --- a/game/src/mod_menu.rpy +++ b/game/src/mod_menu.rpy @@ -1,102 +1,1079 @@ +# This is one big clusterfuck so let's break it down: + +# All mods have a format that is strictly'ish followed - Metadata json files are a dict of values that represent a mod name, if they jump to a label, ID's, +# descriptions, etc. and are needed to load any mod scripts at all (And scripts are the only way mods can add anything into the game). Given Ren'Py's lack of support +# for namespaced variables, metadata files are designed as such that they don't have mod conflicts so all mods can have their metadata shown at once, even if +# their respective mod is disabled, so the metadata of alls mods gets loaded into mod_menu_metadata. + +# For this big ass init block, metadata loading has these phases: +# 1. Mod metadata collection (Searches through all files, checks for mod disabling files, and picks out metadata.json files) +# 2. Mod metadata sanitization/error handling (Searches through all metadata.json files, warning of errors and safeguards bad input) +# 3. Mod metadata image/script collection (Searches for images besides metadata.json, loading the thumbnail, icon, and screenshots if they're there, and language +# dependant metadata files such as jsons with translated strings or images) +# 4. Add to mod_menu_metadata (Metadata is added to the mod_menu_metadata list) +# 5. Mod order organizing (Metadata is initially loaded in file alphabetical order, so it rearranges mod_menu_metadata according to load order +# and applies whether a mod is supposed to be disabled or not) +# 6. Mod loading (Loads mod scripts according to mod_menu_metadata) + +# Entries that don't exist in mod_menu_metadata or end up as 'None' by the end of the mod loading should use the .get method for accessing values. This way modders +# don't need to implement every single entry into metadata.json (But makes the code here messier) + +# When an index in mod_menu_metadata is filled out, each mod should look a little something like this (but more sporadically organized), depending on what's +# actually filled out in the metadata files: +# { +# "ID": "my_mod_id", +# "Enabled: True +# "Scripts": [ "mods/My Mod/my_mod_script1", "mods/My Mod/my_mod_script2", "mods/My Mod/my_mod_script3" ] +# "Label": "my_mod_label", +# "None": { +# "Name": "My Mod Name" +# "Version": "1.0", +# "Authors": [ "Author1", "Author2", "Author3" ], +# "Links": "Link1", +# "Description": "My Mod Description", +# "Mobile Description": "My Mod Description", +# "Display": "icon", +# "Thumbnail Displayable": "my_thumbnail_displayable" +# "Icon Displayable": "my_icon_displayable" +# "Screenshot Displayables": [ "my_screenshot_displayable1", "my_screenshot_displayable2", "mods/My Mod/screenshot3.png", "my_screenshot_displayable4" ] +# "Thumbnail": "mods/My Mod/thumbnail.png" +# "Icon": "mods/My Mod/icon.png" +# "Screenshots": [ "mods/My Mod/screenshot1.png", "mods/My Mod/screenshot2.png", "mods/My Mod/screenshot3.png" ] +# }, +# "es": +# "Name": "My Mod Name but in spanish" +# "Version": "1.0", +# "Authors": [ "Author1", "Author2", "Author3" ], +# "Links": "Link1", +# "Description": "My Mod Description but in spanish", +# "Mobile Description": "My Mod Description but in spanish", +# "Display": "icon", +# "Thumbnail Displayable": "my_thumbnail_displayable_es" +# "Icon Displayable": "my_icon_displayable_es" +# "Screenshot Displayables": [ "my_screenshot_displayable1_es", "my_screenshot_displayable2_es", "mods/My Mod/screenshot3.png", "my_screenshot_displayable4_es" ] +# "Thumbnail": "mods/My Mod/thumbnail_es.png" +# "Icon": "mods/My Mod/icon_es.png" +# "Screenshots": [ "mods/My Mod/screenshot1_es.png", "mods/My Mod/screenshot2.png", "mods/My Mod/screenshot3_es.png" ] +# }, +# "ru": { +# "Name": "My Mod Name but in russian" +# etc... +# }, +# etc... +# } +# +# Note that some keys may exist, but simple be 'None', likely as a result of improperly filled out metadata files. + + +init -999 python: + import json + from enum import Enum + + # + # Modding system setup + # + + class ModError(Enum): + Metadata_Fail = 0 + Name_Not_String = 1 + Label_Not_String = 2 + Display_Not_String = 3 + Display_Invalid_Mode = 4 + Version_Not_String = 5 + Authors_Not_String_Or_List = 6 + Authors_Contents_Not_String = 7 + Links_Not_String_Or_List = 8 + Links_Contents_Not_String = 9 + Description_Not_String = 10 + Mobile_Description_Not_String = 11 + Screenshot_Displayables_Not_List = 12 + Screenshot_Displayables_Contents_Not_String = 13 + Icon_Displayable_Not_String = 14 + Thumbnail_Displayable_Not_String = 15 + No_ID = 16 + ID_Not_String = 17 + Similar_IDs_Found = 18 + Installed_Incorrectly = 19 + Invalid_Image_Extension = 20 + Invalid_Language_Code = 21 + + + mods_dir = "mods/" # The root mod folder. Important that you keep the slash at the end. + mod_menu_moddir = ".../game/" + mods_dir # The visual mod dir name + mod_menu_access = [] # This variable exists for legacy mods to be usable. + + # A list containing tuples that contain a mod's ID and if the mod is enabled. The game references this list to activate mods and order them from + # first to last index + if persistent.enabled_mods == None: + persistent.enabled_mods = [] + + valid_image_filetypes = [ ".png", ".jpg", ".webp", ".avif", ".svg", ".gif", ".bmp" ] + + # Makes loading mods on android possible. It creates folders for android mods, changing mod_menu_moddir as necessary if the user is playing on Android. + if renpy.android and not config.developer: + android_mods_path = os.path.join(os.environ["ANDROID_PUBLIC"], "game", mods_dir) + try: + # We have to create both the 'game' and 'mods' folder for android. + os.mkdir(mods_dir) + os.mkdir(android_mods_path) + except: + pass + + mod_menu_moddir = android_mods_path + + + # + # Helper functions + # + + # Determines if a filename with it's extension is valid for renpy's image displaying and for our specified mod metadata. + def is_valid_metadata_image(filename, mod_name): + error_trigger = True + + for ext in valid_image_filetypes: + if this_file.endswith(ext): + error_trigger = False + if error_trigger: + mod_menu_errorcodes.append([ ModError.Invalid_Image_Extension, { "mod_name": mod_name, "name_of_file": filename }]) + return False + + return True + + # Moves a key from one list to another, deleting the key from the source list + def move_key_to_dict(source_dict, destination_dict, key): + if key in source_dict.keys(): + destination_dict[key] = source_dict[key] + del source_dict[key] + + # Checks to see if the key in the metadata dict exists/is correct, and tries to set a default if it isn't. + # For keys that are set to None, it will be treated as if it doesn't exist + def value_isnt_valid_string(metadata, key): + return metadata.get(key) != None and not isinstance(metadata.get(key), str) + + def value_isnt_valid_list(metadata, key): + return metadata.get(key) != None and not isinstance(metadata.get(key), list) + + + + + + # Start finding mods. Find json files within each folder and mod disablers in the mods directory if there's any. + # NOMODS - Disables mod loading entirely + # NOLOADORDER - Erases load order/mod states. + # DISABLEALLMODS - Temporarily disables all mods, but still loading their metadata, and returns them back to their original state when this is removed. + all_mod_files = [ i for i in renpy.list_files() if i.startswith(mods_dir) ] + load_metadata = True + load_mods = True + loadable_mod_metadata = [] + for i in all_mod_files: + if i.startswith(mods_dir + "NOMODS"): + load_metadata = False + loadable_mod_metadata = [] + if i.startswith(mods_dir + "NOLOADORDER"): + persistent.enabled_mods.clear() + if i.startswith(mods_dir + "DISABLEALLMODS"): + load_mods = False + + if load_metadata and i.endswith("/metadata.json"): + loadable_mod_metadata.append(i) + + # + # Get and store metadata + # + + # Where all mod info will be stored + mod_menu_metadata = [] + # A list that contains tuples that contain an error code and a dictionary containing metadata regarding the error such as + # which mod it's referring to. Used to construct error strings outside of this python block, to workaround Ren'Py's + # translation system not working this early. + mod_menu_errorcodes = [] + # Contains the mod_name's of previous mods that have successfully loaded, mirroring mod_menu_metadata while in this loop. Only used for ID checking. + mod_name_list = [] + + if load_metadata: + for file in loadable_mod_metadata: + mod_data_final = {} + mod_jsonfail_list = [] # List of languages that has an associated metadata language file that failed to load. + mod_preferred_modname = [] + mod_exception = False + mod_in_root_folder = file.count("/", len(mods_dir)) is 0 + mod_folder_name = file.split("/")[-2] + # mod_name is used only to display debugging information via mod_menu_errorcodes. Contains the mod folder name and whatever translations of + # the mod's name that exist. Kind of a cursed implemnetation but with how early error reporting this is before solidifying the mod name + # this is just what I came up with. + # Other than what's directly defined here, it will contain mod names by their language code if they exist. The "None" key will be the fallback if a name of user's + # current language is not there. + mod_name = {} + if mod_in_root_folder: + mod_name["Folder"] = None # 'None' will make it default to 'in root of mods folder' when used in the errorcode conversion. + else: + mod_name["Folder"] = mod_folder_name + + + # Quickly get the names of mods for debugging information, and in the process get raw values from each metadata file that exists. + + # Make the base metadata file (presumably english) organized like a lang_data object, moving the ID to the mod_data_final object. + try: + mod_data = json.load(renpy.open_file(file)) + except Exception as e: + if mod_in_root_folder: + print("//////////// ERROR IN ROOT FOLDER MOD:") + else: + print(f"//////////// ERROR IN MOD '{mod_folder_name}':") + print(" "+str(e)) + print("//////////// END OF ERROR") + mod_exception = True + mod_jsonfail_list.append("None") + + if not mod_jsonfail_list: + if _preferences.language == None and isinstance(mod_data.get("Name"), str): + mod_name["None"] = mod_data["Name"] + + # Move these non-language specific pairs out of mod_data, into the base of the final mod dict. + move_key_to_dict(mod_data, mod_data_final, "ID") + move_key_to_dict(mod_data, mod_data_final, "Label") + # Then store the rest like any other language, just our default one. + mod_data_final['None'] = mod_data + + # Find language metadata files in the same place as our original metadata file, and then get values from it. + for lang in renpy.known_languages(): + lang_file = file[:-5] + "_" + lang + ".json" # Finds the metadata file. ex: metadata_es.json + if renpy.loadable(lang_file): + try: + lang_data = (json.load(renpy.open_file(lang_file))) + except Exception as e: + if mod_in_root_folder: + print(f"//////////// ERROR FOR {lang} METADATA IN ROOT FOLDER MOD:") + else: + print(f"//////////// ERROR FOR {lang} METADATA IN MOD '{mod_folder_name}':") + print(" "+str(e)) + print("//////////// END OF ERROR") + mod_jsonfail_list.append(lang) + + # Attempt to use this mod's translation of it's name if it matches the user's language preference. + if not lang in mod_jsonfail_list: + if _preferences.language == lang and isinstance(lang_data.get("Name"), str): + mod_name[lang] = lang_data["Name"] + mod_data_final[lang] = lang_data + + # Finally report if any of the jsons failed to load, now that we have the definitive list of mod names we could display. + for lang_code in mod_jsonfail_list: + mod_menu_errorcodes.append([ ModError.Metadata_Fail, { "mod_name": mod_name, "lang_code": lang_code }]) + + # + # Sanitize/Clean metadata values + # + + # Make sure our main metadata loaded + if not "None" in mod_jsonfail_list: + if mod_data_final.get("Label") != None and not isinstance(mod_data_final.get("Label"), str): + mod_menu_errorcodes.append([ ModError.Label_Not_String, { "mod_name": mod_name }]) + mod_data_final["Label"] = None + + # If we don't have an ID, don't put it in the mod loader + if mod_data_final.get("ID") == None: + mod_menu_errorcodes.append([ ModError.No_ID, { "mod_name": mod_name }]) + mod_exception = True + elif not isinstance(mod_data_final["ID"], str): + mod_menu_errorcodes.append([ ModError.ID_Not_String, { "mod_name": mod_name }]) + mod_exception = True + else: + # Detect already loaded metadata that has the same ID as our current one, and if so, don't load them + # We'll never get a match for the first mod loaded, so this is fine. + for i, x in enumerate(mod_menu_metadata): + if x["ID"] == mod_data_final["ID"]: + mod_menu_errorcodes.append([ ModError.Similar_IDs_Found, { "mod_name": mod_name, "mod_name2": mod_name_list[i] }]) + mod_exception = True + break + + # Since lang keys will only be added to the mod data dict if their respective metadata successfully loaded, no need to check if they can. + for lang_key in mod_data_final.keys(): + if lang_key is "None" or lang_key in renpy.known_languages(): + lang_data = mod_data_final[lang_key] + + # The JSON object returns an actual python list, but renpy only works with it's own list object and the automation for this fails with JSON. + # So we gotta make all lists revertable + for x in lang_data.keys(): + if type(lang_data[x]) == python_list: + lang_data[x] = renpy.revertable.RevertableList(lang_data[x]) + + # Automatically give the name of the mod from the folder it's using if there's no defined name, but report an error if one is defined but not a string + if value_isnt_valid_string(lang_data, "Name"): + if lang_data.get("Name") != None: + mod_menu_errorcodes.append([ ModError.Name_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + + lang_data["Name"] = mod_folder_name + + # Default "Display" to 'both' mode + if lang_data.get("Display") != None: + if not isinstance(lang_data.get("Display"), str): + if lang_data.get("Display") != None: + mod_menu_errorcodes.append([ ModError.Display_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Display"] = "both" + elif lang_data["Display"] not in ["both","icon","name"]: + mod_menu_errorcodes.append([ ModError.Display_Invalid_Mode, { "mod_name": mod_name, "display_mode": lang_data["Display"], "lang_code": lang_key }]) + lang_data["Display"] = "both" + + if value_isnt_valid_string(lang_data, "Version"): + mod_menu_errorcodes.append([ ModError.Version_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Version"] = None + + # See if "Authors" is a list or string, and if it's a list search through the contents of the list to check if they're valid strings + if value_isnt_valid_string(lang_data, "Authors") and value_isnt_valid_list(lang_data, "Authors"): + mod_menu_errorcodes.append([ ModError.Authors_Not_String_Or_List, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Authors"] = None + elif isinstance(lang_data.get("Authors"), list): + # Search through and call out entries that aren't strings + for i, s in enumerate(lang_data["Authors"]): + if not isinstance(s, str): + mod_menu_errorcodes.append([ ModError.Authors_Contents_Not_String, { "mod_name": mod_name, "author_number": i + 1, "lang_code": lang_key }]) + # And then mutate the list to only include strings + lang_data["Authors"][:] = [x for x in lang_data["Authors"] if isinstance(x, str)] + + if lang_data["Authors"] == []: + lang_data["Authors"] = None + + # Do the same as 'Authors' to 'Links' + if value_isnt_valid_string(lang_data, "Links") and value_isnt_valid_list(lang_data, "Links"): + mod_menu_errorcodes.append([ ModError.Links_Not_String_Or_List, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Links"] = None + elif isinstance(lang_data.get("Links"), list): + for i, s in enumerate(lang_data["Links"]): + if not isinstance(s, str): + mod_menu_errorcodes.append([ ModError.Links_Contents_Not_String, { "mod_name": mod_name, "link_number": i + 1, "lang_code": lang_key }]) + lang_data["Links"][:] = [x for x in lang_data["Links"] if isinstance(x, str)] + + if lang_data["Links"] == []: + lang_data["Links"] = None + + if value_isnt_valid_string(lang_data, "Description"): + mod_menu_errorcodes.append([ ModError.Description_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Description"] = None + + if value_isnt_valid_string(lang_data, "Mobile Description"): + mod_menu_errorcodes.append([ ModError.Mobile_Description_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Mobile Description"] = None + + if lang_data.get("Screenshot Displayables") != None: + if not isinstance(lang_data.get("Screenshot Displayables"), list): + mod_menu_errorcodes.append([ ModError.Screenshot_Displayables_Not_List, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Screenshot Displayables"] = None + else: + # Instead of remaking the list to only include strings, replace the non-strings with empty strings ("") so subsequent strings will still be in the right + # place when eventually showing displayable screenshots over non-displayable ones + for i, s in enumerate(lang_data["Screenshot Displayables"]): + if not isinstance(s, str): + mod_menu_errorcodes.append([ ModError.Screenshot_Displayables_Contents_Not_String, { "mod_name": mod_name, "screenshot_number": i + 1, "lang_code": lang_key }]) + s = "" + + if lang_data["Screenshot Displayables"] == []: + lang_data["Screenshot Displayables"] = None + + if value_isnt_valid_string(lang_data, "Icon Displayable"): + mod_menu_errorcodes.append([ ModError.Icon_Displayable_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Icon Displayable"] = None + + if value_isnt_valid_string(lang_data, "Thumbnail Displayable"): + mod_menu_errorcodes.append([ ModError.Thumbnail_Displayable_Not_String, { "mod_name": mod_name, "lang_code": lang_key }]) + lang_data["Thumbnail Displayable"] = None + + # If our mod does not follow the structure of 'mods/Mod Name/metadata.json', don't load them + this_mod_dir = file[len(mods_dir):-len("metadata.json")] # If the metadata file is in another place, this will connect the filepath between it and the mods folder. + if file.count("/", len(mods_dir)) > 1: + good_folder = "'{color=#ffbdbd}" + mods_dir + mod_folder_name + "/{/color}'" + curr_folder = "'{color=#ffbdbd}" + mods_dir + this_mod_dir + "{/color}'" + mod_menu_errorcodes.append([ ModError.Installed_Incorrectly, { "mod_name": mod_name, "good_dir": good_folder, "curr_dir": curr_folder }]) + mod_exception = True + elif mod_in_root_folder: + mod_menu_errorcodes.append([ ModError.Installed_Incorrectly, { "mod_name": mod_name, "good_dir": mods_dir + "My Mod/", "curr_dir": mods_dir }]) + mod_exception = True + + # + # Collect mod scripts and metadata images + # + + mod_scripts = [] + mod_screenshots = {} + this_mod_dir_length = len(mods_dir + this_mod_dir) + for i in all_mod_files: + if i.startswith(mods_dir + this_mod_dir): + # Collect mod scripts + if not mod_exception and i.endswith(".rpym"): + mod_scripts.append(i[:-5]) + continue + + # This will only allow files that are at the root of the mod folder and have one period. + elif i.count("/", this_mod_dir_length) == 0 and i.count(".", this_mod_dir_length) == 1: + this_file = i[this_mod_dir_length:] + + if this_file.startswith("thumbnail."): + if is_valid_metadata_image(this_file, mod_name): + mod_data_final["None"]["Thumbnail"] = i + elif this_file.startswith("thumbnail_"): + trimmed_string = this_file[len("thumbnail_"):this_file.find(".")] + for lang in renpy.known_languages(): + if lang == trimmed_string: + if is_valid_metadata_image(this_file, mod_name): + mod_data_final[lang]["Thumbnail"] = i + + + elif this_file.startswith("icon."): + if is_valid_metadata_image(this_file, mod_name): + if mod_data_final.get("None") == None: + mod_data_final["None"] = {} + mod_data_final["None"]["Icon"] = i + elif this_file.startswith("icon_"): + trimmed_string = this_file[len("icon_"):this_file.find(".")] + for lang in renpy.known_languages(): + if lang == trimmed_string: + if is_valid_metadata_image(this_file, mod_name): + if mod_data_final.get(lang) == None: + mod_data_final["None"] = {} + mod_data_final[lang]["Icon"] = i + + + elif this_file.startswith("screenshot"): + # Disect the string after "screenshot" for the number and possible lang code + trimmed_string = this_file[len("screenshot"):this_file.find(".")] + number = "" + lang_code = "" + seperator = trimmed_string.find("_") + if seperator != -1: + number = trimmed_string[:seperator] + lang_code = trimmed_string[seperator + 1:] + else: + number = trimmed_string + + # See if we can extract the number + try: + converted_number = int(number) + except: + continue + + if not is_valid_metadata_image(this_file, mod_name): + continue + + if seperator == -1: + if mod_screenshots.get("None") == None: + mod_screenshots["None"] = [] + mod_screenshots["None"].append((i, converted_number)) + elif lang_code in renpy.known_languages(): + if mod_screenshots.get(lang_code) == None: + mod_screenshots[lang_code] = [] + mod_screenshots[lang_code].append((i, converted_number)) + + + # Don't load the mod if there's mod breaking errors + if mod_exception: + continue + + # We're now gonna clean up the screenshots to be more usable as-is. + + # Refine collected screenshots so that translated screenshots use the 'None' screenshots (the ones without a lang code) + # as a base, and then either replacing or adding the translated screenshots according to their order/number. + for lang_key in mod_screenshots.keys(): + if lang_key != "None": + if mod_screenshots.get("None") == None: + temp_list_with_indexes = mod_screenshots[lang_key] + else: + temp_list_with_indexes = mod_screenshots["None"] + for i, lang_images in enumerate(mod_screenshots[lang_key]): + u = 0 + while u < len(temp_list_with_indexes): + if lang_images[1] > temp_list_with_indexes[u][1] and u == len(temp_list_with_indexes) - 1: + temp_list_with_indexes.append(lang_images) + break + elif lang_images[1] == temp_list_with_indexes[u][1]: + temp_list_with_indexes[u] = lang_images + break + elif lang_images[1] < temp_list_with_indexes[u][1]: + temp_list_with_indexes.insert(u, lang_images) + break + + u += 1 + else: + temp_list_with_indexes = mod_screenshots["None"] + + # Get rid of the tuples and just leave the screenshot files + mod_data_final[lang_key]["Screenshots"] = [] + for i in temp_list_with_indexes: + mod_data_final[lang_key]["Screenshots"].append(i[0]) + + # Make a copy of the current screenshots list, then put displayable screenshots wherever the values of "Screenshot Displayables" correspond in this list + # mod_screenshots will return an empty list if there were no screenshot files to begin with, so this works fine. + for lang_key in mod_data_final.keys(): + if lang_key is "None" or lang_key in renpy.known_languages(): + if mod_data_final[lang_key].get("Screenshots") == None: + mod_screenshots = [] + else: + mod_screenshots = mod_data_final[lang_key]["Screenshots"] + + if mod_data_final[lang_key].get("Screenshot Displayables") != None: + mod_displayable_list = mod_screenshots.copy() + + for i, x in enumerate(mod_data_final[lang_key]["Screenshot Displayables"]): + if i < len(mod_screenshots): + if x != "": + mod_displayable_list[i] = x + else: + mod_displayable_list.append(x) + + if mod_displayable_list != []: + mod_data_final[lang_key]["Screenshot Displayables"] = mod_displayable_list + else: + mod_data_final[lang_key]["Screenshot Displayables"] = None + + # Store the collected scripts and screenshots + mod_data_final["Scripts"] = mod_scripts + + # Make our mod loadable + mod_menu_metadata.append(mod_data_final) + mod_name_list.append(mod_name) # This will mirror mod_menu_metadata + + + # Sort mod metadata list according to enabled_mods, while dropping mods from enabled_mods if they aren't installed + # This will also apply the state of a mod if it's supposed to be enabled/disabled + # The effect will be that mod_menu_metadata is sorted from first to last to be loaded + temp_list = [] + for saved_mod_id, saved_mod_state in persistent.enabled_mods: + for mod in mod_menu_metadata: + if mod["ID"] == saved_mod_id: + mod["Enabled"] = saved_mod_state + temp_list.append(mod) + break + + # Now inverse search to find new mods and append them to metadata list. New mods are by default enabled, and are the last to be loaded + for mod in mod_menu_metadata: + mod_not_found = True + for saved_mod_id in persistent.enabled_mods: + if mod["ID"] == saved_mod_id[0]: + mod_not_found = False + if mod_not_found: + # If this mod doesn't have any loadable scripts, treat it as on (Say, if a mod changed something in the base game and the label points there) + if not mod["Scripts"]: + mod["Enabled"] = True + # Otherwise set mods to the default state + else: + mod["Enabled"] = persistent.newmods_default_state + temp_list.append(mod) + + mod_menu_metadata = temp_list + + # Rewrite enabled_mods to reflect the new mod order, and load all the mods + persistent.enabled_mods.clear() + + for mod in mod_menu_metadata: + persistent.enabled_mods.append( [ mod["ID"], mod["Enabled"] ] ) + + # Making the load_mods check here makes it so the NOLOAD flag doesn't overwrite the previously saved load order + if load_mods and mod["Enabled"]: + for script in mod["Scripts"]: + renpy.include_module(script) + else: + mod["Enabled"] = False + +# Now convert our errorcodes to errorstrings +init python: + def return_translated_mod_name(mod_dict): + if _preferences.language == None and "None" in mod_dict.keys(): + return "'{color=#ffbdbd}" + mod_dict["None"] + "{/color}'" + elif _preferences.language in mod_dict.keys(): + return "'{color=#ffbdbd}" + mod_dict[_preferences.language] + "{/color}'" + else: + if mod_dict["Folder"] == None: + return __("the root of the mods folder") + else: + return "'{color=#ffbdbd}" + mod_dict["Folder"] + "{/color}'" + def convert_errorcode_to_errorstring(error_code, var_dict): + if var_dict.get("mod_name") != None: + mod_name = return_translated_mod_name(var_dict["mod_name"]) + if var_dict.get("mod_name2") != None: + mod_name2 = return_translated_mod_name(var_dict["mod_name2"]) + + lang_code = var_dict.get("lang_code") + if lang_code == "None" or lang_code == None: + lang_code = "" + else: + lang_code = __(" for '{color=#ffbdbd}") + lang_code + __("{/color}' language") + + name_of_file = var_dict.get('name_of_file') + if name_of_file != None: + name_of_file = "'{color=#ffbdbd}" + name_of_file + "{/color}'" + + if error_code == ModError.Metadata_Fail: + if lang_code == "": + return __("{color=#ff1e1e}Mod in ") + mod_name + __(" failed to load: Metadata is formatted incorrectly. Check log.txt or console for more info.{/color}") + else: + return __("{color=#ff8b1f}Metadata in ") + mod_name + lang_code + _(" is formatted incorrectly. Check log.txt or console for more info.{/color}") + elif error_code == ModError.Name_Not_String: + return __("{color=#ff8b1f}Mod's name in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Label_Not_String: + return __("{color=#ff8b1f}Mod's label in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Display_Not_String: + return __("{color=#ff8b1f}Display mode in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Display_Invalid_Mode: + return __("{color=#ff8b1f}Display mode in ") + mod_name + lang_code + _(" is not valid. Valid options are 'both', 'icon' and 'name', not ") + var_dict['display_mode'] + __(".{/color}") + elif error_code == ModError.Version_Not_String: + return __("{color=#ff8b1f}Mod's version in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Authors_Not_String_Or_List: + return __("{color=#ff8b1f}Mod's authors in ") + mod_name + lang_code + _(" is not a string or list.{/color}") + elif error_code == ModError.Authors_Contents_Not_String: + return __("{color=#ff8b1f}Author ") + var_dict['author_number'] + __(" in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Links_Not_String_Or_List: + return __("{color=#ff8b1f}Mod's links in ") + mod_name + lang_code + __(" is not a string or list.{/color}") + elif error_code == ModError.Links_Contents_Not_String: + return __("{color=#ff8b1f}Link ") + var_dict['link_number'] + __(" in ") + mod_name + lang_code + __(" is not a string.{/color}") + elif error_code == ModError.Description_Not_String: + return __("{color=#ff8b1f}Mod's description in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Mobile_Description_Not_String: + return __("{color=#ff8b1f}Mod's mobile description in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Screenshot_Displayables_Not_List: + return __("{color=#ff8b1f}Mod's screenshot displayables in ") + mod_name + lang_code + _(" is not a list.{/color}") + elif error_code == ModError.Screenshot_Displayables_Contents_Not_String: + return __("{color=#ff8b1f}Screenshot Displayable ") + var_dict['screenshot_number'] + __(" in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Icon_Displayable_Not_String: + return __("{color=#ff8b1f}Mod's icon displayable in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.Thumbnail_Displayable_Not_String: + return __("{color=#ff8b1f}Mod's thumbnail displayable in ") + mod_name + lang_code + _(" is not a string.{/color}") + elif error_code == ModError.No_ID: + return __("{color=#ff1e1e}Mod in ") + mod_name + __(" failed to load: Does not have a mod ID.{/color}") + elif error_code == ModError.ID_Not_String: + return __("{color=#ff1e1e}Mod in ") + mod_name + __(" failed to load: ID is not a string.{/color}") + elif error_code == ModError.Similar_IDs_Found: + return __("{color=#ff1e1e}Mod in ") + mod_name + __(" failed to load: Another mod ") + mod_name2 + __(" has the same ID.{/color}") + elif error_code == ModError.Installed_Incorrectly: + return __("{color=#ff1e1e}Mod in ") + mod_name + __(" is not installed correctly.\nMake sure it's structure is ") + var_dict['good_dir'] + __(" instead of ") + var_dict['curr_dir'] + __(".{/color}") + elif error_code == ModError.Invalid_Image_Extension: + return __("{color=#ff8b1f}") + name_of_file + __(" image for mod in ") + mod_name + lang_code + _(" has an incompatible file extension. {a=https://www.renpy.org/doc/html/displayables.html#images}Only use images that Ren'Py supports!{/a}{/color}") + # Mod Menu screen ############################################################ ## ## Handles jumping to the mods scripts ## Could be more lean but if this is going to one of last time I touch the UI, ## then fine by me ## -#similar to quick_button funcs -screen mod_menu_button(filename, label, function): - button: - xmaximum 600 - ymaximum 129 - action function - if 'Back' in label or 'Return' in label or 'Quit' in label or 'Main Menu' in label: - activate_sound "audio/ui/uiBack.wav" - else: - activate_sound "audio/ui/uiClick.wav" - fixed: - add filename xalign 0.5 yalign 0.5 zoom 0.9 - text label xalign 0.5 yalign 0.5 xanchor 0.5 size 34 -# arr is [{ -# 'Name': string (name that appears on the button) -# 'Label': string (jump label) -# }, { .. } ] -# Reuse the same image string and keep things 'neat'. -screen mod_menu_buttons(filename, arr): - for x in arr: - use mod_menu_button(filename, x['Name'], Start(x['Label']) ) +# Some gay python workarounds for screen jank +init python: + def toggle_persistent_mods(index): + if persistent.enabled_mods[index][1] == True: + persistent.enabled_mods[index][1] = False + elif persistent.enabled_mods[index][1] == False: + persistent.enabled_mods[index][1] = True + def swapList(sl,pos1,pos2): + temp = sl[pos1] + sl[pos1] = sl[pos2] + sl[pos2] = temp + def swapMods(idx1,idx2): + swapList(mod_menu_metadata,idx1,idx2) + swapList(persistent.enabled_mods,idx1,idx2) + + # All operations that use this function need to be able to parse "None" as a safeguard. + def return_translated_metadata(mod_metadata, key): + if _preferences.language != None and _preferences.language in mod_metadata.keys() and mod_metadata[_preferences.language].get(key) != None: + return mod_metadata[_preferences.language][key] + elif "None" in mod_metadata.keys(): + return mod_metadata["None"].get(key) + else: + return None + + +default persistent.seenModWarning = False screen mod_menu(): - tag menu style_prefix "main_menu" add gui.main_menu_background - frame: xsize 420 yfill True background "gui/overlay/main_menu.png" -#side_yfill True - vbox: - xpos 1940 - yalign 0.03 - if persistent.splashtype == 1: - add "gui/sneedgame.png" - else: - add "gui/snootgame.png" + default mod_metadata = {} + default reload_game = False + default mod_button_enabled = True + default mod_button_alpha = 1.0 + default mod_screenshot_list = None + default mod_icon = None + default mod_thumbnail = None + + + # The top 2 buttons + hbox: + xpos 1272 + ypos 30 + + spacing 8 + + use mod_menu_top_buttons(_("Reload Mods"), SetScreenVariable("reload_game", True)): + # For some reason, Function() will instantly reload the game upon entering the mod menu, and put it in an infinite loop, so doing this python jank + # is the only way + if reload_game: + python: + reload_game = False + renpy.reload_script() + use mod_menu_top_buttons(_("Return"), ShowMenu("extras")) viewport: - # this could be better but its ok for now - xpos 1885-540 - xmaximum 540 - ymaximum 0.8 - ypos 200 - yinitial 0 - if len(mod_menu_access) > 5: # Hides the scrollbar when not needed. Ideally nobody would install more than one mod at the same time, but oh well - scrollbars "vertical" + xpos 1260 + ypos 180 + xmaximum 637 + ymaximum 889 + + scrollbars "vertical" + vscrollbar_unscrollable "hide" mousewheel True draggable True pagekeys True - - if len(mod_menu_access) != 0: + if len(mod_menu_metadata) != 0 or len(mod_menu_access) != 0: vbox: - use mod_menu_button("gui/button/menubuttons/template_idle.png", _("Return"), ShowMenu("main_menu")) - spacing 18 - use mod_menu_buttons("gui/button/menubuttons/template_idle.png", mod_menu_access ) - else: - use mod_menu_button("gui/button/menubuttons/template_idle.png", _("Return"), ShowMenu("main_menu")) - text _("You have no mods! \nInstall some in:\n\"[moddir]\""): - style_prefix "navigation" - size 45 - text_align 0.5 - outlines [(3, "#445ABB", absolute(0), absolute(0))] at truecenter + for i, x in enumerate(mod_menu_metadata): + hbox: + xsize 129 + ysize 129 -############################# -# Stuff for mods in android # -############################# + vbox: + at truecenter + style_prefix None + spacing 5 -init python: - - import os + if renpy.variant(["mobile", "steam_deck"]): + ysize 200 + else: + ysize 160 - if renpy.android and not config.developer: + # Move mod up button + if i!=0: + button: + at truecenter + style_prefix "main_menu" + add Null(30,30) - moddir = os.path.join(os.environ["ANDROID_PUBLIC"], "game") + activate_sound "audio/ui/snd_ui_click.wav" - try: - # We have to create both the 'game' and 'mods' folder for android. - os.mkdir(moddir) - os.mkdir(os.path.join(moddir, "mods")) - except: - pass + idle_foreground Transform("gui/button/menubuttons/up.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#445ABB")) + hover_foreground Transform("gui/button/menubuttons/up.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#00FF03")) + action Function(swapMods, i, i-1) + else: + add Null(30,30) at truecenter - else: - moddir = ".../game" + # Enablin/disabling mods button + button: + at truecenter + style_prefix "main_menu" - moddir += "/mods/" \ No newline at end of file + # Manual adjustment to make the arrow buttons closer to the mod toggle button + if not renpy.variant(["mobile", "steam_deck"]): + ysize 65 + + add "gui/button/menubuttons/checkbox.png" xalign 0.5 yalign 0.5 + + if x["Scripts"]: + action Function(toggle_persistent_mods, i) + activate_sound "audio/ui/snd_ui_click.wav" + if persistent.enabled_mods[i][1]: + idle_foreground Transform("gui/button/menubuttons/check.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#00ff40")) + hover_foreground Transform("gui/button/menubuttons/check.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#ffffff")) + else: + idle_foreground Transform("gui/button/menubuttons/cross.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#db1a1a")) + hover_foreground Transform("gui/button/menubuttons/cross.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#ffffff")) + + + # Move mod down button + if i!=len(mod_menu_metadata)-1: + button: + at truecenter + style_prefix "main_menu" + add Null(30,30) + action Function(swapMods, i, i+1) + activate_sound "audio/ui/snd_ui_click.wav" + + idle_foreground Transform("gui/button/menubuttons/down.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#445ABB")) + hover_foreground Transform("gui/button/menubuttons/down.png",xalign=0.5,yalign=0.5,matrixcolor=TintMatrix("#00FF03")) + + else: + add Null(30,30) at truecenter + + # The main mod button that displays the mod name and potential icon. + python: + mod_button_enabled = (x["Enabled"] == True) and (x.get("Label") != None) + mod_button_alpha = 1.0 if x["Enabled"] == True else 0.4 # Fade mod buttons out if their mod is disabled + + if x['Enabled'] == True and return_translated_metadata(x, "Icon Displayable") != None: + mod_icon = return_translated_metadata(x, "Icon Displayable") + elif return_translated_metadata(x, "Icon") != None: + mod_icon = return_translated_metadata(x, "Icon") + else: + mod_icon = None + + button: + at transform: + truecenter + alpha mod_button_alpha + activate_sound "audio/ui/snd_ui_click.wav" + hovered SetScreenVariable("mod_metadata", x) + + # Clicking the mod button starts the mod on PC, but we have to click a seperate button to start on Android. + if mod_button_enabled and not renpy.variant(["mobile", "steam_deck"]): + action Start(x["Label"]) + else: + action NullAction() + + frame: + xsize 475 + ymaximum 2000 + if mod_button_enabled: + background Frame("gui/button/menubuttons/template_idle.png", 12, 12) + else: + background Transform(Frame("gui/button/menubuttons/template_idle.png", 12, 12),matrixcolor=SaturationMatrix(0.5)) + + padding (5, 5) + + # Display mod name and/or icon + if return_translated_metadata(x, "Display") == "icon" and mod_icon != None: + add RoundedCorners(mod_icon, radius=(5, 5, 5, 5)) xsize 342 fit "scale-down" at truecenter + elif return_translated_metadata(x, "Display") == "both" or return_translated_metadata(x, "Display") == None and mod_icon != None: + hbox: + spacing 20 + at truecenter + add mod_icon xysize (100, 100) fit "contain" at truecenter + if mod_button_enabled: + text return_translated_metadata(x, "Name") xalign 0.5 yalign 0.5 size 34 textalign 0.5 at truecenter + else: + text return_translated_metadata(x, "Name") xalign 0.5 yalign 0.5 size 34 textalign 0.5 at truecenter hover_color "#FFFFFF" + else: + if mod_button_enabled: + text return_translated_metadata(x, "Name") xalign 0.5 yalign 0.5 size 34 textalign 0.5 + else: + text return_translated_metadata(x, "Name") xalign 0.5 yalign 0.5 size 34 textalign 0.5 hover_color "#FFFFFF" + + # Only here for backwards compatibility to legacy mods + for x in mod_menu_access: + hbox: + xsize 129 + ysize 129 + add Null(88) + vbox: + if renpy.variant(["mobile", "steam_deck"]): + ysize 200 + else: + ysize 160 + button: + at truecenter + activate_sound "audio/ui/snd_ui_click.wav" + action Start(x["Label"]) + + frame: + xsize 475 + ymaximum 2000 + background Frame("gui/button/menubuttons/template_idle.png", 12, 12) + padding (5, 5) + text x["Name"] xalign 0.5 yalign 0.5 size 34 textalign 0.5 + + else: + fixed: + ymaximum 600 # This is the stupidest fucking hack fix + + text _("You have no mods! \nInstall some in:\n\"{color=#abd7ff}[mod_menu_moddir]{/color}\""): + style_prefix "navigation" + size 45 + text_align 0.5 + xalign 0.5 yalign 0.5 + outlines [(3, "#342F6C", absolute(0), absolute(0))] + + # Displays the mod metadata on the left side + # This has two seperate viewports for error display because renpy is retarded + if mod_metadata != {}: + + # Mod play button for android + # I'm too fuckin tired to make this not shit and just put this and the viewport into a vbox, forgive me + $ mod_has_label_android = renpy.variant(["mobile", "steam_deck"]) and mod_metadata.get("Label") != None + if mod_has_label_android: + $ mod_button_alpha = 1.0 if mod_metadata["Enabled"] == True else 0.4 # Fade mod button out if mod is disabled + button: + xpos 13 + ypos 928 + transform: + alpha mod_button_alpha + frame: + xsize 1190 + ysize 129 + + background Frame("gui/button/menubuttons/template_full_idle.png", 12, 12) + text _("Start") xalign 0.5 yalign 0.5 size 50 + + action Start(mod_metadata["Label"]) + activate_sound "audio/ui/snd_ui_click.wav" + + + # Mod details pane + viewport: + xmaximum 1190 + if mod_has_label_android: + ymaximum 900 + else: + ymaximum 1050 + xpos 15 + ypos 15 + scrollbars "vertical" + vscrollbar_unscrollable "hide" + mousewheel True + draggable True + vbox: + style_prefix "mod_menu" + + # Thumbnail + python: + if mod_metadata["Enabled"] == True and return_translated_metadata(mod_metadata, "Thumbnail Displayable") != None: + mod_thumbnail = return_translated_metadata(mod_metadata, "Thumbnail Displayable") + elif return_translated_metadata(mod_metadata, "Thumbnail") != None: + mod_thumbnail = return_translated_metadata(mod_metadata, "Thumbnail") + else: + mod_thumbnail = None + + if mod_thumbnail: + frame: + background None + xpadding 30 + bottom_padding 30 + xalign 0.5 + add mod_thumbnail fit 'scale-down' + + # Mod details + # Omits checking for mod name, since we'll always have some kind of mod name. + # This will also not show anything if there's only a mod name, since we already show one in the mod button. + if return_translated_metadata(mod_metadata, "Version") != None or return_translated_metadata(mod_metadata, "Authors") != None or return_translated_metadata(mod_metadata, "Links") != None: + frame: + background Frame("gui/mod_frame.png", 30, 30) + padding (30, 30) + xfill True + + vbox: + if return_translated_metadata(mod_metadata, "Name") != None: + hbox: + text _("Name: ") + text return_translated_metadata(mod_metadata, "Name") + if return_translated_metadata(mod_metadata, "Version") != None: + hbox: + text _("Version: ") + text return_translated_metadata(mod_metadata, "Version") + if return_translated_metadata(mod_metadata, "Authors") != None: + if isinstance(return_translated_metadata(mod_metadata, "Authors"), list): + hbox: + text _("Authors: ") + text ", ".join(return_translated_metadata(mod_metadata, "Authors")) + else: + hbox: + text _("Author: ") + text return_translated_metadata(mod_metadata, "Authors") + if return_translated_metadata(mod_metadata, "Links") != None: + if isinstance(return_translated_metadata(mod_metadata, "Links"), list): + hbox: + text _("Links: ") + text ", ".join(return_translated_metadata(mod_metadata, "Links")) + else: + hbox: + text _("Link: ") + text return_translated_metadata(mod_metadata, "Links") + + # Description + if return_translated_metadata(mod_metadata, "Description") != None or return_translated_metadata(mod_metadata, "Mobile Description") != None: + frame: + background Frame("gui/mod_frame.png", 30, 30) + padding (30, 30) + xfill True + + # If there's no mobile description, display the regular description on Android. + if (not renpy.android or return_translated_metadata(mod_metadata, "Mobile Description") == None) and (return_translated_metadata(mod_metadata, "Description") != None): + text return_translated_metadata(mod_metadata, "Description") + elif return_translated_metadata(mod_metadata, "Mobile Description") != None: + text return_translated_metadata(mod_metadata, "Mobile Description") + + # Screenshots + python: + if mod_metadata["Enabled"] == True and return_translated_metadata(mod_metadata, "Screenshot Displayables") != None: + mod_screenshot_list = return_translated_metadata(mod_metadata, "Screenshot Displayables") + elif return_translated_metadata(mod_metadata, "Screenshots") != None: + mod_screenshot_list = return_translated_metadata(mod_metadata, "Screenshots") + else: + mod_screenshot_list = None + + if persistent.show_mod_screenshots and mod_screenshot_list: + frame: + background Frame("gui/mod_frame.png", 30, 30) + padding (30, 30) + xfill True + + hbox: + xoffset 12 + box_wrap True + box_wrap_spacing 25 + spacing 25 + + for i in mod_screenshot_list: + imagebutton: + at transform: + ysize 200 subpixel True + fit "scale-down" + xalign 0.5 yalign 0.5 + + idle i + hover Transform(i, matrixcolor=BrightnessMatrix(0.1)) + action Show("mod_screenshot_preview", Dissolve(0.5), img=i) + + elif len(mod_menu_errorcodes) != 0: + viewport: + xmaximum 1190 + ymaximum 1050 + xpos 15 + ypos 15 + scrollbars "vertical" + vscrollbar_unscrollable "hide" + mousewheel True + draggable True + vbox: + style_prefix "mod_menu" + frame: + background Frame("gui/mod_frame.png", 30, 30) + padding (30, 30) + xfill True + vbox: + spacing 25 + for t in mod_menu_errorcodes: + text convert_errorcode_to_errorstring(t[0], t[1]) + + if not persistent.seenModWarning: + $ persistent.seenModWarning = True + use OkPrompt(_("Installing mods is dangerous since you are running unknown code in your computer. Only install mods from sources that you trust.\n\nIf you have problems with installed mods, check the README.md in the root of the mods folder."), False) + +style mod_menu_text: + size 34 + +# A copy of the image previewer found in the phone library, so that the phone library can still be modular if someone takes it out. +screen mod_screenshot_preview(img): + modal True + add Solid("#000") at Transform(alpha=0.6) + + add img: + align (0.5, 0.5) + fit "scale-down" + + key ["mouseup_1", "mouseup_3"] action Hide("mod_screenshot_preview", dissolve) + +screen mod_menu_top_buttons(text, action): + button: + frame: + xmaximum 300 + ymaximum 129 + + background Frame("gui/button/menubuttons/template_idle.png", 12, 12) + text text xalign 0.5 yalign 0.5 size 34 + + action action + activate_sound "audio/ui/snd_ui_click.wav" + + transclude \ No newline at end of file diff --git a/game/src/rounded_corners.rpy b/game/src/rounded_corners.rpy new file mode 100644 index 0000000..37d5a1a --- /dev/null +++ b/game/src/rounded_corners.rpy @@ -0,0 +1,91 @@ +# RoundedCorners() rounds the corners of a displayable you give it + +python early: + import collections, pygame_sdl2 as pygame + + def normalize_color(col): + a = col[3] / 255.0 + r = a * col[0] / 255.0 + g = a * col[1] / 255.0 + b = a * col[2] / 255.0 + return (r, g, b, a) + + _rounded_corners_relative = { + None: 0.0, + "min": 1.0, + "max": 2.0, + "width": 3.0, + "height": 4.0, + } + + def RoundedCorners(child, radius, relative=None, outline_width=0.0, outline_color="#fff", **kwargs): + if not isinstance(radius, tuple): radius = (radius,) * 4 + relative = _rounded_corners_relative[relative] + outline_color = normalize_color(Color(outline_color)) + return Transform(child, mesh=True, shader="shader.rounded_corners", u_radius=radius, u_relative=relative, u_outline_color=outline_color, u_outline_width=outline_width, **kwargs) + + CurriedRoundedCorners = renpy.curry(RoundedCorners) + + renpy.register_shader("shader.rounded_corners", variables=""" + uniform vec4 u_radius; + uniform float u_outline_width; + uniform vec4 u_outline_color; + uniform float u_relative; + uniform sampler2D tex0; + attribute vec2 a_tex_coord; + varying vec2 v_tex_coord; + uniform vec2 u_model_size; + """, vertex_200=""" + v_tex_coord = a_tex_coord; + """, fragment_functions=""" + float rounded_rectangle(vec2 p, vec2 b, float r) { + return length(max(abs(p) - b + r, 0.0)) - r; + } + + float get_radius(vec2 uv_minus_center, vec4 radius) { + vec2 xy = (uv_minus_center.x > 0.0) ? radius.xy : radius.zw; + float r = (uv_minus_center.y > 0.0) ? xy.x : xy.y; + return r; + } + """, fragment_200=""" + vec2 center = u_model_size.xy / 2.0; + vec2 uv = (v_tex_coord.xy * u_model_size.xy); + + vec2 uv_minus_center = uv - center; + float radius = get_radius(uv_minus_center, u_radius); + + vec4 color = texture2D(tex0, v_tex_coord); + + if (u_relative != 0.0) { + float side_size; + if (u_relative == 1.0) { + side_size = u_model_size.x; + } else if (u_relative == 2.0) { + side_size = u_model_size.y; + } else if (u_relative == 3.0) { + side_size = min(u_model_size.x, u_model_size.y); + } else { + side_size = max(u_model_size.x, u_model_size.y); + } + + radius *= side_size; + } + + if (u_outline_width > 0.0) { + vec2 center_outline = center - u_outline_width; + + float crop1 = rounded_rectangle(uv - center, center, radius); + float crop2 = rounded_rectangle(uv - center, center_outline, radius - u_outline_width); + + float coeff1 = smoothstep(1.0, -1.0, crop1); + float coeff2 = smoothstep(1.0, -1.0, crop2); + + float outline_coeff = (coeff1 - coeff2); + + gl_FragColor = mix(vec4(0.0), mix(color, u_outline_color, outline_coeff), coeff1); + } + else { + float crop = rounded_rectangle(uv_minus_center, center, radius); + gl_FragColor = mix(color, vec4(0.0), smoothstep(0.0, 1.0, crop)); + } + """) \ No newline at end of file diff --git a/game/src/splashscreen.rpy b/game/src/splashscreen.rpy index e520124..cfaf3bc 100644 --- a/game/src/splashscreen.rpy +++ b/game/src/splashscreen.rpy @@ -1,24 +1,10 @@ label splashscreen: - $ persistent.splashtype = random.randint(0,2000 - 1) - - image caveintrosequence: - "caveintro" - alpha 0 - time 0.5 - linear 3.5 alpha 1 - time 10 - linear 1 alpha 0 - - show caveintrosequence - play sound 'audio/OST/startup.ogg' - pause 11.2 - stop sound - - if (persistent.languaged_up is None): - $ persistent.languaged_up = True - $ preferences.set_volume('ui', config.default_sfx_volume) # hack - call screen lang_sel + if not renpy.get_autoreload(): + show caveintrosequence + play sound 'audio/OST/startup.ogg' + pause 11.2 + stop sound return diff --git a/game/src/translation.rpy b/game/src/translation.rpy index 1bc58a2..2cca593 100644 --- a/game/src/translation.rpy +++ b/game/src/translation.rpy @@ -36,7 +36,7 @@ init python: notice = _("NOTICE: Please keep in mind this is a fan translation, and as such it may not be completely accurate to the original intent of any written lines.") languages = [ - {'image': 'gui/flag/USofA.png', 'name': 'English', 'value': None }, + {'image': 'gui/flag/USofA.png', 'name': 'English', 'value': 'en' }, {'image': 'gui/flag/Mexico.png', 'name': 'Español', 'value': 'es'}, {'image': 'gui/flag/Rus.png', 'name': 'Русский', 'value': 'ru'}, {'image': 'gui/flag/Poland.png', 'name': 'Polski', 'value': 'pl'}, @@ -106,7 +106,7 @@ screen lang_sel(): imagebutton: idle darkie(languages[i]["image"]) hover glowie(languages[i]["image"]) - action If(languages[i]["value"] in persistent.seenWarning or languages[i]["value"] == None, + action If(languages[i]["value"] in persistent.seenWarning or languages[i]["value"] == 'en', true = [Language(languages[i]["value"]), MainMenu(False,False)], # Important to change the language before calling notice. Otherwise it will be in english. false = [Language(languages[i]["value"]), AddToSet(set=persistent.seenWarning, value=languages[i]["value"]), Show(screen="OkPrompt", message=notice, go_menu=True)] @@ -122,7 +122,7 @@ screen lang_button(lang): spacing 15 textbutton lang["name"]: activate_sound "audio/ui/uiRollover.wav" - action If(lang["value"] in persistent.seenWarning or lang["value"] == None, + action If(lang["value"] in persistent.seenWarning or lang["value"] == 'en', true = [Language(lang["value"])], false = [Language(lang["value"]), AddToSet(set=persistent.seenWarning, value=lang["value"]), Show(screen="OkPrompt", message=notice, go_menu=False)] ) diff --git a/game/tl/en/mod-stuff-goes-here.txt b/game/tl/en/mod-stuff-goes-here.txt new file mode 100644 index 0000000..e69de29