diff --git a/.gitignore b/.gitignore index 66f1dff62..6de8b3347 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,12 @@ reports/ # Localisation files: *.mo +# Specifies the version for compilation. +src/bee_version.txt # PyInstaller temporary file directory /build_tmp/ + +# Folders for DLLs we need. +lib-32/ +lib-64/ diff --git a/BEE2.fgd b/BEE2.fgd index e4cf264d8..621891619 100644 --- a/BEE2.fgd +++ b/BEE2.fgd @@ -151,6 +151,15 @@ ] ] +@SolidClass = bee2_editor_occupiedvoxel: "Defines a region the item takes up. Depending on the resultant option, this can be positioned in a variety of ways: \n" + + "- Align to the 128x128 grid, to specify full voxels.\n" + + "- Make it thinner than 8 units, aligned to the side of a voxel to indicate a side.\n" + + "- Make it a multiple of 32 units to specify a set of 1/4 voxels." + [ + coll_type(string) : "Collide Type" : "SOLID" : "Types of collision for this item." + coll_against(string) : "Collide Against" : "" : "Types this collides against." + ] + // Entities added to the finished map to pass information to VRAD. @PointClass diff --git a/FAQ.md b/FAQ.md index ce0b6394c..55fa22e21 100644 --- a/FAQ.md +++ b/FAQ.md @@ -28,7 +28,7 @@ No. This was the case with the High Energy Pellet items in the original BEEmod, BEE2.4 supports Portal 2 and Aperture Tag. Thinking With Time Machine support is planned, but not yet implemented. (BEE2.4 can be used with TWTM, but you will not have the time machine and several other things will be broken.) Destroyed Aperture support is also planned once that mod is released. -### What about Portal 2: Community Edition? Will BEE2.4 be compatible with that, will it make any new things possible? +### Will BEE2.4 be compatible with Portal 2: Community Edition, will that make any new things possible? Theoretically, P2CE would expand what is possible with BEEmod; however, the P2CE developers were unable to obtain the Puzzlemaker source code from Valve, so it won't be included. They will likely implement their own puzzlemaker from scratch at some point in the future, but it is not currently being worked on and would likely be incompatible with BEE. @@ -111,8 +111,12 @@ The Source engine has very strict limits on toggleable lights. Only two dynamic `puzzlemaker_export` does not work with BEEmod, as the custom compiler isn't able to modify the map and make it actually functional. Before reading on however, keep in mind that exporting Puzzlemaker maps to Hammer is usually not the best idea. If you want to make extensive modifications to a map, or are just starting out learning Hammer, it's generally easier to build a map from scratch, as Puzzlemaker maps are generated in a complex way making them difficult to edit by hand. Only consider exporting if you just want to make small adjustments to a map, and already have experience using Hammer. -If this is still what you want to do, first set Spawn Point to Elevator and disable Restart When Reaching Exit in the BEE2 app. Compile your map in Puzzlemaker, then open `maps/styled/preview.vmf` in Hammer and resave it to a different location so it doesn't get overwritten. Additionally, check your Build Programs settings to make sure that you're using the BEE2 version of VRAD (simply called `vrad.exe`), as this is needed for some features such as music to work. +If this is still what you want to do, first go into the BEE2 app and set Spawn Point to Elevator and disable Restart When Reaching Exit. Compile your map in Puzzlemaker, then open `maps/styled/preview.vmf` in Hammer and resave it to a different location so it doesn't get overwritten. Additionally, check your Build Programs settings to make sure that you're using the BEE2 version of VRAD (simply called `vrad.exe`), as this is needed for some features such as music to work. ### Why is BTS being removed? The BTS style was made back before BEE2.4, and our direction has long since changed. The style doesn't work in the Puzzlemaker and it has become increasingly difficult to maintain and develop. As such, we've decided that it's best to remove it, at least for now. The style might make a return if we have time to develop it or if someone else in the community steps in to work on it, but it would likely take on a different form, and nothing regarding this is being worked on right now. + +### Will you add Desolation's Industrial style to BEE2.4? + +No. The Industrial style is fairly complex and would be difficult to implement in the Puzzlemaker, requiring lots of compiler modifications. It also relies on many custom assets which would need to be packed into the map, increasing its file size substantially. Even if ways around both of these things were found, the style also makes use of custom features of Desolation's engine, which can't be replicated in standard Portal 2.\n\nImplementing the style within Desolation itself would solve the latter two issues, but Desolation won't be able to use Valve's puzzlemaker for technical reasons, and the developers currently have no plans to write their own. It also wouldn't solve the first problem, which is actually generating Industrial maps.\n\nAdditionally, see the question above about new styles in general. \ No newline at end of file diff --git a/README.md b/README.md index ced178dcb..62661fb2e 100644 --- a/README.md +++ b/README.md @@ -40,22 +40,28 @@ As of version 4.37 we have stopped supporting BEE2.4 on Mac. See [this wiki arti ## Building from Source ## ### Compilation ### -First, grab the 3 git repositories you need: - git clone https://github.com/TeamSpen210/HammerAddons.git - git clone https://github.com/BEEmod/BEE2.4.git +* You'll need Python 3.8 or later, for 32-bit / Windows 7 you need 3.8 specifically. +* First, grab the 3 git repositories you need: -Run `python -m pip install -r requirements.txt` to install the required packages. On Linux, + git clone https://github.com/TeamSpen210/HammerAddons.git + git clone https://github.com/BEEmod/BEE2.4.git + +* Run `python -m pip install -r requirements.txt` to install the required packages. On Linux, Pillow might need to be installed via the system package manager with the TK component: `python-pillow`, `python-pillow.imagetk`. -Finally, switch to the BEE2.4 repo and build the compiler, then the application: +* To allow sound effects in the app, you need a copy of FFmpeg: + * In the `BEE2.4` folder, add `lib-32` and/or `lib-64/` folders. + * Download the [32-bit](https://github.com/sudo-nautilus/FFmpeg-Builds-Win32/releases) or [64-bit](https://github.com/BtbN/FFmpeg-Builds/releases) builds (`winXX-lgpl-shared`), then copy the contents of the `bin` folder into the appropriate `lib-XX` folder mentioned. + +* Finally, switch to the BEE2.4 repo and build the compiler, then the application: - cd BEE2.4/src/ - pyinstaller --distpath ../dist/64bit/ --workpath ../build_tmp compiler.spec - pyinstaller --distpath ../dist/64bit/ --workpath ../build_tmp BEE2.spec + cd BEE2.4/src/ + pyinstaller --distpath ../dist/64bit/ --workpath ../build_tmp compiler.spec + pyinstaller --distpath ../dist/64bit/ --workpath ../build_tmp BEE2.spec -The built application is found in `BEE2.4/dist/64bit/BEE2/`. +* The built application is found in `BEE2.4/dist/64bit/BEE2/`. To generate the packages zips, either manually zip the contents of each folder or use the `compile_packages` script in BEE2-items. This does the same thing, but additionally removes some unnessary content diff --git a/dev-requirements.txt b/dev-requirements.txt index 25c76d813..5e6a74640 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ mypy==0.782 mypy-extensions==0.4.3 pytest>=6.2.2 Cython==0.29.21 +types-Pillow~=8.3.0 diff --git a/dev/Discohook/faq/qna/community edition.json b/dev/Discohook/faq/qna/community edition.json index af7f41c74..346edd3b2 100644 --- a/dev/Discohook/faq/qna/community edition.json +++ b/dev/Discohook/faq/qna/community edition.json @@ -9,7 +9,7 @@ "content": null, "embeds": [ { - "title": "What about Portal 2: Community Edition? Will BEE2.4 be compatible with that, will it make any new things possible?", + "title": "Will BEE2.4 be compatible with Portal 2: Community Edition, will that make any new things possible?", "description": "Theoretically, P2CE would expand what is possible with BEEmod; however, the P2CE developers were unable to obtain the Puzzlemaker source code from Valve, so it won't be included. They will likely implement their own puzzlemaker from scratch at some point in the future, but it is not currently being worked on and would likely be incompatible with BEE.", "color": 16761704 } diff --git a/dev/Discohook/faq/qna/desolation industrial.json b/dev/Discohook/faq/qna/desolation industrial.json index 1b800aa10..010b918dc 100644 --- a/dev/Discohook/faq/qna/desolation industrial.json +++ b/dev/Discohook/faq/qna/desolation industrial.json @@ -10,7 +10,7 @@ "embeds": [ { "title": "Will you add Desolation's Industrial style to BEE2.4?", - "description": "No. The Industrial style is fairly complex and would be difficult to implement in the Puzzlemaker, requiring lots of compiler modifications. It also relies on many custom assets which would need to be packed into maps, exceeding the Workshop file size limit very easily. Even if ways around both of these things were found, the style also makes use of custom features of Desolation's engine, which can't be replicated in standard Portal 2.\n\nImplementing the style within Desolation itself would solve the latter two issues, but Desolation won't be able to use Valve's puzzlemaker for technical reasons, and the developers currently have no plans to write their own. It also wouldn't solve the first problem, which is actually generating Industrial maps.\n\nAdditionally, see the question above about new styles in general.", + "description": "No. The Industrial style is fairly complex and would be difficult to implement in the Puzzlemaker, requiring lots of compiler modifications. It also relies on many custom assets which would need to be packed into the map, increasing its file size substantially. Even if ways around both of these things were found, the style also makes use of custom features of Desolation's engine, which can't be replicated in standard Portal 2.\n\nImplementing the style within Desolation itself would solve the latter two issues, but Desolation won't be able to use Valve's puzzlemaker for technical reasons, and the developers currently have no plans to write their own. It also wouldn't solve the first problem, which is actually generating Industrial maps.\n\nAdditionally, see the question above about new styles in general.", "color": 16761704 } ], diff --git a/dev/Discohook/faq/qna/hammer export.json b/dev/Discohook/faq/qna/hammer export.json index 20f9002ff..766cadd33 100644 --- a/dev/Discohook/faq/qna/hammer export.json +++ b/dev/Discohook/faq/qna/hammer export.json @@ -10,7 +10,7 @@ "embeds": [ { "title": "I exported my BEEmod map to Hammer and it won't compile/music won't play/.", - "description": "`puzzlemaker_export` does not work with BEEmod, as the custom compiler isn't able to modify the map and make it actually functional. Before reading on however, keep in mind that exporting Puzzlemaker maps to Hammer is usually not the best idea. If you want to make extensive modifications to a map, or are just starting out learning Hammer, it's generally easier to build a map from scratch rather than trying to modify a Puzzlemaker map. Only consider exporting if you just want to make small adjustments to a map, and already have experience using Hammer.\n\nIf this is what you want to do, first set Spawn Point to Elevator and disable Restart When Reaching Exit in the BEE2 app. Compile your map in Puzzlemaker, then open `maps/styled/preview.vmf` in Hammer and resave it to a different location so it doesn't get overwritten. Additionally, check your Build Programs settings to make sure that you're using the BEE2 version of VRAD (simply called `vrad.exe`), as this is needed for some features such as music to work.", + "description": "`puzzlemaker_export` does not work with BEEmod, as the custom compiler isn't able to modify the map and make it actually functional. Before reading on however, keep in mind that exporting Puzzlemaker maps to Hammer is usually not the best idea. If you want to make extensive modifications to a map, or are just starting out learning Hammer, it's generally easier to build a map from scratch rather than trying to modify a Puzzlemaker map. Only consider exporting if you just want to make small adjustments to a map, and already have experience using Hammer.\n\nIf this is what you want to do, first go into the BEE2 app and set Spawn Point to Elevator and disable Restart When Reaching Exit. Compile your map in Puzzlemaker, then open `maps/styled/preview.vmf` in Hammer and resave it to a different location so it doesn't get overwritten. Additionally, check your Build Programs settings to make sure that you're using the BEE2 version of VRAD (simply called `vrad.exe`), as this is needed for some features such as music to work.", "color": 16761704 } ], diff --git a/dev/Discohook/faq/qna/new styles.json b/dev/Discohook/faq/qna/new styles.json index f8ba2a2d0..fb41ba44b 100644 --- a/dev/Discohook/faq/qna/new styles.json +++ b/dev/Discohook/faq/qna/new styles.json @@ -10,7 +10,7 @@ "embeds": [ { "title": "Can you add new style *x*?", - "description": "Styles take a lot of work to create and maintain, so new ones are rarely added. For a new style to even be considered, it should make sense overall, work with most/all of Portal 2's test elements, and be possible to reasonably implement in the Puzzlemaker.\n\nAs an example, Portal 1 is a style that fits this criteria, as most of Portal 2's test elements can reasonably exist there and it has a block-based structure similar to Clean. On the other hand, BTS style typically doesn't use test elements like buttons and cubes, and it's very difficult to make it look good in the Puzzlemaker. The only reason BTS was added is because these guidelines didn't exist yet at the time.\n\nIf your suggested style is a simple variant of an existing style (e.g. Original Clean, which just changes wall textures), it may be added. Custom packages can also add their own styles, so feel free to implement a new style yourself.", + "description": "Styles take a lot of work to create and maintain, so new ones are rarely added. For a new style to even be considered, it should make sense overall, work with most/all of Portal 2's test elements, and be possible to reasonably implement in the Puzzlemaker.\n\nAs an example, Portal 1 is a style that fits this criteria, as most of Portal 2's test elements can reasonably exist there and it has a block-based structure similar to Clean. On the other hand, BTS style typically doesn't use test elements like buttons and cubes, and it's very difficult to make it look good in the Puzzlemaker, hence why it was removed; had this been considered back in 2015, the style never would have been added to begin with.\n\nIf your suggested style is a simple variant of an existing style (e.g. Original Clean, which just changes wall textures), it may be added. Custom packages can also add their own styles, so feel free to implement a new style yourself.", "color": 16761704 } ], diff --git a/dev/Discohook/faq/qna/p1 glados voicelines.json b/dev/Discohook/faq/qna/p1 glados voicelines.json index 8411f60b0..92437f495 100644 --- a/dev/Discohook/faq/qna/p1 glados voicelines.json +++ b/dev/Discohook/faq/qna/p1 glados voicelines.json @@ -10,7 +10,7 @@ "embeds": [ { "title": "Why doesn't Portal 1 GLaDOS have any lines?", - "description": "The Portal 1 GLaDOS lines aren't in Portal 2 by default, and as stated below, it's not possible to pack voice lines into maps. The plan is to use existing lines that are similar to the P1 dialogue, but this hasn't been implemented yet.", + "description": "The Portal 1 GLaDOS lines aren't in Portal 2 by default, and as stated above, it's not possible to pack voice lines into maps. In the next release, existing lines which are similar to the P1 dialogue will be used.", "color": 16761704 } ], diff --git a/dev/Discohook/welcome/info.json b/dev/Discohook/welcome/info.json index e91bbacae..d897ed6c2 100644 --- a/dev/Discohook/welcome/info.json +++ b/dev/Discohook/welcome/info.json @@ -16,13 +16,21 @@ "name": "> GitHub Repositories (repos)", "value": "> BEEmod is split into two repositories. One for the app itself, and the other is for items and in-editor / in-game content. Issues and suggestions related to the BEE2 application (executable) should go on BEE2.4, anything else on BEE2-items. If you have a question regarding BEE2.4, first check the FAQ, and if you can't find your answer there you can ask in one of the other channels." }, - { - "name": "> Enrolling", - "value": "> To receive the Test Subject role and gain access to all of the chat channels on this server, head to <#605131357533896864> and use the command `?enroll`. For security reasons, you will need to have a verified phone number on your Discord account in order to post here. If you still do not receive the role after using this command, it likely means Dyno was offline. Post a message in the channel and we will assign it manually." - }, { "name": "> Contributors", "value": "> The Contributor role is given to anybody who has contributed some sort of content to the BEE2.4. Typically this role will not be given for creating a separate tool/pack or BEE2.2 content, although this is not always the case." + }, + { + "name": "> User Created Packages (UCPs)", + "value": "> User Created Packages are community made addon packages for BEEmod. They are not directly supported by the team and may break. Use them at your own discretion. If you use UCPs make sure you test issues with UCPs disabled before reporting." + }, + { + "name": "> Beta Testers", + "value": "> Beta Testers are people who receive beta builds of the app. Beta builds tend to be less stable so these testers catch any mistakes the development team might have made. Beta Testers are hand picked by the development team, so don't ask to receive the role." + }, + { + "name": "> BEEmod Community", + "value": "> The community section of the BEEmod discord is reserved for people who both want it and can follow the rules. Its a more relaxed section of the server where you can talk about more general stuff. In order to opt in, you must introduce yourself in <#901350687047315496>" } ] } diff --git a/dev/Discohook/welcome/rules.json b/dev/Discohook/welcome/rules.json index 717905fe1..887594c42 100644 --- a/dev/Discohook/welcome/rules.json +++ b/dev/Discohook/welcome/rules.json @@ -10,58 +10,9 @@ "embeds": [ { "title": "--------------- RULES ----------------", - "color": 16761704, - "fields": [ - { - "name": ":zero::one: Act mature.", - "value": "> Use your better judgement, just because you can post something doesn't mean you should. Don’t be excessively childish or try to trick people into something. Don't start unnecessary drama." - }, - { - "name": ":zero::two: Post with purpose", - "value": "> No low effort posts. We like jokes here and try to keep the atmosphere light; however, this is not a meme server. Do not post messages or media with no context or purpose. Try not to post content outside of their respective channels. Posting low effort media with no context will result in a soft mute. You can request to be unmuted after a day if you show you have an understanding of why you were soft muted." - }, - { - "name": ":zero::three: Listen to moderators", - "value": "> If a moderator tells you to stop doing something then stop doing it, now and in the future. We try to keep this discord well organized as its main purpose is help and feedback for BEEmod. Do not backseat moderate. Its fine to explain to someone if they are breaking a rule or might be posting in the wrong channel, but do not act like you have authority. If someone is breaking rules then ping @ CentralCore as that is our moderation team." - }, - { - "name": ":zero::four: Keep discussion in English.", - "value": "> We cannot moderate other languages. If you're talking to someone in a different language, take the discussion to DMs." - }, - { - "name": ":zero::five: Do not post any sort of NSFW content.", - "value": "> This also includes things on your profile such as your avatar. Nudity, gore, or topics that are not safe for work should absolutely never shown here." - }, - { - "name": ":zero::six: Do not spam.", - "value": "> This one should be obvious, but don't post messages rapidly or use a load of emojis/reactions. A good rule is more than 5 is a waste. If you are found to have spammed something in multiple discords, or DM people in the discord randomly or without consent, you will be banned. Showcasing content in multiple discords is fine, as long as you are being respectful and posting in the right channels." - }, - { - "name": ":zero::seven: Do not abuse mentions.", - "value": "> Only ping the Central Core role when a user is breaking the rules. `@everyone` and `@here` are turned off, don't try to use them." - }, - { - "name": ":zero::eight: Know what you're talking about when giving help", - "value": "> Do not try help someone if you do not have experience. \n> For example: don't try to help people with Hammer unless you actually use hammer." - }, - { - "name": ":zero::nine: This is not an issue tracker", - "value": "> Do not use this server for bug reports or feature requests. This is not referring to diagnosing an issue or asking for help. Bug reports should be posted on the Issues page of the correct GitHub repositories (More information below). Don't beg or @ devs for help, a lot of us are busy and we will help when we are available. You are expected to be open to help, if you refuse to listen then we will not help you." - }, - { - "name": ":one::zero: Use typing etiquette", - "value": "> Keep messages easy to read. Don't rapidly post multiple messages, instead take time to type out your message in full. Don't roleplay or use typing quirks." - }, - { - "name": ":one::one: Feedback is expected", - "value": "If you showcase content, it is under the assumption that you're looking for feedback or help. Try to be open minded and listen to people. If you are giving feedback; do not attack people for their skill level, quality of work, or how they choose to map. Be respectful of the other person while giving feedback." - }, - { - "name": ":one::two: Rules are rules", - "value": "You are expected to have read all the rules when you join the server or when they have been updated. Claiming to not have read the rules is not an excuse. Repeat offenders will be muted, kicked or banned. Punishments are usually case by case and decided by moderators." - } - ] - } + "description": ">>> :one: - Listen to Staff.\n:two: - English discussion only.\n:three: - Do not post NSFW, hate speech, slurs, spam, or suspicious files/links.\n:four: - No low effort content, memes, or random/disruptive messages.\n:five: - Use your better judgement.", + "color": 16761704 + } ], "username": "BLoBDOS" }, diff --git a/i18n/BEE2.pot b/i18n/BEE2.pot index d8b06b94d..0da80a527 100644 --- a/i18n/BEE2.pot +++ b/i18n/BEE2.pot @@ -4,14 +4,14 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: https://github.com/BEEmod/BEE2.4/issues\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" #: loadScreen.py msgid "Skipped!" @@ -25,7 +25,7 @@ msgstr "" msgid "Cancel" msgstr "" -#: app/UI.py loadScreen.py +#: app/paletteUI.py loadScreen.py msgid "Clear" msgstr "" @@ -69,7 +69,7 @@ msgstr "" msgid "Better Extended Editor for Portal 2" msgstr "" -#: utils.py +#: localisation.py msgid "__LANG_USE_SANS_SERIF__" msgstr "" @@ -210,6 +210,12 @@ msgstr "" msgid "Full" msgstr "" +#: app/CompilerPane.py +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + #: app/CompilerPane.py msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " @@ -283,6 +289,12 @@ msgstr "" msgid "Elevator" msgstr "" +#: app/CompilerPane.py +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + #: app/CompilerPane.py msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " @@ -405,6 +417,7 @@ msgid "" "enabled, colored frames will be added to distinguish them." msgstr "" +#. i18n: StyleVar default value. #: app/StyleVarPane.py msgid "Default: On" msgstr "" @@ -413,10 +426,12 @@ msgstr "" msgid "Default: Off" msgstr "" +#. i18n: StyleVar which is totally unstyled. #: app/StyleVarPane.py msgid "Styles: Unstyled" msgstr "" +#. i18n: StyleVar which matches all styles. #: app/StyleVarPane.py msgid "Styles: All" msgstr "" @@ -570,43 +585,15 @@ msgid "" "editor wall previews are changed." msgstr "" -#: app/UI.py -msgid "Delete Palette \"{}\"" -msgstr "" - -#: app/UI.py -msgid "BEE2 - Save Palette" -msgstr "" - -#: app/UI.py -msgid "Enter a name:" -msgstr "" - -#: app/UI.py -msgid "This palette already exists. Overwrite?" -msgstr "" - -#: app/UI.py app/gameMan.py -msgid "Are you sure you want to delete \"{}\"?" -msgstr "" - -#: app/UI.py -msgid "Clear Palette" -msgstr "" - -#: app/UI.py app/UI.py -msgid "Delete Palette" -msgstr "" - #: app/UI.py msgid "Save Palette..." msgstr "" -#: app/UI.py app/UI.py +#: app/UI.py app/paletteUI.py msgid "Save Palette As..." msgstr "" -#: app/UI.py app/UI.py +#: app/UI.py app/paletteUI.py msgid "Save Settings in Palettes" msgstr "" @@ -684,14 +671,6 @@ msgstr "" msgid "Palette" msgstr "" -#: app/UI.py -msgid "Fill Palette" -msgstr "" - -#: app/UI.py -msgid "Save Palette" -msgstr "" - #: app/UI.py msgid "View" msgstr "" @@ -708,6 +687,18 @@ msgstr "" msgid "Fill empty spots in the palette with random items." msgstr "" +#: app/__init__.py +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + #: app/backup.py msgid "Copying maps" msgstr "" @@ -1042,6 +1033,10 @@ msgid "" " (BEE2 will quit, this is the last game set!)" msgstr "" +#: app/gameMan.py app/paletteUI.py +msgid "Are you sure you want to delete \"{}\"?" +msgstr "" + #: app/helpMenu.py msgid "Wiki..." msgstr "" @@ -1127,6 +1122,14 @@ msgstr "" msgid "No Properties available!" msgstr "" +#: app/itemPropWin.py +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py msgid "Search:" msgstr "" @@ -1454,6 +1457,14 @@ msgstr "" msgid "Dump Items list" msgstr "" +#: app/optionWindow.py +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py msgid "BEE2 - Restart Required!" msgstr "" @@ -1488,10 +1499,62 @@ msgstr "" msgid "Portal 2 Collapsed" msgstr "" +#: app/paletteUI.py +msgid "Clear Palette" +msgstr "" + +#: app/paletteUI.py app/paletteUI.py +msgid "Delete Palette" +msgstr "" + +#: app/paletteUI.py +msgid "Change Palette Group..." +msgstr "" + +#: app/paletteUI.py +msgid "Rename Palette..." +msgstr "" + +#: app/paletteUI.py +msgid "Fill Palette" +msgstr "" + +#: app/paletteUI.py +msgid "Save Palette" +msgstr "" + +#: app/paletteUI.py +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py +msgid "Delete Palette \"{}\"" +msgstr "" + +#: app/paletteUI.py app/paletteUI.py +msgid "BEE2 - Save Palette" +msgstr "" + +#: app/paletteUI.py app/paletteUI.py +msgid "Enter a name:" +msgstr "" + +#: app/paletteUI.py +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + #: app/richTextBox.py msgid "Open \"{}\" in the default browser?" msgstr "" +#: app/selector_win.py +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description #: app/selector_win.py msgid "Do not add anything." @@ -1502,16 +1565,12 @@ msgstr "" msgid "" msgstr "" -#: app/selector_win.py app/selector_win.py -msgid "Suggested" -msgstr "" - #: app/selector_win.py msgid "Play a sample of this item." msgstr "" #: app/selector_win.py -msgid "Reset to Default" +msgid "Select Suggested" msgstr "" #: app/selector_win.py @@ -1529,6 +1588,10 @@ msgstr[1] "" msgid "Color: R={r}, G={g}, B={b}" msgstr "" +#: app/selector_win.py app/selector_win.py +msgid "Suggested" +msgstr "" + #: app/signage_ui.py app/signage_ui.py msgid "Configure Signage" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index cbc0aeae1..0663f3a74 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -12,91 +12,91 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "¡Saltado!" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "Versión: " -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "Cancelar" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "Limpiar" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "Copiar" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "Mostrar:" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "Registros - {}" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "Mensajes de Depuración" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "Predeterminado" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "Sólo advertencias" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "Paquetes" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "Cargando Objetos" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "Iniciando Interfaz" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "Alternar todas las casillas." -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "ATLAS" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "P-Body" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Chell" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Bendy" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " @@ -107,7 +107,7 @@ msgstr "" "tamaño del mapa o eliminar formas complejas del mismo." #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 #, fuzzy msgid "" "Entities are the things in the map that have functionality. Removing " @@ -125,7 +125,7 @@ msgstr "" "los mismos." #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 #, fuzzy msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " @@ -136,11 +136,11 @@ msgstr "" "pueden ser los símbolos, indicadores o las lineas de conexión. Ocultando " "largas lineas de conexión o cambiándolas a indicadores reducirá esto." -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "Pasillo" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." @@ -148,15 +148,15 @@ msgstr "" "Seleccionar un pasillo de forma aleatoria. Esto es guardado en los datos " "del puzzle y no cambiará." -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "Aleatorio" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "Archivos de Imagenes" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." @@ -164,35 +164,35 @@ msgstr "" "Las opciones en este panel se pueden cambiar sin hacer falta exportar o " "reiniciar el juego." -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "Configuración del Mapa" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "Configuración de la Compilación" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" msgstr "Miniatura" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "Automático" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "PeTI" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "Personalizado:" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "Eliminar capturas de pantalla antiguas" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -205,11 +205,11 @@ msgstr "" "recientemente (en las últimas horas), se utilizará una captura por " "defecto de la visión del editor del juego." -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "Utiliza la imagen por defecto del editor del juego." -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" @@ -219,7 +219,7 @@ msgstr "" "tu mapa. Haz clic en el rectángulo negro de abajo para seleccionarla. \n" "Las imágenes serán convertidas a formato JPEG de ser necesario." -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " @@ -228,19 +228,25 @@ msgstr "" "utilizadas. Deshabilita esta casilla si deseas mantener esas capturas. " "Las mismas se encuentran en: \"portal2/screenshots\"." -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "Iluminación:" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "Rápida" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "Completa" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" @@ -251,7 +257,7 @@ msgstr "" "incorrectas. En el momento que se publica el mapa, esta opción no se " "tendrá en cuenta." -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 #, fuzzy msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " @@ -263,11 +269,11 @@ msgstr "" "efectos lumínicos. En el momento que se publica el mapa, esto siempre se " "activará de forma automática." -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "Volcar archivos empacados a:" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." @@ -276,19 +282,19 @@ msgstr "" "fueron empacados dentro del mapa. Útil si tienes la intención de editar " "los mapas en Hammer." -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "Última Compilación:" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "Entidad" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "Overlays" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." @@ -297,19 +303,19 @@ msgstr "" "Pulse después de que se haya realizado una compilación para mostrar los " "nuevos valores." -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" msgstr "Bloque" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "Líneas de voz:" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "Usar líneas de voces prioritarias" -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " @@ -320,19 +326,25 @@ msgstr "" " prueba en el mapa. Si se deshabilita, cualquier línea aplicable será " "utilizada." -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "Aparecer en:" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "Puerta de entrada" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "Elevador" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 #, fuzzy msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " @@ -343,7 +355,7 @@ msgstr "" "si se llega atravesar la puerta de salida. Utilizar esta opción si deseas" " examinar los pasillos de entrada y salida del mapa." -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 #, fuzzy msgid "When previewing in SP, spawn just before the entry door." msgstr "" @@ -351,63 +363,63 @@ msgstr "" "antes de la puerta de entrada. Cuando atravieses la puerta de salida, el " "mapa se reiniciará." -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "Pasillo:" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" msgstr "" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" msgstr "Entrada SP:" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" msgstr "Salida SP:" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "Modelo del jugador (SP):" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "Opciones de Compilación" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "Opciones de Compilación - {}" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "Voz de Cave Johnson (Workshop)" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "" "Reproduce la voz de Cave Johnson que fue programada para el Workshop en " "el comienzo del mapa." -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" msgstr "Prevenir trucos con los portales (En desintegrador)" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" @@ -416,19 +428,19 @@ msgstr "" "Añade un volumen anti-portal que hace más difícil el colocar portales a " "través de los bordes del desintegrador." -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" msgstr "Suprimir el diálogo de mitad de cámara" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "Deshabilita todas las líneas de voz exceptuando las de entrada y salida." -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "Desbloquear elementos obligatorios" -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" @@ -437,13 +449,13 @@ msgstr "" " como la gran sala de observación. Solo recomendable con la gran sala de " "observación. Usar con precaución, ¡esto puede dar resultados extraños!" -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "" "Permitir agregar vapor sobre la \n" "superficie del pringue mortal" -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " @@ -454,12 +466,12 @@ msgstr "" "creado grandes y complejos pozos de ácido, así que deshabilítalo si es " "necesario." -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 #, fuzzy msgid "Light Reversible Excursion Funnels" msgstr "Iluminar Embudos de Translación Reversibles" -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -472,11 +484,11 @@ msgstr "" "prevenir esto desabilitando las luces. Los Embudos de translación no " "reversibles no tienen este problema." -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "Habilitar el encuadre de formas" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." @@ -484,74 +496,77 @@ msgstr "" "Después de usar 10 indicador de forma, los carteles se repiten. Con esto " "activado, se añadirán marcos coloreados para poder distingirlos." -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "Predeterminado: Activado" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "Predeterminado: Desactivado" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "Estilos: Sin estilo" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "Estilos: Todos" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "Estilo: {}" msgstr[1] "Estilos: {}" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 #, fuzzy msgid "Style/Item Properties" msgstr "Propiedades de Estilo/ítem" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "Estilos" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "Todos:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "Estilo seleccionado:" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "Otros Estilos:" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "¡Sin Opciones!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "¡Ninguno!" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "Ítems" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "Oculta/Muestra las \"{}\" ventanas." -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "Exportar..." -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "Seleccionar Cielos" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " @@ -563,19 +578,19 @@ msgstr "" "precipicios, así como el color de la \"niebla\" vistos en las cámaras " "largas." -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "Skybox 3D" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "Color de Niebla" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "Selecciona línea de voces adicionales" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " @@ -587,31 +602,31 @@ msgstr "" "elementos presentes en el mapa. Las líneas de voz adicionales de Cave " "Johnson se controlan por separado en las propiedades del estilo." -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "No añadir voces de línea adicionales, sólo Cave (Workshop)." -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "Personajes" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "Monitor de Disparos de Torreta" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "Visuales de Monitor" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "Selecciona Estilo" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -627,15 +642,15 @@ msgstr "" "El Estilo define ampliamente el período de tiempo en el que se establece " "una cámara de pruebas." -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "Videos del Elevador" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "Selecciona el video del Elevador" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " @@ -646,23 +661,23 @@ msgstr "" "seleccionado \"Ninguno\", se elegirá un vídeo aleatorio cada vez que se " "juegue el mapa, como en el editor de niveles del juego predeterminado." -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "Este estilo no cuenta con una pantalla en el elevador." -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "Elegir un video aleatorio." -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "Orientación Multiple" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" msgstr "¡Estilo y elementos exportados correctamente!" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" @@ -675,71 +690,43 @@ msgstr "" "Hammer para asegurarse de que las vistas previas de las paredes del " "editor han cambiado." -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "Borrar Paleta \"{}\"" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "BEE2 - Guardar Paleta" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "Introduzca un nombre:" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "Esta paleta ya existe. ¿Desea reemplazarla?" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "¿Estas seguro que deseas borrar \"{}\"?" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "Vaciar la Paleta" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "Borrar Paleta" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." msgstr "Guardar Paleta..." -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." msgstr "Guardar Paleta Como..." -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" msgstr "Guardar Ajustes en las Paletas" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "Música:" -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "{arr} Usar Recomendado {arr}" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "Estilo: " -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "Voz: " -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "Skybox: " -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "Vid Elev: " -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." @@ -747,317 +734,321 @@ msgstr "" "Habilita o deshabilita ciertas lineas de voces, para prevenir que sean " "añadidas." -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "Todos los Elementos: " -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "Exportar a \"{}\"..." -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "Archivo" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "Exportar" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "Añadir Juego" -#: app/UI.py:1694 +#: app/UI.py:1484 #, fuzzy msgid "Uninstall from Selected Game" msgstr "Borrar del Juego Seleccionado" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." msgstr "Copias de seguridad/Restaurar mapas..." -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "Administrar Paquetes..." -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "Ociones" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "Salir" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" msgstr "Paleta" -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "Rellenar Paleta" - -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "Guardar Paleta" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "Ver" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" msgstr "Paletas" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "Opciones de Exportación" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "Rellenar espacios vacíos de la paleta con elementos aleatorios." -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "Copiando mapas" -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "Cargando mapas" -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "Eliminando mapas" -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." msgstr "" "Error al analizar este mapa. Sigue siendo posible hacer una copia de " "seguridad." -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "No se ha encontrado una descripción." -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "Cooperativo" -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "Un solo jugador" -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" msgstr "" "Este nombre de archivo ya está en la copia de seguridad. Desea " "reemplazarlo? ({})" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" msgstr "Copia de seguridad de BEE2" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "¡No se ha elegido ningún mapa para realizar copias de seguridad!" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" msgstr "Este mapa ya está en el directorio del juego. ¿Desea reemplazarlo? ({})" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "Cargar Copia de Seguridad" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" msgstr "Copia de seguridad zip" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "Copia de seguridad no guardada" -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "Guardar Copia de Seguridad Como" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "Confirmar Eliminación" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" msgstr[0] "¿Desea eliminar {} mapa?\n" msgstr[1] "¿Desea eliminar {} mapas?\n" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "Restaurar:" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" msgstr "Copia de Seguridad:" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "Marcado" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "Eliminar marcados" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" msgstr "BEEMOD {} - Copia de Seguridad / Restaurar Mapas" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "Nueva Copia de Seguridad" -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "Abrir Copia de Seguridad" -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "Guardar Copia de Seguridad" -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" msgstr "Borrar Juego" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "Juego" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" msgstr "Crear una Copia de Seguridad automáticamente después de exportar" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "Mantener (Por Juego):" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" msgstr "Copia De Seguridad / Restaurar Mapas" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "Este elemento no puede ser rotado." -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "Este elemento puede ser rotado en 4 direcciones." -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "Este elemento se puede colocar en los lados y en el centro." -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "Este elemento puede centrarse en dos direcciones y en los lados." -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "Este elemento puede ser rotado como una banda luminosa." -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "Este elemento se puede rotar en el piso para hacer frente a 360 grados." -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "" "Este elemento se posiciona mediante la trayectoria de la plataforma de " "salto." -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." msgstr "" "Este elemento posiciona el dispensador para alcanzar las ubicaciones del " "objetivo." -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "Este elemento no acepta ninguna conexión entrante." -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "Este elemento acepta conexiones entrantes." -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." msgstr "" "Este ítem tiene dos tipos de entrada (A y B), usando los ítems de Entrada" " A y Entrada B." -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." msgstr "Este elemento no tiene conexiones de salida." -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." msgstr "Este elemento tiene conexiones de salida." -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." msgstr "Este elemento tiene conexiones de salida temporizadas." -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "Este elemento no ocupa ningún espacio dentro de las paredes." -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "Este elemento ocupa espacio dentro de las paredes." -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "Este elemento no puede ser colocado en ningún lado..." -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "Este elemento solo puede ser colocado en los techos." -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "Este elemento solo puede ser colocado en el suelo." -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "Este elemento puede ser colocado en los techos y el suelo." -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "Este elemento solo puede ser colocado en las paredes." -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "Este elemento puede ser colocado en las paredes y los techos." -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "Este elemento puede ser colocado en el suelo y las paredes." -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "Este elemento puede ser colocado en el suelo, paredes y techos." -#: app/contextWin.py:226 +#: app/contextWin.py:227 #, fuzzy msgid "No Alternate Versions" msgstr "No hay otras versiones" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." msgstr "" "Los Embudos de Translación aceptan una conexión de " "activación/desactivación, así como una conexión direccional." -#: app/contextWin.py:371 +#: app/contextWin.py:372 #, fuzzy msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" @@ -1079,11 +1070,11 @@ msgstr "" "límite de 2048 en total. Esto proporciona una guía de cuantos de estos " "elementos pueden ser puestos en un mapa." -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "Descripción:" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" @@ -1091,25 +1082,25 @@ msgstr "" "Error al abrir un navegador web. ¿Desea que la URL sea copiada en el " "portapapeles?" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "Más Info>>" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." msgstr "Cambiar Ajustes por Defecto..." -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "" "Cambia la configuración predeterminada de este elemento cuando es " "colocado en el mapa." -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "BEE2 - ¡Exportación Fallida!" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." @@ -1118,14 +1109,14 @@ msgstr "" " pulse OK para verificar la integridad de los archivos. Después, podrás " "exportar de nuevo." -#: app/gameMan.py:859 +#: app/gameMan.py:881 #, fuzzy msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "" "Fallo a la hora de copiar el archivo de compilador {file}. Asegurese de " "que {game} no esta abierto.\n" -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." @@ -1134,50 +1125,50 @@ msgstr "" "Las pistolas del Coop no funcionarán. Verifique la integridad de los " "archivos para reparar el error." -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" msgstr "BEE2 - Falta de archivos de Aperture Tag" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "" "Selecciona el directorio donde se encuentre el ejecutable del juego " "({appname})..." -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "BEE2 - Añadir juego" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "Encontrar Ejecutable del Juego" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "Ejecutable" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" msgstr "¡Eso no parece ser un directorio de juego valido!" -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "¡Portal Stories: Mel no tiene un editor!" -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "Inserte el nombre de este juego:" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "¡Este nombre ya está en uso!" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "¡Por favor introduzca un nombre a este juego!" -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" @@ -1185,82 +1176,86 @@ msgstr "" "\n" " (BEE2 se cerrará, este es el último juego seleccionado!)" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "¿Estas seguro que deseas borrar \"{}\"?" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "" -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "Ítems Originales..." #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "Servidor de Discord..." -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "" -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "Repositorio de la Aplicación" -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "Repositorio de los Ítems..." -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "Reportar errores de la aplicación..." -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "Reportar errores de Ítems..." #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "Cerrar" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "Ayuda" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "Créditos de BEE2" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "Créditos..." -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "Posición de Inicio" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "Posición de Salida" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" @@ -1268,24 +1263,32 @@ msgstr "" "Retraso\n" "(0=infinito)" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "¡No hay propiedades disponibles!" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "Seleccione un color" -#: app/music_conf.py:132 +#: app/music_conf.py:147 #, fuzzy msgid "Select Background Music - Base" msgstr "Selecciona la música de fondo - Base" -#: app/music_conf.py:133 +#: app/music_conf.py:148 #, fuzzy msgid "" "This controls the background music used for a map. Expand the dropdown to" @@ -1295,7 +1298,7 @@ msgstr "" " tienen variaciones que se oyen al interactuar con ciertos elementos de " "prueba." -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." @@ -1303,53 +1306,53 @@ msgstr "" "No añadir ninguna música de fondo al mapa. La música de ciertos elementos" " de prueba específicos puede que sea añadida." -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" msgstr "SFX del Gel de Propulsión" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" msgstr "SFX del Gel de Repulsion" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "Música del embudo de translación" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "Música del Embudo de Translación sincronizada" -#: app/music_conf.py:152 +#: app/music_conf.py:168 #, fuzzy msgid "Select Excursion Funnel Music" msgstr "Selecciona la música del Embudo de Translación" -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "Selecciona la música usada al estar dentro de los Embudos de Translación." -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "No hay música al estar dentro de los Embudos de translación." -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "Seleccionar Música del Gel de Repulsión." -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "Selecciona la música que se reproduce al saltar en el Gel de Repulsión." -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "No añadir música al saltar en el Gel de Repulsión." -#: app/music_conf.py:179 +#: app/music_conf.py:197 #, fuzzy msgid "Select Propulsion Gel Music" msgstr "Seleccionar la música del Gel de Repulsión" -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." @@ -1357,61 +1360,61 @@ msgstr "" "Selecciona la música que se reproduce cuando los jugadores tienen una " "alta velocidad horizontal." -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "No añadir música al moverse rápido." -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "" -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "Embudo de Translación:" -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "Rebotar:" -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "Velocidad:" -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 msgid "" "\n" "Launch Game?" msgstr "" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 msgid "" "\n" "Minimise BEE2?" msgstr "" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" msgstr "" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" msgstr "" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" msgstr "" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" msgstr "Opciones de BEE2" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." @@ -1419,72 +1422,72 @@ msgstr "" "La fecha de los archivos cache de los paquetes se ha reseteado. Estos " "serán extraídos durante la siguiente exportación." -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." msgstr "\"Preservar Recursos del Juego\" se ha desabilitado." -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 #, fuzzy msgid "Packages Reset" msgstr "Resetear Paquetes" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "General" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "Ventanas" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "Desarollo" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "Vale" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "Después de la Exportación:" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "No hacer nada" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" msgstr "Minimizar BEE2" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "Cerrar BEE2" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "Después de exportar, no hacer nada y mantener BEE2 abierto." -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "Después de exportar, minimizar a la barra de tareas/bandeja." -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "Después de exportar, salir de BEE2." -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "Ejecutar Juego" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "Después de exportar, ejecutar el juego seleccionado automáticamente." -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "Reproducir Sonidos" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." @@ -1492,19 +1495,19 @@ msgstr "" "Pyglet no está instalado, o está roto.\n" "Los efectos de sonido se han desabilitado." -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "Resetear la caché de los Paquetes" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 msgid "Force re-extracting all package resources." msgstr "Forzar la re-extracción de todos los recursos de los paquetes." -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "Mantener ventanas dentro de la pantalla" -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." @@ -1512,12 +1515,12 @@ msgstr "" "Prevenir el movimiento de las sub-ventanas fuera de los bordes de " "pantalla. Si tienes varios monitores, deshabilita esta opción." -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 #, fuzzy msgid "Keep loading screens on top" msgstr "Mantener pantallas de carga encima" -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " @@ -1527,15 +1530,15 @@ msgstr "" "Como no aparecen en la barra de tareas/bandeja, no es posible moverlas " "encima facilmente." -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "Resetear la posición de todas las ventanas" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "Registrar cuenta de entidades que faltan" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." @@ -1543,11 +1546,11 @@ msgstr "" "Cuando se cargan ítems, registrar ítems que faltan de entidades en el " "archivo properties.txt." -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "Registrar cuando un ítem no tiene un estilo" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." @@ -1555,11 +1558,11 @@ msgstr "" "Registrar ítems que no tienen una versión aplicable para un estilo en " "particular. Esto usualmente significa que lucirá muy mal." -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "Registrar cuando un ítem usa el estilo del padre" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." @@ -1568,11 +1571,11 @@ msgstr "" "usando ítems de 1950s, por ejemplo). Esto normalmente está bien, pero " "puede que tenga que ser reparado." -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "Registrar recursos packfile que faltan" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " @@ -1582,22 +1585,22 @@ msgstr "" "encuentran presentes en el zip. Esto puede estar bien (en un zip " "requerido), pero normalmente indica un error." -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 #, fuzzy msgid "Development Mode" msgstr "Desarollo" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 msgid "" "Enables displaying additional UI specific for development purposes. " "Requires restart to have an effect." msgstr "" -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "Preservar Directorios del Juego" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 msgid "" "When exporting, do not copy resources to \n" "\"bee2/\" and \"sdk_content/maps/bee2/\".\n" @@ -1609,19 +1612,19 @@ msgstr "" "Activar si estás desarrollando nuevo contenido, para asegurarse de que " "este no sea reemplazado." -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "Mostrar la Ventana Log" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "Mostrar el archivo log en tiempo real." -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "Forzar Modelos de Editor" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " @@ -1631,14 +1634,22 @@ msgstr "" "tiene un límite the 1024 modelos cargados en memoria al mismo tiempo, por" " lo que necesitamos desabilitar los no usados para liberar memoria." -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 msgid "Dump All objects" msgstr "Volcar TODOS los objectos" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "Lista de elementos volcados" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" msgstr "BEE2 - Reinicio Requerido!" @@ -1656,141 +1667,196 @@ msgid "BEE2 - Manage Packages" msgstr "BEE2 - Administrar Paquetes" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "Vacío" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" msgstr "Portal 2 Colapsado" -#: app/richTextBox.py:183 +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "Vaciar la Paleta" + +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "Borrar Paleta" + +#: app/paletteUI.py:115 +#, fuzzy +msgid "Change Palette Group..." +msgstr "Guardar Paleta Como..." + +#: app/paletteUI.py:121 +#, fuzzy +msgid "Rename Palette..." +msgstr "Guardar Paleta..." + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "Rellenar Paleta" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "Guardar Paleta" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "Borrar Paleta \"{}\"" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "BEE2 - Guardar Paleta" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "Introduzca un nombre:" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "Abrir \"{}\" en el navegador predeterminado?" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "No añadir nada." #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "Recomendado" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." msgstr "Ver una demostración de este elemento." -#: app/selector_win.py:688 -msgid "Reset to Default" -msgstr "Restablecer a predeterminado." +#: app/selector_win.py:862 +#, fuzzy +msgid "Select Suggested" +msgstr "Recomendado" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "Otro" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "Autor: {}" msgstr[1] "Autores: {}" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "Color: R={r}, G={g}, B={b} " -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "Recomendado" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "Configurar Cartel" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 #, fuzzy msgid "Selected" msgstr "Seleccionado:" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "Cartel: {}" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "Un Jugador" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 #, fuzzy msgid "Cooperative" msgstr "Cooperativo" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "Personajes Humanos (Bendy y Chell)" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "Personajes IA (ATLAS, P-Body, o Coop)" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "Muerte - Pringue mortal" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "Muerte - Torretas" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" msgstr "Muerte - Machacador" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "Muerte - Campo Láser" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "Transcripción:" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "Guardar" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "Resp" -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "BEE2 - Configurar \"{}\"" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "Mitad - Camara" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." @@ -1798,21 +1864,21 @@ msgstr "" "Líneas de voz reproducidas durante la camara actual, después de que se " "hayan producido eventos específicos." -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "Respuestas" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "" "Líneas de voz reproducidas en respuesta a ciertos acontecimientos en el " "modo cooperativo." -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "¡Sin nombre!" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "¿Sin nombre?" @@ -1840,3 +1906,9 @@ msgstr "¿Sin nombre?" #~ msgid "Enables displaying additional UI specific for development purposes." #~ msgstr "" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "Esta paleta ya existe. ¿Desea reemplazarla?" + +#~ msgid "Reset to Default" +#~ msgstr "Restablecer a predeterminado." + diff --git a/i18n/fr.po b/i18n/fr.po index d2ab04d61..48324f7b1 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: BEEMOD2\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -12,91 +12,91 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "Sauté !" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "Version: " -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "Annuler" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "Vider" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "Copier" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "Montrer:" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "Logs - {}" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "Messages de débug" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "Par défaut" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "Avertissements seulement" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "Paquets" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "Chargement des objets" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "Initialisation de l'interface" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "Better Extended Editor pour Portal 2" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "Cocher toutes les cases" -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "ATLAS" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "P-Body" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Chell" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Bendy" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " @@ -107,7 +107,7 @@ msgstr "" "simplifier sa forme." #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 #, fuzzy msgid "" "Entities are the things in the map that have functionality. Removing " @@ -126,7 +126,7 @@ msgstr "" "le sont pas par le moteur de jeu." #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 #, fuzzy msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " @@ -137,11 +137,11 @@ msgstr "" " comme les symboles ou les lignes en pointillés. Cacher les longues " "lignes ou régler le paramètre sur Signalisation réduira cette valeur." -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "Couloir" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." @@ -149,15 +149,15 @@ msgstr "" "Choisit un couloir aléatoire. Ceci est sauvegardé dans le data du puzzle " "et ne va pas changer." -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "Aléatoire" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "Fichiers image" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." @@ -165,35 +165,35 @@ msgstr "" "Les options de ce panneau peuvent être modifiées sans exporter ou " "redémarrer le jeu." -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "Paramètres de carte" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "Paramètres de compilation" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" msgstr "Miniature" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "Auto" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "PeTI" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "Personnalisée:" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "Supprimer les anciens screenshots" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -205,11 +205,11 @@ msgstr "" "la carte n'a pas été prévisualisée récemment (dans les dernières heures)," " le screenshot PeTI par défaut sera utilisé à la place." -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "Utilise la vue normale de l'éditeur pour l'image de prévisualisation." -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" @@ -219,7 +219,7 @@ msgstr "" "l'image pour la choisir.\n" "Les images seront converties en JPEG si besoin." -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " @@ -227,19 +227,25 @@ msgstr "" "Supprimer automatiquement les screenshots Autos non utilisés. Décochez si" " vous souhaitez garder des images dans \"portal2/screenshots\"." -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "Lumière:" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "Rapide" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "Complète" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" @@ -250,7 +256,7 @@ msgstr "" "incorrectes.\n" "À la publication, ce n'est pas utilisé." -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 #, fuzzy msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " @@ -261,11 +267,11 @@ msgstr "" "c'est plus beau. À utiliser si vous arrangez les lumières.\n" "À la publication, ceci est toujours utilisé." -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "Vider les fichiers enpaquetés vers :" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." @@ -273,19 +279,19 @@ msgstr "" "Lors de la compilation, vide tous les fichiers venant d'être compilés " "dans la carte. Utile si vous prévoyez d'éditer la carte sous Hammer." -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "Dernière compilation:" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "Entités" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "Overlays" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." @@ -293,19 +299,19 @@ msgstr "" "Rafraîchir les barres de progression. Cliquez après une compilation pour " "actualiser les valeurs." -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" msgstr "Brushes" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "Répliques:" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "Utiliser les priorités des répliques" -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " @@ -316,19 +322,25 @@ msgstr "" "d'éléments de test dans la carte. Si décoché, toute réplique applicable " "sera utilisée." -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "Apparaître à:" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "Porte d'entrée" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "Ascenseur" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 #, fuzzy msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " @@ -339,68 +351,68 @@ msgstr "" "atteignez la sortie. Utilisez ceci pour vérifier les couloirs d'entrée et" " de sortie." -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 #, fuzzy msgid "When previewing in SP, spawn just before the entry door." msgstr "" "Lors d'une prévisualisation en solo, cela fait apparaître derrière la " "porte d'entrée. Quand vous atteindrez la sortie, la carte redémarrera." -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "Couloir:" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" msgstr "" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" msgstr "Entrée solo:" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" msgstr "Sortie solo:" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "Modèle de joueur (Solo):" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "Options de compilation" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "Options de compilateur - {}" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "Cave du multivers" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "Joue les répliques de Cave Johnson du Workshop au début de la carte." -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" msgstr "Empêcher le Portal Bump (Grilles d'Émancipation)" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" @@ -411,19 +423,19 @@ msgstr "" "empêcher le placement de portails dans des endroits serrés proches de " "Grilles ou dissoudre les portails lors de leur placement." -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" msgstr "Retirer les répliques se déclenchant en cours de salle" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "Désactive toutes les répliques autres que celles d'entrée et de sortie." -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "Débloquer les objets par défaut." -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" @@ -432,11 +444,11 @@ msgstr "" "et de la grande salle d'observation. À utiliser avec précaution, cela " "peut causer des résultats étranges." -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "Ajouter de la brume sur l'acide" -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " @@ -446,11 +458,11 @@ msgstr "" " styles. Cela peut augmenter le nombre d'entités avec de grandes fosses. " "À désactiver au besoin." -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 msgid "Light Reversible Excursion Funnels" msgstr "Lumière réversible des Halos d’Excursion" -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -462,11 +474,11 @@ msgstr "" " bugs de lumière. Désactiver cela pour prévenir ces bugs (retire la " "lumière des Halos). Les Halos non réversibles n'ont pas ce problème." -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "Autoriser les contours de symboles" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." @@ -474,73 +486,76 @@ msgstr "" "Une fois 10 symboles utilisés (mode Signalisation), ils se répètent. Si " "ceci est activé, des cadres colorés seront ajoutés pour les différencier." -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "Par défaut: On" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "Par défaut: Off" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "Styles: Sans style" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "Styles: Tous" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "Style: {}" msgstr[1] "Styles: {}" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 msgid "Style/Item Properties" msgstr "Propriétés de style/d'objet" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "Styles" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "Tous:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "Style choisi:" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "Autres styles:" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "Pas d'options!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "Aucun(e)" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "Objets" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "Cacher/Montrer la fenêtre \"{}\"" -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "Export..." -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "Sélectionnez une skybox" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " @@ -551,19 +566,19 @@ msgstr "" "sans fond (si présents) tout comme la couleur du \"brouillard\" (vu dans " "les grandes salles)." -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "Skybox 3D" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "Couleur du brouillard" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "Sélectionnez les répliques additionnelles" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " @@ -575,31 +590,31 @@ msgstr "" "dans la salle. Les répliques additionnelles de Cave du multivers sont " "contrôlées séparément dans les propriétés de style." -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "N'ajoute aucune réplique supplémentaire." -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "Personnages" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "Les tourelles tirent sur les moniteurs" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "Visuels des moniteurs" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "Sélectionnez un style" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -613,15 +628,15 @@ msgstr "" "\n" "Le style définit de façon large l'époque où se place la salle." -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "Vidéos d'ascenseur" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "Sélectionnez une vidéo d'ascenceur" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " @@ -632,23 +647,23 @@ msgstr "" "est choisit, une vidéo aléatoire sera sélectionnée chaque fois que la " "carte sera jouée, comme avec le style PeTI par défaut." -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "Ce style n'a aucune vidéo d'ascenseur." -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "Choisit une vidéo aléatoire." -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "Orientations multiples" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" msgstr "Le style et les objets sélectionnés ont été exportés avec succès !" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" @@ -661,71 +676,43 @@ msgstr "" "Hammer pour assurer le rafraîchissement des apparences des murs en " "prévisualisation." -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "Supprimer la palette \"{}\"" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "BEE2 - Enregistrer la palette" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "Entrer un nom:" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "Cette palette existe déjà. Remplacer ?" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "Êtes-vous sûr de vouloir supprimer cette palette ?" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "Vider la palette" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "Supprimer la palette" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." msgstr "Enregistrer la palette..." -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." msgstr "Enregistrer la palette sous..." -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" msgstr "" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "Musique: " -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "{arr} Utiliser les suggestions {arr}" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "Style: " -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "Voix: " -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "Skybox: " -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "Vid Asc: " -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." @@ -733,119 +720,123 @@ msgstr "" "Activer ou désactiver certaines répliques pour les empêcher d'être " "ajoutées." -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "Tous les objets: " -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "Exporter vers \"{}\"..." -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "Fichier" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "Exporter" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "Ajouter le jeu" -#: app/UI.py:1694 +#: app/UI.py:1484 msgid "Uninstall from Selected Game" msgstr "Désinstaller du jeu sélectionné" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." msgstr "Sauvegarder/Restaurer les puzzles..." -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "Gérer les paquets..." -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "Options" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "Quitter" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" msgstr "Palette" -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "Remplir la palette" - -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "Enregistrer la palette" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" msgstr "Palettes" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "Options d'export" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "Remplir les emplacements vides de la palette avec des objets aléatoires." -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "Copie des cartes" -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "Chargement des cartes" -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "Suppression des cartes" -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." msgstr "" "Échec du traitement de ce fichier de puzzle. Il peut toujours être " "restauré." -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "Aucune description trouvée." -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "Coop" -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "Solo" -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" msgstr "" "Ce nom de fichier est déjà présent dans la sauvegarde. Voulez-vous le " "remplacer ? ({})" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" msgstr "Sauvegarde BEE2" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "Aucune carte sélectionnée !" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" @@ -853,27 +844,27 @@ msgstr "" "Cette carte est déjà dans les fichiers du jeu. Voulez-vous la remplacer ?" " ({})" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "Charger la sauvegarde" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" msgstr "Compresser la sauvegarde" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "Sauvegarde non enregistrée" -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "Enregistrer la sauvegarde sous" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "Confirmer la suppression" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" msgstr[0] "" @@ -883,168 +874,168 @@ msgstr[1] "" "Voulez-vous supprimer {} cartes ?\n" "\n" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "Restaurer:" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" msgstr "Sauvegarder:" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "Celles cochées" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "Supprimer celles cochées" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" msgstr "BEEMOD {} - Sauvegarder / Restaurer les puzzles" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "Nouvelle sauvegarde" -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "Ouvrir la sauvegarde" -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "Sauvegarder" -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" msgstr "Retirer le jeu" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "Jeu" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" msgstr "Sauvegarde automatique après export" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "Nombre (par jeu):" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" msgstr "Sauvegarder/Restaurer des puzzles" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "Cet objet ne peut pas être tourné." -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "Cet objet peut être pointé dans 4 directions." -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "Cet objet peut être positionné sur les côtés ou au centre." -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "Cet objet peut être centré sur deux directions et sur les côtés." -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "Cet objet peut être placé comme une source lumineuse." -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "Cet objet peut être tourné sur le sol à 360°." -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "Cet objet est positionné avec une trajectoire de catapulte." -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." msgstr "Cet objet positionne le distributeur pour toucher des zones ciblées." -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "Cet objet n'accepte pas de connexion entrante." -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "Cet objet accepte des connexions entrantes." -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." msgstr "" "Cet objet a deux types d'entrée (A et B), utilisant les items Input A et " "B." -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." msgstr "Cet objet n'a pas de connexion sortante." -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." msgstr "Cet objet a une connexion sortante." -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." msgstr "Cet objet a une connexion sortante temporisée." -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "Cet objet ne prend pas de place dans le mur." -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "Cet objet prend de la place dans le mur." -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "Cet objet ne peut pas être placé." -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "Cet objet est attaché au plafond seulement." -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "Cet objet est posé au sol seulement." -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "Cet objet peut être placé au plafond et au sol." -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "Cet objet ne se place que sur les murs." -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "Cet objet peut être attaché au plafond et aux murs." -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "Cet objet peut être placé au sol et aux murs." -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "Cet objet peut être placé dans n'importe quel sens." -#: app/contextWin.py:226 +#: app/contextWin.py:227 #, fuzzy msgid "No Alternate Versions" msgstr "Pas de versions alternatives !" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." msgstr "" "Les Halos d'Excursion acceptent une connexion on/off et une connexion de " "polarité." -#: app/contextWin.py:371 +#: app/contextWin.py:372 #, fuzzy msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" @@ -1066,11 +1057,11 @@ msgstr "" "nombre d'entités à 2048 en tout. Ceci fournit un guide pour savoir " "combien de ces objets il est possible de placer à la fois sur la carte." -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "Description:" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" @@ -1078,83 +1069,83 @@ msgstr "" "Échec de l'ouverture du navigateur. Voulez-vous que l'URL soit copiée " "dans le presse-papiers à la place ?" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "Plus d'infos>>" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." msgstr "Changer les valeurs par défaut..." -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "Changer les réglages par défaut pour cet objet lorsqu'il est placé." -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "BEE2 - Export échoué !" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." msgstr "" -#: app/gameMan.py:859 +#: app/gameMan.py:881 #, fuzzy msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "" "Copie du fichier {file} échouée. Veuillez vous assurer que {game} n'est " "pas en cours d'exécution." -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." msgstr "" -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" msgstr "" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "Sélectionnez le dossier où l'exécutable du jeu est localisé ({appname})..." -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "BEE2 - Ajouter un jeu" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "Trouvez l'exécutable du jeu" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "Exécutable" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" msgstr "Cela ne semble pas être un dossier de jeu valide." -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "Portal Stories: Mel n'a pas d'éditeur." -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "Entrez le nom de ce jeu:" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "Ce nom est déjà pris !" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "Veuillez entrer un nom pour ce jeu." -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" @@ -1162,82 +1153,86 @@ msgstr "" "\n" "(BEE2 va se fermer.)" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "Êtes-vous sûr de vouloir supprimer cette palette ?" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "Wiki..." -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "Items originaux..." #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "Serveur Discord..." -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "Le changeur de musique d'aerond..." -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "Emplacement web de l'application..." -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "Emplacement web des objets..." -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "Soumettre un bug de l'application..." -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "Soumettre un bug des objets..." #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "Portal 2" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "Aperture Tag" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "Portal Stories: Mel" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "Thinking with Time Machine" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "Fermer" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "Aide" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "Crédits de BEE2" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "Crédits..." -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "Position de départ" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "Position de fin" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" @@ -1245,24 +1240,32 @@ msgstr "" "Délai\n" "(0=infini)" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "Aucune propriété disponible !" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "Choisissez une couleur" -#: app/music_conf.py:132 +#: app/music_conf.py:147 #, fuzzy msgid "Select Background Music - Base" msgstr "Sélectionnez la musique de fond - Base" -#: app/music_conf.py:133 +#: app/music_conf.py:148 #, fuzzy msgid "" "This controls the background music used for a map. Expand the dropdown to" @@ -1272,59 +1275,59 @@ msgstr "" " ont des variations jouées lors d’interactions avec certains éléments de " "test." -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." msgstr "N'ajoute aucune musique à la carte." -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" msgstr "Effet sonore pour Gel Propulsif" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" msgstr "Effet sonore pour Gel Répulsif" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "Musique du Halo d'Excursion" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "Musique du Halo synchronisée" -#: app/music_conf.py:152 +#: app/music_conf.py:168 #, fuzzy msgid "Select Excursion Funnel Music" msgstr "Sélectionnez la musique du Halo d'Excursion." -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "Sélectionne la musique utilisée à l'intérieur d'un Halo d'Excursion" -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "N'a pas de musique pour les Halos d'Excursion" -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "Sélectionnez la musique du Gel Répulsif" -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "Sélectionne la musique utilisée pour le Gel Répulsif" -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "N'ajoute pas de musique pour le Gel Répulsif." -#: app/music_conf.py:179 +#: app/music_conf.py:197 #, fuzzy msgid "Select Propulsion Gel Music" msgstr "Sélectionnez la musique du Gel Propulsif." -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." @@ -1332,63 +1335,63 @@ msgstr "" "Sélectionne la musique quand les joueurs sont très véloces " "horizontalement." -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "N'ajoute pas de musique en temps de course rapide." -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "Base" -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "Halo d'Excursion" -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "Rebondissement" -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "Vitesse" -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 #, fuzzy msgid "" "\n" "Launch Game?" msgstr "Lancer le jeu" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 #, fuzzy msgid "" "\n" "Minimise BEE2?" msgstr "Minimiser le BEE2" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" msgstr "" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" msgstr "" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" msgstr "" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" msgstr "Options de BEE2" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." @@ -1396,72 +1399,72 @@ msgstr "" "Les caches temporelles des Packages ont été réinitialisées.Elles vont " "être extraites durant le prochain export." -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." msgstr "\"Preserve Game Resources\" a été désactivé." -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 #, fuzzy msgid "Packages Reset" msgstr "réinitialisation des packages" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "Général" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "Windows" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "Développement" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "OK" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "Après l'export:" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "Ne rien faire" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" msgstr "Minimiser BEE2" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "Quitter BEE2" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "Après l'export, ne rien faire et garder BEE2 au premier plan." -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "Après l'export, minimiser BEE2 dans la barre des tâches." -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "Après l'export, quitter le BEE2." -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "Lancer le jeu" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "Après l'export, lancer le jeu sélectionné automatiquement." -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "Jouer les sons" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." @@ -1469,22 +1472,22 @@ msgstr "" "Pyglet est soit désinstallé soit endommagé.\n" "Les effets sonores ont été désactivés." -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "Réinitialiser le cache des packages" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 #, fuzzy msgid "Force re-extracting all package resources." msgstr "" "Forcer la ré-extraction de tous les packages de ressources. Cela requiert" " un redémarrage." -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "Garder les fenêtres dans l'écran." -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." @@ -1492,12 +1495,12 @@ msgstr "" "Empêcher les sous-fenêtres de sortir des limites de l'écran. Si vous " "avezplusieurs écrans, désactivez cela." -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 #, fuzzy msgid "Keep loading screens on top" msgstr "Garder les écrans de chargement au-dessus." -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " @@ -1507,15 +1510,15 @@ msgstr "" "fenêtres.Si elles n'apparaissent pas dans la barre des tâches/dock, elles" " ne peuvent pas aller au-dessusfacilement." -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "Réinitialiser les positions des fenêtres" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "Enregistrer dans un fichier les nombres d'entités manquantes" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." @@ -1523,11 +1526,11 @@ msgstr "" "Lors du chargement des objets, enregistrer les objets ayant unnombre " "d'entités manquantes dans leur fichier properties.txt." -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "Enregistrer dans un fichier quand un objet n'a pas de style" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." @@ -1535,11 +1538,11 @@ msgstr "" "Enregistrer dans un fichier les objets n'ayant pas de version applicable " "pour un style donné. Cela signifie qu'il ne sera pas très beau." -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "Enregistrer dans un fichier quand un objet utilise le style du parent" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." @@ -1549,11 +1552,11 @@ msgstr "" "ex.). Ce n'est généralement pas un problème mais nécessite parfois des " "ajustements." -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "Enregistrer dans un fichier les ressources empaquetées manquantes" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " @@ -1563,22 +1566,22 @@ msgstr "" "\"PackList\" ne sont pas présentes dans le zip. Cela peut être normal " "(dans un zip prérequis) mais peut autrement indiquer un erreur." -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 #, fuzzy msgid "Development Mode" msgstr "Développement" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 msgid "" "Enables displaying additional UI specific for development purposes. " "Requires restart to have an effect." msgstr "" -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "Préserver les dossiers du jeu" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 #, fuzzy msgid "" "When exporting, do not copy resources to \n" @@ -1592,19 +1595,19 @@ msgstr "" "Activez cela si vous développez du contenu nouveau afin qu'il ne soit pas" " remplacé." -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "Montrer la fenêtre de log" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "Montrer le log en temps réel." -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "Forcer les modèles de l'éditeur" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " @@ -1614,14 +1617,22 @@ msgstr "" "limite de 1024 modèles chargés en mémoire en une fois, alors nous devons " "désactiver les autres." -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 msgid "Dump All objects" msgstr "Supprimer tous les objets" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "Supprimer la liste d'objets" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" msgstr "BEE2 - Redémarrage requis !" @@ -1639,159 +1650,214 @@ msgid "BEE2 - Manage Packages" msgstr "BEE2 - Gérer les packages" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "Vide" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "BEEMod" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" msgstr "Portal 2 Compressé" -#: app/richTextBox.py:183 +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "Vider la palette" + +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "Supprimer la palette" + +#: app/paletteUI.py:115 +#, fuzzy +msgid "Change Palette Group..." +msgstr "Enregistrer la palette sous..." + +#: app/paletteUI.py:121 +#, fuzzy +msgid "Rename Palette..." +msgstr "Enregistrer la palette..." + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "Remplir la palette" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "Enregistrer la palette" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "Supprimer la palette \"{}\"" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "BEE2 - Enregistrer la palette" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "Entrer un nom:" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "Ouvrir \"{}\" dans le navigateur par défaut ?" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "Ne rien ajouter." #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "Suggéré" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." msgstr "Jouer un échantillon de cet objet" -#: app/selector_win.py:688 -msgid "Reset to Default" -msgstr "Réinitialiser aux valeurs par défaut" +#: app/selector_win.py:862 +#, fuzzy +msgid "Select Suggested" +msgstr "Suggéré" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "Autre" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "Auteur: {}" msgstr[1] "Auteurs: {}" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "Couleur: R={r}, V={g}, B={b}" -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "Suggéré" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "Configurer la signalisation" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 #, fuzzy msgid "Selected" msgstr "Style choisi:" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "Signalisation: {}" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "Solo" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 #, fuzzy msgid "Cooperative" msgstr "Coop" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "ATLAS (Solo/Coop)" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "P-Body (Solo/Coop)" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "Personnages humains (Bendy et Chell)" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "Personnages IA (ATLAS, P-Body, ou Coop" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "Mort - Vase mortelle" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "Mort - Tourelles" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" msgstr "Mort - Écrabouilleur" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "Mort - Champ de lasers" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "Transcript:" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "Enregistrer" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "Rép." -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "BEE2 - Configurer \"{}\"" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "En cours de salle" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." msgstr "Répliques jouées durant la salle après certains événements spécifiques." -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "Réponses" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "Répliques jouées en réponse à certains événements en Coop." -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "Sans nom !" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "Sans nom ?" @@ -1824,3 +1890,9 @@ msgstr "Sans nom ?" #~ msgid "Enables displaying additional UI specific for development purposes." #~ msgstr "" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "Cette palette existe déjà. Remplacer ?" + +#~ msgid "Reset to Default" +#~ msgstr "Réinitialiser aux valeurs par défaut" + diff --git a/i18n/ja.po b/i18n/ja.po index 1c830509f..1033aeaf4 100644 --- a/i18n/ja.po +++ b/i18n/ja.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: ja\n" @@ -12,91 +12,91 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "バージョン:" -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "キャンセル" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "コピー" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "YES" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "" -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "ATLAS" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "P-Body" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Chell" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Bendy" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " @@ -104,7 +104,7 @@ msgid "" msgstr "" #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 msgid "" "Entities are the things in the map that have functionality. Removing " "complex moving items will help reduce this. Items have their entity count" @@ -116,66 +116,66 @@ msgid "" msgstr "" #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " "lights. Hiding complex antlines or setting them to signage will reduce " "this." msgstr "" -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "廊下" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." msgstr "" -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "ランダム" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." msgstr "" -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "マップ" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "コンパイラ" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" msgstr "サムネイル" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "自動" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "PeTI" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "カスタム:" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -183,213 +183,225 @@ msgid "" "PeTI screenshot will be used instead." msgstr "" -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "" -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" "Images will be converted to JPEGs if needed." msgstr "" -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " msgstr "" -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "照明:" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "迅速" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "完成" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" "When publishing, this is ignored." msgstr "" -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " "to compute. Use if you're arranging lights.\n" "When publishing, this is always used." msgstr "" -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." msgstr "" -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "前のコンパイル:" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "エンティティ" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "オーバーレイ" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." msgstr "" -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" msgstr "ブラシ" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "" -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " "disabled any applicable lines will be used." msgstr "" -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "入口" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "エレベーター" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " "examine the entry and exit corridors." msgstr "" -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 msgid "When previewing in SP, spawn just before the entry door." msgstr "" -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "廊下:" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" msgstr "" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" msgstr "SP入口:" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" msgstr "SP出口:" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "" -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" msgstr "" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" " fizzle portals on activation." msgstr "" -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" msgstr "" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "" -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "" -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" msgstr "" -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "" -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " "if needed." msgstr "" -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 msgid "Light Reversible Excursion Funnels" msgstr "" -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -397,101 +409,104 @@ msgid "" " do not have this issue." msgstr "" -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." msgstr "" -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 msgid "Style/Item Properties" msgstr "" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "全:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "オプションありません!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "なし!" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "" -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "" -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " "present), as well as color of \"fog\" (seen in larger chambers)." msgstr "" -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " @@ -499,31 +514,31 @@ msgid "" "Style Properties." msgstr "" -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "" -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "キャラクター" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -532,38 +547,38 @@ msgid "" "The style broadly defines the time period a chamber is set in." msgstr "" -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "エレベータービデオ" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " "will be selected each time the map is played, like in the default PeTI." msgstr "" -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "" -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "" -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" msgstr "" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" @@ -571,372 +586,348 @@ msgid "" "editor wall previews are changed." msgstr "" -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." msgstr "" -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." msgstr "" -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" msgstr "" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "音楽:" -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "" -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "" -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "" -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "エレベータービデオ:" -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." msgstr "" -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "" -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "" -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "ファイル" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "" -#: app/UI.py:1694 +#: app/UI.py:1484 msgid "Uninstall from Selected Game" msgstr "" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." msgstr "" -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "" -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "環境設定" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "終了" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" msgstr "" -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "" - -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" msgstr "" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "" -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "" -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "" -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "" -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." msgstr "" -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "" -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "生協" -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "SP" -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" msgstr "" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" msgstr "" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" msgstr "" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" msgstr "" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "" -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" msgstr[0] "" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" msgstr "" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" msgstr "" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "" -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "" -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "" -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" msgstr "" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "ゲーム" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" msgstr "" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" msgstr "" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "" -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "" -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "" -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "" -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "" -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "" -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "" -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." msgstr "" -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "" -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "" -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." msgstr "" -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." msgstr "" -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." msgstr "" -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." msgstr "" -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "" -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "" -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "" -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "" -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "" -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "" -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "" -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "" -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "" -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "" -#: app/contextWin.py:226 +#: app/contextWin.py:227 msgid "No Alternate Versions" msgstr "" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." msgstr "" -#: app/contextWin.py:371 +#: app/contextWin.py:372 msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" " Cubes only." @@ -953,476 +944,488 @@ msgid "" " placed in a map at once." msgstr "" -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" msgstr "" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." msgstr "" -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "" -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." msgstr "" -#: app/gameMan.py:859 +#: app/gameMan.py:881 msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "" -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." msgstr "" -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" msgstr "" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "" -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" msgstr "" -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "" -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "" -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" msgstr "" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "ウィキ。。。" -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "" #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "" -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "" -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "" -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "" -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "" -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "" #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "Portal 2" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "Aperture Tag" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "Portal Stories: Mel" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "Thinking With Time Machine" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "閉じる" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "ヘルプ" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "BEE2のクレジット" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "クレジット。。。" -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" msgstr "" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "" -#: app/music_conf.py:132 +#: app/music_conf.py:147 #, fuzzy msgid "Select Background Music - Base" msgstr "音楽を選択" -#: app/music_conf.py:133 +#: app/music_conf.py:148 msgid "" "This controls the background music used for a map. Expand the dropdown to" " set tracks for specific test elements." msgstr "" -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." msgstr "" -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" msgstr "速ゲルSFX" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" msgstr "" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "" -#: app/music_conf.py:152 +#: app/music_conf.py:168 #, fuzzy msgid "Select Excursion Funnel Music" msgstr "音楽を選択" -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "" -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "" -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "" -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "" -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "" -#: app/music_conf.py:179 +#: app/music_conf.py:197 #, fuzzy msgid "Select Propulsion Gel Music" msgstr "音楽を選択" -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." msgstr "" -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "" -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "" -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "" -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "" -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "" -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 msgid "" "\n" "Launch Game?" msgstr "" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 msgid "" "\n" "Minimise BEE2?" msgstr "" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" msgstr "" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" msgstr "" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" msgstr "" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" msgstr "" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." msgstr "" -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." msgstr "" -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 msgid "Packages Reset" msgstr "" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "OK" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" msgstr "" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "" -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "" -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "" -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "" -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." msgstr "" -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 msgid "Force re-extracting all package resources." msgstr "" -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "" -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." msgstr "" -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 msgid "Keep loading screens on top" msgstr "" -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " "again." msgstr "" -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." msgstr "" -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." msgstr "" -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." msgstr "" -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " "error." msgstr "" -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 msgid "Development Mode" msgstr "" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 msgid "" "Enables displaying additional UI specific for development purposes. " "Requires restart to have an effect." msgstr "" -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 msgid "" "When exporting, do not copy resources to \n" "\"bee2/\" and \"sdk_content/maps/bee2/\".\n" @@ -1430,33 +1433,41 @@ msgid "" "overwritten." msgstr "" -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "" -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " "ones to free this up." msgstr "" -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 msgid "Dump All objects" msgstr "" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" msgstr "" @@ -1472,156 +1483,208 @@ msgid "BEE2 - Manage Packages" msgstr "" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "空ろ" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "BEEMod" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" msgstr "短縮のPortal 2" -#: app/richTextBox.py:183 +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "" + +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "" + +#: app/paletteUI.py:115 +msgid "Change Palette Group..." +msgstr "" + +#: app/paletteUI.py:121 +msgid "Rename Palette..." +msgstr "" + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "" #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "<なし>" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." msgstr "" -#: app/selector_win.py:688 -msgid "Reset to Default" +#: app/selector_win.py:862 +msgid "Select Suggested" msgstr "" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "" -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 msgid "Selected" msgstr "" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 msgid "Cooperative" msgstr "" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" msgstr "" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "" -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." msgstr "" -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "" -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "名前ありません!" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "名前ありません?" @@ -1783,3 +1846,9 @@ msgstr "名前ありません?" #~ msgid "Enables displaying additional UI specific for development purposes." #~ msgstr "" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "" + +#~ msgid "Reset to Default" +#~ msgstr "" + diff --git a/i18n/pl.po b/i18n/pl.po index 2fce3672c..83f26370c 100644 --- a/i18n/pl.po +++ b/i18n/pl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: BEE2 mod translations\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: \n" "Language: pl\n" @@ -13,91 +13,91 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "Pominięto!" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "Wersja: " -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "Anuluj" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "Wyczyść" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "Kopiuj" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "Pokaż:" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "Logi - {}" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "Komunikaty debugowania" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "Domyślne" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "Tylko ostrzeżenia" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "Paczki" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "Ładowanie obiektów" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "Inicjacja interfejsu" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "Lepiej Rozwinięty Edytor map dla Portal 2" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "__LANG_USE_SANS_SERIF__" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "Zaznacz wszystko." -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "ATLAS" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "P-Body" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Chell" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Bendy" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " @@ -107,7 +107,7 @@ msgstr "" " pomóc może zmniejszenie rozmiaru mapy lub usunięcie zawiłych kształtów." #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 msgid "" "Entities are the things in the map that have functionality. Removing " "complex moving items will help reduce this. Items have their entity count" @@ -126,7 +126,7 @@ msgstr "" "dodatkowe, niepokazane tu wartości." #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " "lights. Hiding complex antlines or setting them to signage will reduce " @@ -136,11 +136,11 @@ msgstr "" "znaczniki świetlne. Ukrycie skomplikowanych znaczników lub wymiana ich na" " znaki zmniejszy tą wartość." -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "Korytarz" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." @@ -148,15 +148,15 @@ msgstr "" "Losowo wybiera korytarz. Raz wybrany jest zapisywany w danych mapy i się " "nie zmieni." -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "Losowo" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "Pliki obrazów" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." @@ -164,35 +164,35 @@ msgstr "" "Opcje w tym panelu mogą być zmieniane bez eksportowania lub restartowania" " gry." -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "Ustawienia mapy" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "Ustawienia kompilatora" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" msgstr "Miniaturka" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "Automatycznie" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "Ustawiczna Inicjatywa Testowania" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "Własny:" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "Wyczyść stare zrzuty ekranu" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -204,11 +204,11 @@ msgstr "" "nie był wykonywany w ciągu ostatnich kilku godzin, domyślny obrazek " "zostanie użyty w zamian." -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "Użyj normalnego widoku z edytora jako podgląd mapy" -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" @@ -218,7 +218,7 @@ msgstr "" "wybrać.\n" "Obrazki będą w razie potrzeby konwertowane do formatu JPEG." -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " @@ -227,19 +227,25 @@ msgstr "" "Wyłącz, jeżeli chcesz je przechowywać w folderze \"portal2/screenshots\"." " " -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "Oświetlenie:" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "Szybkie" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "Pełne" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" @@ -249,7 +255,7 @@ msgstr "" "ale nie wygląda tak dobrze. Niektóre cienie mogą wyglądać źle.\n" "Używanie tylko w podglądzie mapy w edytorze." -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " "to compute. Use if you're arranging lights.\n" @@ -259,11 +265,11 @@ msgstr "" "obliczanie jest dłuższe. Używaj jeżeli aranżujesz oświetlenie.\n" "Zawsze używane podczas publikowania mapy." -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "Zachowuj zapakowane pliki w:" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." @@ -271,19 +277,19 @@ msgstr "" "Podczas kompilacji, zapisuje wszystkie zapakowane pliki do pliku mapy. " "Użyteczne, jeżeli zamierzać modyfikować mapy w edytorze Hammer." -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "Ostatnia kompilacja:" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "Jednostki" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "Nakładki" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." @@ -291,19 +297,19 @@ msgstr "" "Odśwież statystyki kompilacji. Naciśnij po wykonaniu nowej kompilacji, " "aby pokazać zaktualizowane wartości." -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" msgstr "Pędzel" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "Ścieżki głosowe:" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "Używaj priorytetowych ścieżek głosowych" -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " @@ -314,19 +320,25 @@ msgstr "" "testowych pojawia się na mapie. Jeśli wyłączone, wszystkie odpowiednie " "ścieżki będą użyte." -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "Spawn:" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "przy drzwiach wejściowych" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "w windzie" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " "examine the entry and exit corridors." @@ -335,67 +347,67 @@ msgstr "" "windzie. Używaj, jeśli chcesz przetestować wejściowe i wyjściowe " "korytarze." -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 msgid "When previewing in SP, spawn just before the entry door." msgstr "" "Podczas podglądu w grze jednoosobowej, spawn nastąpi tuż przed drzwiami " "wejściowymi." -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "Korytarz:" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" msgstr "" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" msgstr "Wejście (tryb 1-os.)" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" msgstr "Wyjście (tryb 1-os.)" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "Wyjście (tryb 2-os.):" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "Model gracza (tryb 1-os.)" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "Opcje kompilacji" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "Opcje kompilatora - {}" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "Cave z multiwersum" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "Odtwarza ścieżki Cave'a Johnsona na starcie mapy." -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" msgstr "Zapobiegaj kolizjom portali (siatka demat.)" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" @@ -406,19 +418,19 @@ msgstr "" " szczelinach niedaleko siatek, albo dematerializowaniu portali podczas " "aktywacji." -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" msgstr "Usuń dialog w środku komory" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "Wyłącza wszystkie ścieżki głosowe inne niż ścieżki wejścia i wyjścia." -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "Odblokuj domyślne przedmioty" -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" @@ -427,11 +439,11 @@ msgstr "" "wyjściowych oraz dużego pokoju obserwacyjnego. Używaj z ostrożnością, " "może to powodować dziwne rezultaty!" -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "Pozwól na mgłę nad mazią" -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " @@ -441,11 +453,11 @@ msgstr "" "zwiększyć znacząco ilość jednostek w mapach z dużymi, złożonymi dołami z " "mazią, więc wyłącz, jeśli będzie to konieczne." -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 msgid "Light Reversible Excursion Funnels" msgstr "Światło w odwracanych tubach transportujących" -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -457,11 +469,11 @@ msgstr "" "oświetleniu. Odznacz, by zapobiec temu wyłączając emitowane światło. " "Nieodwracalne tuby nie mają tego problemu." -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "Włącz ramki dla znaków" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." @@ -469,74 +481,77 @@ msgstr "" "Po użyciu 10 znaczników-symboli, symbole zaczną się powtarzać. Gdy ta " "opcja jest włączona, kolorowe ramki będą dodane, by je rozróżnić." -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "Domyślnie włączone" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "Domyślnie wyłączone" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "Style: Bez stylów" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "Style: Wszystkie" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "Styl: {}" msgstr[1] "Style: {}" msgstr[2] "Style: {}" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 msgid "Style/Item Properties" msgstr "Właściwości stylów i przedmiotów" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "Style" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "Wszystkie:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "Wybrany styl:" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "Pozostałe style:" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "Brak opcji!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "Brak!" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "Przedmioty" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "Pokaż/ukryj okno \"{}\"" -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "Wyeksportuj..." -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "Wybierz skybox" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " @@ -547,19 +562,19 @@ msgstr "" "otchłani (jeśli obecna), a także kolor \"mgły\" (widocznej w większych " "komorach)." -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "Skybox 3D" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "Kolor mgły" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "Wybierz dodatkowe ścieżki głosowe" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " @@ -571,31 +586,31 @@ msgstr "" "Dodatkowe ścieżki Cave'a Johnsona z \"Multiwersum\" są wybierane osobno " "we właściwościach stylu." -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "Nie dodaje żadnych ścieżek, jedynie Cave z multiwersum, jeżeli włączone." -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "Postacie" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "Wieżyczki strzelają w monitor" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "Postać na monitorze" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "Wybierz styl" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -609,15 +624,15 @@ msgstr "" "\n" "Styl po prostu określa okres czasu, w którym rozgrywana jest komora." -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "Filmy przy windzie" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "Wybierz film" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " @@ -628,23 +643,23 @@ msgstr "" "\"None\", losowy film będzie wybrany za każdym razem, gdy mapa jest " "grana, jak w domyślnym edytorze." -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "Ten styl nie ma ekranu na film." -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "Wybierz dowolny film." -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "Wiele orientacji" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" msgstr "Wyeksportowanie wybranych przedmiotów i stylu powiodło się!" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" @@ -656,211 +671,187 @@ msgstr "" "Uwaga: Pliki VPK nie zostały wyeksportowane, wyjdź z Portal 2 i Hammer " "aby upewnić się, że podgląd ścian w edytorze się zmienił." -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "Usuń paletę \"{}\"" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "BEE2 - Zapisz paletę" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "Wpisz nazwę:" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "Ta paleta już istnieje. Nadpisać?" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "Na pewno chcesz usunąć \"{}\"?" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "Wyczyść paletę" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "Usuń paletę" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." msgstr "Zapisz paletę..." -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." msgstr "Zapisz paletę jako..." -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" msgstr "Zapisz ustawienia palet" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "Muzyka: " -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "{arr} Użyj sugerowanego {arr}" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "Styl: " -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "Głos: " -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "Skybox: " -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "Winda:" -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." msgstr "Włącz lub wyłącz poszczególne ścieżki głosowe, aby zapobiec dodaniu ich." -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "Wszystkie przedmioty: " -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "Wyeksportuj do \"{}\"..." -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "Plik" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "Wyeksportuj" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "Dodaj grę" -#: app/UI.py:1694 +#: app/UI.py:1484 msgid "Uninstall from Selected Game" msgstr "Odinstaluj z wybranej gry" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." msgstr "Kopia zapasowa i przywracanie map" -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "Zarządzaj paczkami..." -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "Opcje" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "Wyjdź" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" msgstr "Paleta" -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "Wypełnij paletę" - -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "Zapisz paletę" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "Widok" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" msgstr "Palety" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "Opcje eksportu" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "Wypełnia puste miejsca na palecie losowymi przedmiotami." -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "Kopiowanie map" -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "Ładowanie map" -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "Usuwanie map" -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." msgstr "Analiza mapy nie powiodła się. Kopia zapas. nadal może być stworzona." -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "Nie znaleziono opisu." -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "Tryb 2-os." -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "Tryb 1-os." -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" msgstr "Ten plik już jest w kopii zapasowej. Czy chcesz go nadpisać? ({})" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" msgstr "Kopia zapasowa BEE2" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "Nie wybrano map do kopii zapasowej!" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" msgstr "Ta mapa już jest w katalogu gry. Czy chcesz ją nadpisać?" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "Załaduj kopię zapasową" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" msgstr "Kopia zapasowa w zip" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "Niezapisana kopia zapas." -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "Zapisz kopię zapas. jako" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "Potwierdź usunięcie" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" msgstr[0] "" @@ -873,169 +864,169 @@ msgstr[2] "" "Czy chcesz usunąć {} mapy?\n" "\n" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "Przywróć:" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" msgstr "Kopia zapasowa:" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "Zaznaczono" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "Usuń zaznaczenie" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" msgstr "BEEMOD {} - Kopia zapas. i przywracanie map" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "Nowa kopia zapas." -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "Otwórz kopię zapas." -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "Zapisz kopię zapas." -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" msgstr "Usuń grę" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "Gra" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" msgstr "Automatyczna kopia zapas. po wyeksportowaniu" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "Zachowuj (na grę):" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" msgstr "Kopia zapasowa i przywracanie map" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "Ten przedmiot nie może być obrócony." -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "Ten przedmiot może być skierowany w 4 kierunki." -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "Ten przedmiot może być umieszczany po bokach i na środku." -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "" "Ten przedmiot może być położony na środku w dwóch kierunkach, a także na " "bokach." -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "Ten przedmiot może być położony jak pasy świetlne." -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "Ten przedmiot może być obrócony na podłodze o 360 stopni." -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "Ten przedmiot jest umieszczany używając trajektorii katapulty." -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." msgstr "Ten przedmiot ustawia dozownik, by dosięgnął docelowej lokalizacji." -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "Ten przedmiot nie akceptuje sygnału wejściowego." -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "Ten przedmiot akceptuje sygnał wejściowy." -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." msgstr "" "Ten przedmiot ma 2 rodzaje sygnału wejścia (A i B), używa przedmiotu " "\"Wejście A i B\"." -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." msgstr "Ten przedmiot nie wysyła sygnału." -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." msgstr "Ten przedmiot może wysyłać sygnał." -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." msgstr "Ten przedmiot wysyła czasowy sygnał." -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "Ten przedmiot nie zajmuje przestrzeni za ścianami." -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "Ten przedmiot zajmuje przestrzeń za ścianami." -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "Ten przedmiot nie może być położony nigdzie..." -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "Ten przedmiot może być przymocowany tylko do sufitu." -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "Ten przedmiot może być położony tylko na podłodze." -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "Ten przedmiot może być położony na podłodze i na suficie." -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "Ten przedmiot może być umieszczony tylko na ścianach." -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "Ten przedmiot może być umieszczony na ścianach i na suficie." -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "Ten przedmiot może być umieszczony na ścianach i na podłodze." -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "Ten przedmiot może być przymocowany gdziekolwiek." -#: app/contextWin.py:226 +#: app/contextWin.py:227 msgid "No Alternate Versions" msgstr "Brak wersji alteratywnych" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." msgstr "" "Tuby transportowe akceptują sygnał włącz/wyłącz oraz sygnał zmiany " "kierunku." -#: app/contextWin.py:371 +#: app/contextWin.py:372 msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" " Cubes only." @@ -1057,11 +1048,11 @@ msgstr "" "łącznej wartości 2048. To jest porada ile może być takich przedmiotów " "łącznie położonych." -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "Opis:" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" @@ -1069,23 +1060,23 @@ msgstr "" "Nie udało się otworzyć przeglądarki. Czy chcesz w zamian skopiować link " "do schowka?" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "Więcej informacji>>" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." msgstr "Zmień domyślne wartości..." -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "Zmienia domyślne wartości dla tego przedmiotu w edytorze." -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "BEE2 - Eksport nieudany!" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." @@ -1094,13 +1085,13 @@ msgstr "" "kliknij OK, aby zweryfikować cache gry. Wtedy możesz wyeksportować " "ponownie." -#: app/gameMan.py:859 +#: app/gameMan.py:881 msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "" "Kopiowanie pliku kompilatora {file} nie powiodło się. Upewnij się, że gra" " {game} nie jest uruchomiona." -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." @@ -1109,48 +1100,48 @@ msgstr "" "Portal-gun i Paint-gun nie będą tam działały - zweryfikuj cache, aby to " "naprawić." -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" msgstr "BEE2 - Brakuje Plików Aperture Tag" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "Wybierz folder, gdzie znajduje się plik wykonywalny gry ({appname})..." -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "BEE2 - Dodaj grę" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "Znajdź plik .exe gry" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "Wykonywalny" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" msgstr "To nie wygląda jak prawidłowy folder gry!" -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "Portal Stories: Mel nie ma edytora!" -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "Wprowadź nazwę gry:" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "Ta nazwa jest już zajęta!" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "Wprowadź nazwę dla tej gry!" -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" @@ -1158,82 +1149,86 @@ msgstr "" "\n" "(BEE2 zostanie zamknięte, to jest ostatnia gra w katalogu!)" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "Na pewno chcesz usunąć \"{}\"?" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "Wiki..." -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "Oryginalne przedmioty..." #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "Serwer Discord..." -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "Własna muzyka..." -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "Magazyn aplikacji..." -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "Magazyn przedmiotów..." -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "Zgłoś błędy aplikacji..." -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "Zgłoś błędy przedmiotów..." #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "Portal 2" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "Aperture Tag" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "Portal Stories: Mel" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "Thinking With Time Machine" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "Zamknij" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "Pomoc" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "Licencje programów w BEE2" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "Licencje..." -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "Pozycja startowa" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "Pozycja końcowa" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" @@ -1241,23 +1236,31 @@ msgstr "" "Opóźnienie\n" "(0=nieskończoność)" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "Brak dostępnych właściwości!" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "Wybierz kolor" -#: app/music_conf.py:132 +#: app/music_conf.py:147 msgid "Select Background Music - Base" msgstr "Wybierz muzykę - Główna" -#: app/music_conf.py:133 +#: app/music_conf.py:148 msgid "" "This controls the background music used for a map. Expand the dropdown to" " set tracks for specific test elements." @@ -1265,7 +1268,7 @@ msgstr "" "Ta opcja kontroluje muzykę w tle użytą do mapy. Rozwiń to menu aby " "zobaczyć ścieżki dla konkretnych elementów testowych." -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." @@ -1273,77 +1276,77 @@ msgstr "" "Nie dodawaj muzyki do mapy w ogóle. Muzyka poszczególnych elementów " "testowych wciąż może być dodana." -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" msgstr "Muzyka żelu przyspieszającego" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" msgstr "Muzyka żelu repulsyjnego" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "Muzyka w Tubie Transportowej" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "Synchronizowana muzyka w Tubie" -#: app/music_conf.py:152 +#: app/music_conf.py:168 msgid "Select Excursion Funnel Music" msgstr "Wybierz muzykę w Tubie Transportowej" -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "Wybierz muzykę graną podczas podróży Tubą Transportową." -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "Nie graj żadnej muzyki podczas podróży Tubą." -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "Wybierz muzykę żelu repulsyjnego" -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "Wybierz muzykę graną podczas podczas skakania na żelu repulsyjnym." -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "Nie dodawaj żadnej muzyki podczas skakania na żelu repulsyjnym." -#: app/music_conf.py:179 +#: app/music_conf.py:197 msgid "Select Propulsion Gel Music" msgstr "Wybierz muzykę żelu przyspieszającego" -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." msgstr "Wybierz muzykę graną, gdy gracz ma duże wartości prędkości poziomej." -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "Nie graj muzyki przy dużych prędkościach." -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "Baza: " -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "Tuba: " -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "Odbicie: " -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "Prędkość: " -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 msgid "" "\n" "Launch Game?" @@ -1351,7 +1354,7 @@ msgstr "" "\n" "Uruchomić grę?" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 msgid "" "\n" "Minimise BEE2?" @@ -1359,7 +1362,7 @@ msgstr "" "\n" "Zminimalizować BEE2?" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" @@ -1367,7 +1370,7 @@ msgstr "" "\n" "Uruchomić grę i zminimalizować BEE2?" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" @@ -1375,7 +1378,7 @@ msgstr "" "\n" "Wyjść z BEE2?" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" @@ -1383,11 +1386,11 @@ msgstr "" "\n" "Uruchomić grę i wyjść z BEE2?" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" msgstr "Opcje BEE2" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." @@ -1395,71 +1398,71 @@ msgstr "" "Czasy cache pakietów zostały zresetowane. Zostaną one teraz wyodrębnione " "podczas następnego eksportu." -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." msgstr "\"Zachowaj zasoby gry\" zostało wyłączone." -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 msgid "Packages Reset" msgstr "Reset paczek" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "Ogólne" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "Okna" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "Tworzenie" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "OK" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "Po eksporcie:" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "Nic nie rób" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" msgstr "Minimalizuj BEE2" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "Wyjdź z BEE2" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "Po eksporcie, nic nie rób i zachowaj BEE2 na wierzchu." -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "Po eksporcie, zminimalizuj do paska zadań." -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "Po eksporcie, zamknij BEE2." -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "Uruchom grę" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "Po eksporcie, uruchom wybraną grę automatycznie." -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "Graj dźwięki" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." @@ -1467,19 +1470,19 @@ msgstr "" "Pyglet jest uszkodzony lub niezainstalowany. Efekty dźwiękowe zostały " "wyłączone." -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "Zresetuj cache paczek" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 msgid "Force re-extracting all package resources." msgstr "Wymuś ponowne wypakowanie wszystkich źródeł z paczek." -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "Trzymaj okienka na ekranie." -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." @@ -1487,11 +1490,11 @@ msgstr "" "Zapobiegaj wysuwaniu się okienek z opcjami poza krawędź ekranu. Jeżeli " "masz kilka monitorów, wyłącz tą opcję." -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 msgid "Keep loading screens on top" msgstr "Trzymaj ekrany ładowania na wierzchu" -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " @@ -1501,15 +1504,15 @@ msgstr "" "nie wyświetlają się na pasku zadań, nie mogą łatwo zostać wysunięte na " "wierzch z powrotem." -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "Resetuj pozycję wszystkich okien" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "Loguj brakujące liczby jednostek" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." @@ -1517,11 +1520,11 @@ msgstr "" "Podczas ładowania przedmiotów, loguj przedmioty z brakującymi liczbami " "jednostek do ich plików właściwości (properties.txt)" -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "Loguj, kiedy przedmiot nie ma stylu" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." @@ -1529,11 +1532,11 @@ msgstr "" "Loguj przedmioty, które nie mają wersji dla aktualnie wybranego stylu. To" " najczęściej oznacza, że przedmiot będzie wyglądał bardzo źle." -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "Loguj, kiedy przedmiot używa stylu pobratymczego" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." @@ -1542,11 +1545,11 @@ msgstr "" "używanie przedmiotu 1950s w mapie w stylu 1970s). Najczęściej wygląda " "dobrze, ale mogą być niezbędne poprawki." -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "Loguj brakujące źródła z plików paczek" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " @@ -1556,22 +1559,22 @@ msgstr "" "pliku zip. Może wyświetlać błąd nawet, gdy jest w porządku (w wymaganym " "zip-ie)." -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 msgid "Development Mode" msgstr "Tryb dewelopera" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 #, fuzzy msgid "" "Enables displaying additional UI specific for development purposes. " "Requires restart to have an effect." msgstr "Wyświetlaj dodatkowe UI do celów deweloperskich." -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "Zachowaj zasoby gry" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 msgid "" "When exporting, do not copy resources to \n" "\"bee2/\" and \"sdk_content/maps/bee2/\".\n" @@ -1582,19 +1585,19 @@ msgstr "" "\"sdk_content/maps/bee2/\". Uruchamiaj tylko, jeżeli tworzysz nową " "zawartość, aby upewnić się, że nie została nadpisana." -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "Pokazuj okienko logów" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "Pokazuj plik logów w czasie rzeczywistym." -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "Wymuś modele edytora" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " @@ -1604,14 +1607,22 @@ msgstr "" "Portal 2 ma limit 1024 modelów załadowanych w pamięci naraz, dlatego " "trzeba wyłączyć te nieużywane, aby zwolnić miejsce." -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 msgid "Dump All objects" msgstr "Zachowuj wszystkie obiekty" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "Zachowuj listę przedmiotów" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" msgstr "BEE2 - Niezbędny restart!" @@ -1627,56 +1638,107 @@ msgid "BEE2 - Manage Packages" msgstr "BEE2 - Ustawienia paczek" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "Puste" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "BEEMod" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" msgstr "Portal 2 - Złożone" -#: app/richTextBox.py:183 +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "Wyczyść paletę" + +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "Usuń paletę" + +#: app/paletteUI.py:115 +#, fuzzy +msgid "Change Palette Group..." +msgstr "Zapisz paletę jako..." + +#: app/paletteUI.py:121 +#, fuzzy +msgid "Rename Palette..." +msgstr "Zapisz paletę..." + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "Wypełnij paletę" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "Zapisz paletę" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "Usuń paletę \"{}\"" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "BEE2 - Zapisz paletę" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "Wpisz nazwę:" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "Otworzyć \"{}\" w domyślnej przeglądarce?" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "Nie dodawaj niczego." #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "Sugestowane" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." msgstr "Graj próbę tego przedmiotu." -#: app/selector_win.py:688 -msgid "Reset to Default" -msgstr "Zresetuj do domyślnych" +#: app/selector_win.py:862 +#, fuzzy +msgid "Select Suggested" +msgstr "Sugestowane" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "Inne" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "Autor: {}" @@ -1684,101 +1746,105 @@ msgstr[1] "Autorzy: {}" msgstr[2] "Autorzy: {}" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "Kolory: R={r}, G={g}, B={b}" -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "Sugestowane" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "Konfiguruj symbole" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 msgid "Selected" msgstr "Wybrane" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "Symbol: {}" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "Tryb jednoosobowy" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 msgid "Cooperative" msgstr "Tryb kooperacji" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "ATLAS (1-os. i 2-os.)" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "P-Body (1-os. i 2-os.)" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "Postacie ludzkie (Bendy i Chell)" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "SI (ATLAS, P-Body i tryb 2-os.)" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "Śmierć - Toksyczna maź" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "Śmierć - Wieżyczki" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" msgstr "Śmierć - Zgniatacz" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "Śmierć - Pole laserowe" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "Transkrypcja:" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "Zapisz" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "Odp" -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "BEE2 - Konfiguracja {}" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "Podczas testu" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." msgstr "Ścieżki grane podczas właściwego testu, po pewnych wydarzeniach." -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "Odpowiedzi" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "Ścieżki grane w odpowiedzi na odpowiednie wydarzenia w trybie 2-os." -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "Brak nazwy!" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "Bez nazwy?" @@ -1803,3 +1869,9 @@ msgstr "Bez nazwy?" #~ msgid "Loading Images" #~ msgstr "Ładowanie obrazów" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "Ta paleta już istnieje. Nadpisać?" + +#~ msgid "Reset to Default" +#~ msgstr "Zresetuj do domyślnych" + diff --git a/i18n/ru.po b/i18n/ru.po index deff33b3b..575570433 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -3,111 +3,111 @@ msgid "" msgstr "" "Project-Id-Version: BEEMOD2\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" +"PO-Revision-Date: 2021-09-08 09:58+0000\n" "Last-Translator: \n" "Language: ru\n" -"Language-Team: ru \n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"Language-Team: Russian\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10 >= 2 &&" +" n%10<=4 &&(n%100<10||n%100 >= 20)? 1 : 2)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "Пропущено!" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "Версия:" -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "Отмена" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "Очистить" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "Скопировать" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "Показать:" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "Логи - {}" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "Сообщения дебага" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "Стандартный" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "Только предупреждения" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "Пакеты" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "Загрузка объектов" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "Инициализация UI" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "Better Extended Editor for Portal 2" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "__LANG_USE_SANS_SERIF__" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "Выбрать все чекбоксы." -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "АТЛАС" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "П-Боди" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Челл" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Бенди" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " "shapes." msgstr "" -"Щетки образуют стенки или другие части испытательной камеры. Если она " +"Браши образуют стенки или другие части испытательной камеры. Если она " "высока, это может помочь уменьшить размер карты и удалить сложные фигуры." #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 msgid "" "Entities are the things in the map that have functionality. Removing " "complex moving items will help reduce this. Items have their entity count" @@ -126,21 +126,21 @@ msgstr "" "дополнительных сущностей во время выполнения." #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " "lights. Hiding complex antlines or setting them to signage will reduce " "this." msgstr "" "Оверлеи - это небольшие изображения, прикрепленные к поверхностям, таким " -"как знаки или индикаторы. Сокрытие сложных антлайнов или установка их на " -"вывеску уменьшит это." +"как знаки или индикаторы. Скрытие сложных антлайнов или установка " +"соединений в значки уменьшит это." -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "Коридор" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." @@ -148,51 +148,52 @@ msgstr "" "Коридор будет выбран случайно. Он сохранится в данных головоломки и не " "изменится." -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "Случайно" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "Изображения" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." msgstr "" -"Настройки в этой панели могут быть изменены без экспорта или перезапуска " -"игры." +"Эти настройки могут быть \n" +"изменены без экспорта или\n" +"перезапуска игры" -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "Настройки карты" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "Настройки компиляции" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" -msgstr "Эскиз" +msgstr "Превью" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "Автоматический" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "PeTI" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "Свой:" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "Очистить старые скриншоты" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -205,21 +206,21 @@ msgstr "" "(в течение последних нескольких часов), будет использоваться стандартный " "скриншот PeTI." -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "Использовать превью карты как в редакторе уровней." -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" "Images will be converted to JPEGs if needed." msgstr "" -"Использовать кастомное изображение для эскиза. Нажмите на скриншот, чтобы" +"Использовать кастомное изображение для превью. Нажмите на скриншот, чтобы" " выбрать его.\n" -"Изображения переконвертируются в JPEG, если это будет нужну" +"Изображения переконвертируются в JPEG, если это будет нужно" -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " @@ -227,19 +228,25 @@ msgstr "" "Автоматическое удаление неиспользованных скриншотов. Выключите, если вы " "хотите оставить их в \"portal2/screenshots\"." -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "Освещение:" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "Быстрое" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "Полное" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" @@ -250,7 +257,7 @@ msgstr "" "казаться неправильными.\n" "При публикации это игнорируется." -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " "to compute. Use if you're arranging lights.\n" @@ -261,11 +268,11 @@ msgstr "" " освещением.\n" "При публикации это всегда используется." -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "Дампить файлы пакетов в:" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." @@ -273,19 +280,19 @@ msgstr "" "При компиляции дампить все файлы, которые были использованы в карте. " "Полезно, если вы собираетесь редактировать карты в Hammer." -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "Последняя компиляция:" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "Сущность" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "Оверлей" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." @@ -293,19 +300,19 @@ msgstr "" "Обновление индикаторов компиляции. Нажмите после компиляции, чтобы " "отобразить новые значения." -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" -msgstr "Кисть" +msgstr "Браш" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "Озвучка:" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "Использовать приоритетную озвучку." -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " @@ -316,19 +323,25 @@ msgstr "" "тестовых элементов. Если отключено, будут использоваться любые возможные " "фразы." -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "Спавниться в:" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "Двери входа" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "Лифте" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " "examine the entry and exit corridors." @@ -336,101 +349,101 @@ msgstr "" "При одиночном предпросмотре, вы спавнитесь внутри лифта. Используйте это " "для изучения коридоров входа и выхода." -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 msgid "When previewing in SP, spawn just before the entry door." msgstr "При одиночном предпросмотре, вы спавнитесь на входе." -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "Коридор:" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" -msgstr "" +msgstr "Коридор входа" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" -msgstr "" +msgstr "Коридор выхода" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" -msgstr "" +msgstr "Коридор выхода в кооперативном режиме" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" -msgstr "Вход в одиночном режиме:" +msgstr "Вход:" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" -msgstr "Вход в коопе" +msgstr "Выход:" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "Выход в коопе:" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "Моделька игрока:" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "Опции компиляции" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "Опции компилятора - {}" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "Кейв Мултивселенной" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "Проигрывать реплики Кейва Джонсона из мастерской в начале." -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" -msgstr "Защита от порталов (распылитель)" +msgstr "Защита от порталов (поле антиэкспроприации)" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" " fizzle portals on activation." msgstr "" -"Добавляет хитбоксы, убирая возможность ставить порталы через распылитель." -" Это предотвращает размещение порталов в узких пространствах между " -"распылителями или во время их активации." +"Добавляет хитбоксы, убирая возможность ставить порталы через поле " +"антиэкспроприации. Это предотвращает размещение порталов в узких " +"пространствах между полями антиэкспроприации или во время их активации." -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" -msgstr "Хорош базарить" +msgstr "Заглушить диалоги посередине карты" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "" "Заглушает все реплики за исключением тех, которые проигрываются на " "входе/выходе." -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "Разблокировать стандартные предметы" -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" msgstr "" "Разрешить создание и удаление входа/выхода и большого зала наблюдения. " -"Осторожно, может получиться что-то странное!" +"Осторожно, удаления этих предметов может привести к странным результатам!" -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "Добавление тумана над токсичной жидкостью." -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " @@ -440,11 +453,11 @@ msgstr "" "количество сущностей с большими и сложными ямами с жижей, поэтому, " "выключите если требуется." -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 msgid "Light Reversible Excursion Funnels" msgstr "Освещение разворачиваемых экскурсионных воронок." -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -456,11 +469,11 @@ msgstr "" "освещением. Отключите, чтобы решить эти проблемы отключением света от " "воронок. Воронки с одной полярностью не имеют такой проблемы." -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "Рамки символов" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." @@ -468,74 +481,77 @@ msgstr "" "Если используется больше 10 антлайнов-символов, сами символы начинают " "повторятся. Эта функция добавляет цветные рамки по краям символов." -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "Стандартный: да" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "Стандартный: нет" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "Стиль: без стиля" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "Стиль: все" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "Стиль: {}" msgstr[1] "Стили: {}" msgstr[2] "" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 msgid "Style/Item Properties" msgstr "Параметры объектов и стилей" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "Стили" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "Все:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "Выбранный стиль:" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "Другие стили:" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "Нет опций!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "Нету!" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "Предметы" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "Показать/скрыть окно \"{}\"" -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "Экспорт..." -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "Выберите скайбоксы" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " @@ -545,57 +561,57 @@ msgstr "" "цвет неба (видимый в некоторых предметах), стиль бездонной ямы (если " "таковая имеется), а также цвет \"тумана\" (видимый в больших камерах)." -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "3D скайбокс" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "Цвет дыма" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "Выберите дополнительную озвучку" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " "The additional \"Multiverse\" Cave lines are controlled separately in " "Style Properties." msgstr "" -"Выберите, какие дополнительные голоса будут воспроизводиться, когда игрок" -" входит или выходит из камеры. Они выбираются в зависимости от того, " -"какие элементы присутствуют на карте. Дополнительные линии Кейва " +"Выберите, какая дополнительная озвучка будет воспроизводиться, когда " +"игрок входит или выходит из камеры. Они выбираются в зависимости от того," +" какие элементы присутствуют на карте. Дополнительные линии Кейва " "Мультивселенной управляются отдельно в Свойствах стиля." -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "" -"Не добавлять дополнительную озвучку, только Кейва Мультивселенной, если " +"Не добавлять дополнительную озвучку, только Кейва мультивселенной, если " "он включён." -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "<Только Кейв мультивселенной>" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "Персонажи" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "Турель будет стрелять в монитор" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "Картинка для мониторов" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "Выберите стиль" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -610,15 +626,15 @@ msgstr "" "Стиль в широком смысле определяет период времени, в котором была создана " "камера." -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "Видео лифта" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "Выберите видео лифта" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " @@ -629,97 +645,69 @@ msgstr "" "значение \"Нет\", то при каждом воспроизведении карты будет выбираться " "случайное видео, как в PeTI по умолчанию.\n" -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "В этом стиле нет экранов в лифтовых помещениях." -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "Выбрать случайное видео." -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "Несколько ориентаций" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" -msgstr "Выбранные предметы и стили удачно экспортированы!" +msgstr "Выбранные предметы и стили успешно экспортированы!" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" "Warning: VPK files were not exported, quit Portal 2 and Hammer to ensure " "editor wall previews are changed." msgstr "" -"Внимание: VPK-фалы не были экспортированы. Выйдите из Portal 2 и Hammer " +"Внимание: VPK-файлы не были экспортированы. Выйдите из Portal 2 и Hammer " "для экспорта." -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "Удалить набор \"{}\"" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "BEE2 - Сохранить Набор" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "Введите название:" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "Такой набор уже существует. Перезаписать?" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "Вы точно хотите удалить \"{}\"?" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "Очистить набор" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "Удалить набор" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." -msgstr "Сохранить набор..." +msgstr "Сохранить палитру..." -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." -msgstr "Сохранить набор как..." +msgstr "Сохранить палитру как..." -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" -msgstr "Сохранить настройки в наборе" +msgstr "Сохранить настройки в палитре" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "Музыка:" -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "{arr} Использовать предложенное {arr}" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "Стиль:" -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "Голос:" -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "Скайбокс:" -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "Видео лифта:" -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." @@ -727,304 +715,312 @@ msgstr "" "Включить или отключить определенную озвучку, чтобы предотвратить её " "добавление." -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "Все предметы:" -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "Экспортировать в \"{}\"..." -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "Файл" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "Экспорт" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "Добавить игру" -#: app/UI.py:1694 +#: app/UI.py:1484 msgid "Uninstall from Selected Game" msgstr "Убрать из выбранной игры" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." -msgstr "Резервирование/восстановление головоломок" +msgstr "Бэкап/Восстановление головоломок..." -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "Управление пакетами..." -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "Опции" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "Выход" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" -msgstr "Набор" - -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "Заполнить набор" +msgstr "Палитра" -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "Сохранить набор" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "Вид" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" -msgstr "Наборы" +msgstr "Палитры" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "Экспорт опций" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "Заполнить пустые слоты в наборе случайными предметами." -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "Копирование карт" -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "Загрузка карт" -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "Удаление карт" -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." -msgstr "Не удалось прочитать файл паззла. Он всё ещё может быть зарезервирован." +msgstr "Не удалось прочитать файл карты. Он всё ещё может быть помещен в бэкап." -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "Не найдено описания." -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "Кооператив" -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "Один игрок" -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" -msgstr "Файл с таким именем уже зарезервирован. Вы хотите его перезаписать? ({})" +msgstr "Бэкап с таким именем уже имеется. Вы хотите его перезаписать? ({})" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" -msgstr "Резервирование BEE2" +msgstr "Бэкап BEE2" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "Не выбраны карты для резервирования!" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" msgstr "Эта карта уже в папке игры. Хотите перезаписать? ({})" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "Загрузить бекап" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" -msgstr "Зарезервировать zip" +msgstr "Бэкап" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "Несохранённый бекап" -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "Сохранить бекап как" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "Подтвердите удаление" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" -msgstr[0] "Вы хотите удалить {} карту?" -msgstr[1] "Вы хотите удалить {} карты?" +msgstr[0] "Вы хотите удалить карту {}?" +msgstr[1] "Вы хотите удалить карты {}?" msgstr[2] "" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "Восстановить:" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" -msgstr "Зарезервировать:" +msgstr "Бэкап:" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "Выбранные" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "Удалить выбранные" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" -msgstr "BEEMOD {} - Зарезервировать/Восстановить головоломки" +msgstr "BEEMOD {} - Бэкап/Восстановление головоломок" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "Новый бекап" -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "Открыть бекап" -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "Сохранить бекап" -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" -msgstr "Удалить бекап" +msgstr "Удалить BEE2 из текущей игры" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "Игра" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" -msgstr "Автоматически резервировать после сохранения" +msgstr "Создавать авто-бэкап после экспортирования" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "Сохранять (в течение игры):" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" -msgstr "Зарезервировать/Восстановить головоломки" +msgstr "Бэкап/Восстановление головоломок" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "Этот предмет нельзя повернуть." -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "Этот предмет может быть повёрнут в 4 стороны." -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "Этот предмет может быть размещён по сторонам и по центру." -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "Этот предмет может находиться по центру в 2 направлениях и по сторонам." -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "Этот предмет может быть размещён как световая лента." -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "Этот предмет может быть повёрнут на 360 градусов." -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "Этот предмет устанавливается с помощью мишени катапульты." -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." -msgstr "Этот предмет создаёт выбрасыватель." +msgstr "Этот предмет создаёт раздатчик." -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "К этому предмету нельзя подключать антлайны." -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "К этому предмету можно подключать антлайны" -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." -msgstr "У этого предмета есть два типа входа (А и Б), он использует \"A/B Input\"." +msgstr "" +"У этого предмета есть два типа входящих соединений (А и Б), он использует" +" \"A/B Input\"." -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." -msgstr "У этого предмета нет выходов." +msgstr "У этого предмета нет исходящих соединений" -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." -msgstr "У этого предмета есть выходы." +msgstr "У этого предмета есть исходящие соединения." -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." -msgstr "У этого предмета есть выходы с таймерами." +msgstr "У этого предмета есть исходящие соединения с таймером" -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "Этот предмет не занимает место под собой." -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "Этот предмет занимает место под собой." -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "Этот предмет нельзя разместить нигде..." -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "Этот предмет можно разметить только на потолке." -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "Этот предмет можно разместить только на стене." -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "Этот предмет можно разместить на полу и потолке." -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "Этот предмет можно разместить только на стене." -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "Этот предмет можно разместить на стене и потолке." -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "Этот предмет может быть помещён на стене и на полу." -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "Этот предмет может быть помещён где угодно." -#: app/contextWin.py:226 +#: app/contextWin.py:227 msgid "No Alternate Versions" msgstr "Нет других версий" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." -msgstr "У экскурсионных воронок есть вкл/выкл входы и входы направления." +msgstr "" +"У экскурсионных воронок есть вкл/выкл переключение и переключение " +"направления." -#: app/contextWin.py:371 +#: app/contextWin.py:372 msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" " Cubes only." @@ -1046,47 +1042,48 @@ msgstr "" "одной карте всего 2048 объектов. Это число показывает, сколько таких " "предметов может быть размещено на одной карте." -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "Описание:" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" msgstr "Не удалось открыть браузер. Вы хотите оставить URL в буфере обмена?" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "Больше информации>>" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." -msgstr "Изменить умолчания..." +msgstr "Изменить значения по умолчанию..." -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "Изменить стандартные настройки объекта при размещении." -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "BEE2 - Экспорт не удался!" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." msgstr "" -"Файл компилятора {file} не найден. Закройте приложения Steam, нажмите ОК " -"для подтверждения кэша игры. Вы можете экспортировать снова." +"Файл компилятора {file} не найден. Закройте приложение Steam, затем " +"нажмите ОК для проверки кэша игры. Затем, вы сможете экспортировать " +"снова." -#: app/gameMan.py:859 +#: app/gameMan.py:881 msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "" -"Не удалось скопировать файл компилсятора {file}. Необходимая игра не " -"запущена." +"Не удалось скопировать файл компилсятора {file}. Убедитесь, что игра " +"{game} не запущена." -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." @@ -1094,131 +1091,135 @@ msgstr "" "Кооперативный краскопульт ApTag не найден! Кооперативные краскопульты не " "будут работать - подтвердите кэш чтобы исправить." -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" -msgstr "BEE2 - не найдены файлы Aperture Tag" +msgstr "BEE2 - Не найдены файлы Aperture Tag" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "Выберите папку с .exeшником {appname}..." -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "BEE2 - Добавить игру" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "Найдите .exeшник игры" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "Исполняемый файл" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" -msgstr "Так, блэт. Это не похоже на допустимую папку с игрой!" +msgstr "Это не похоже на верную папку с игрой" -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "В Portal Stories: Mel нет редактора!" -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "Введите название игры:" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "Это название уже занято!" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "Пожалуйста, введите название игры!" -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" msgstr "" "\n" -"(BEE2 выйдет, это последний игровой набор!)" +"(BEE2 закроется!)" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "Вы точно хотите удалить \"{}\"?" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "Вики..." -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "Стандартные предметы..." #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "Дискорд-сервер" -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "aerond's Music Changer..." -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "Репозиторий приложения..." -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "Репозиторий предметов..." -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "Сообщить о баге в приложении..." -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "Сообщить о баге с предметами..." #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "Portal 2" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "Aperture Tag" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "Portal Stories: Mel" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "Thinking With Time Machine" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "Закрыть" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "Помощь" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "Титры BEE2" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "Титры..." -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "Начальная позиция" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "Конечная позиция" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" @@ -1226,23 +1227,31 @@ msgstr "" "Задержка\n" "(0=бесконечно)" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "Нет доступных параметров!" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "Настройки для \"{}\"" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "Поиск:" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "Выберите цвет" -#: app/music_conf.py:132 +#: app/music_conf.py:147 msgid "Select Background Music - Base" msgstr "Выберите основную фоновую музыку" -#: app/music_conf.py:133 +#: app/music_conf.py:148 msgid "" "This controls the background music used for a map. Expand the dropdown to" " set tracks for specific test elements." @@ -1250,7 +1259,7 @@ msgstr "" "Это позволяет выбрать фоновую музыку для карты. Разверните выпадающий " "список, чтобы установить музыку для отдельных тестовых элементов." -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." @@ -1258,51 +1267,51 @@ msgstr "" "Не добавлять музыку в карту. Музыку для отдельных тестовых элементов всё " "ещё можно будет добавить." -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" -msgstr "Эффект ускорения" +msgstr "Звук ускорающего геля" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" -msgstr "Эффект отскока" +msgstr "Звук отталкивающего геля" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "Музыка экскурсионной воронки" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "Синхронная музыка воронки" -#: app/music_conf.py:152 +#: app/music_conf.py:168 msgid "Select Excursion Funnel Music" msgstr "Выбрать музыку для экскурсионной воронки" -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "Устанавливает музыку, которая проигрывается внутри экскурсионных воронок" -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "Нет музыки для воронок." -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "Выберите музыку для отталкивающего геля" -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "Устанавливает музыку, проигрывающуюся при отскоке от геля." -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "Не добавлять музыку при отскоке от геля." -#: app/music_conf.py:179 +#: app/music_conf.py:197 msgid "Select Propulsion Gel Music" -msgstr "Выберите музыку для проталкивающего геля" +msgstr "Выберите музыку для ускоряющего геля" -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." @@ -1310,27 +1319,27 @@ msgstr "" "Устанавливает музыку, которая проигрывается при развитии большой " "горизонтальной скорости." -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "Не добавлять музыку при быстром беге." -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "Основа:" -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "Воронка:" -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "Отскок:" -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "Ускорение:" -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 msgid "" "\n" "Launch Game?" @@ -1338,7 +1347,7 @@ msgstr "" "\n" "Запустить игру?" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 msgid "" "\n" "Minimise BEE2?" @@ -1346,7 +1355,7 @@ msgstr "" "\n" "Свернуть BEE2?" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" @@ -1354,7 +1363,7 @@ msgstr "" "\n" "Запустить игру и свернуть BEE2?" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" @@ -1362,7 +1371,7 @@ msgstr "" "\n" "Выйти из BEE2?" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" @@ -1370,83 +1379,83 @@ msgstr "" "\n" "Запустить игру и выйти из BEE2?" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" -msgstr "Опции BEE2" +msgstr "Настройки BEE2" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." msgstr "" -"Время кэширования пакетов было сброшено. Они будут извлечены во время " -"следующего экспорта." +"Время кэширования пакетов было сброшено. Они будут загружены полностью во" +" время следующего экспорта." -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." -msgstr "\"Сохранение игровых ресурсов\" отключено." +msgstr "\"Предохранение игровых директорий\" отключено." -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 msgid "Packages Reset" msgstr "Сброс пакетов" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "Основное" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "Окно" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "Разработка" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "ОК" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "После экспорта:" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "Ничего не делать" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" -msgstr "Свернуть шею BEE2" +msgstr "Свернуть BEE2" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "Выйти из BEE2" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "Не делать ничего после экспорта, и оставлять BEE2 развёрнутым." -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "После экспорта сворачивать в панель задач." -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "После экспорта, выходить из BEE2." -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "Запускать игру" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "Автоматически запускать выбранную игру после экспорта." -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "Проигрывать звуки" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." @@ -1454,19 +1463,19 @@ msgstr "" "Pyglet не установлен или сломан.\n" "Звуковы эффекты отключены." -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "Сбросить кеширование пакетов" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 msgid "Force re-extracting all package resources." msgstr "Принудительное повторное извлечение всех ресурсов пакета." -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "Держать окна внутри экрана" -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." @@ -1474,11 +1483,11 @@ msgstr "" "Запрещать перемещение подокон за пределы границ экрана. Если у вас " "несколько мониторов, отключите это." -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 msgid "Keep loading screens on top" msgstr "Держать экран загрузки поверх всех окон" -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " @@ -1487,15 +1496,15 @@ msgstr "" "Заставить загрузочные экраны располагаться поверх других окон. Поскольку " "они не отображаются на панели задач, их невозможно снова показать сверху." -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "Сбросить все позиции окон" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "[Логировать] количество утерянных сущностей" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." @@ -1503,11 +1512,11 @@ msgstr "" "При загрузке предметов [логировать] предметы без максимально допустимого " "количества в их properties.txt файле." -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "[Логировать] предметы без стилей" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." @@ -1516,11 +1525,11 @@ msgstr "" "определенного стиля. Обычно это означает, что предмет будет выглядеть " "очень плохо." -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "[Логировать] предметы, использующие родительские стили" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." @@ -1529,11 +1538,11 @@ msgstr "" "стиля (например, 1970-е годы с использованием элементов 1950-х годов). " "Обычно это нормально, но, возможно, потребуется исправить." -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "[Логировать] недоступные ресурсы файлов пакетов" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " @@ -1543,11 +1552,11 @@ msgstr "" "отсутствующие в zip. Это может быть нормально (в предварительном " "zip-файле), но часто указывает на ошибку." -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 msgid "Development Mode" msgstr "Режим разработчика" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 #, fuzzy msgid "" "Enables displaying additional UI specific for development purposes. " @@ -1556,11 +1565,11 @@ msgstr "" "Позволяет отображать дополнительный пользовательский интерфейс для " "разработчиков." -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "Сохранять папки игры" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 msgid "" "When exporting, do not copy resources to \n" "\"bee2/\" and \"sdk_content/maps/bee2/\".\n" @@ -1571,19 +1580,19 @@ msgstr "" "\"sdk_content/maps/bee2/\". Включите только в том случае, если вы " "разрабатываете новый контент, чтобы он не был перезаписан." -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "Показывать окно с логом" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "Показывать лог в реальном времени" -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "Форсить модели редактора" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " @@ -1593,17 +1602,27 @@ msgstr "" "2 имеет ограничение в 1024 модели, загруженные в память одновременно, " "поэтому нам нужно отключить неиспользуемые, чтобы освободить их." -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 msgid "Dump All objects" msgstr "Дампить все объекты" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "Дампить список объектов" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "Перезагрузить все изображения" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" +"Перезагрузить все изображения в приложении. От этого приложение зависнет " +"ненадолго" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" -msgstr "BEE2 - Необходима перезагрузка!" +msgstr "BEE2 - Необходим перезапуск!" #: app/packageMan.py:65 msgid "" @@ -1616,140 +1635,195 @@ msgid "BEE2 - Manage Packages" msgstr "BEE2 - Управление пакетами" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "<Последний экспорт>" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "Пустой" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "BEEMod" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" -msgstr "Portal 2 вылетел" +msgstr "Portal 2 был свернут" + +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "Очистить палитру" -#: app/richTextBox.py:183 +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "Удалить палитру" + +#: app/paletteUI.py:115 +#, fuzzy +msgid "Change Palette Group..." +msgstr "Сохранить палитру как..." + +#: app/paletteUI.py:121 +#, fuzzy +msgid "Rename Palette..." +msgstr "Сохранить палитру..." + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "Заполнить набор" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "Сохранить палитру" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "Удалить палитру \"{}\"" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "BEE2 - Сохранить Палитру" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "Введите название:" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "Открыть \"{}\" в стандартном браузере?" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "{} Превью" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "Ничего не добавляйте." #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "<Ничего>" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "Предложенное" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." -msgstr "Воспроизвести пример этого предмета." +msgstr "Воспроизвести сэмпл." -#: app/selector_win.py:688 -msgid "Reset to Default" -msgstr "Сбросить до умолчаний" +#: app/selector_win.py:862 +#, fuzzy +msgid "Select Suggested" +msgstr "Предложенное" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "Другое" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "Автор: {}" msgstr[1] "Авторы: {}" -msgstr[2] "" +msgstr[2] "Авторы: {}" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "Цвет: R={r}, G={g}, B={b}" -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "Предложенное" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "Настроить обозначения" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 msgid "Selected" msgstr "Выбранное" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "Знак: {}" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "Один игрок" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 msgid "Cooperative" msgstr "Кооператив" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "АТЛАС (один игрок/кооп)" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "П-Боди (один игрок/кооп)" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "Человеческие персонажи (Бенди и Челл)" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "ИИ-персонажи (АТЛАС, П-Боди или кооп)" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "Смерть - Токсичная жижа" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "Смерть - Турели" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" -msgstr "Смерть - Хрясь)))" +msgstr "Смерть - Пресс" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "Смерть - Лазерное поле" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "Текст:" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "Сохранить" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "Реакция" -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "BEE2 - Управление \"{}\"" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "Тестовая камера" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." @@ -1757,22 +1831,25 @@ msgstr "" "Реплики, проигрываемые во время прохождения самой тестовой камеры, или " "после специальных событий." -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "Реакции" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "Реплики для реакций на события в кооперативе" -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "Нет имени!" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "Нет имени?" -#~ msgid "Loading Images" -#~ msgstr "Загрузка изображений" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "Такая палитра уже существует. Перезаписать?" + +#~ msgid "Reset to Default" +#~ msgstr "Сбросить до значений по умолчанию" diff --git a/i18n/zh.po b/i18n/zh.po index cd3de13be..a3036e1aa 100644 --- a/i18n/zh.po +++ b/i18n/zh.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: https://github.com/BEEmod/BEE2.4/issues\n" -"POT-Creation-Date: 2021-07-02 15:17+1000\n" +"POT-Creation-Date: 2021-11-14 15:29+1000\n" "PO-Revision-Date: 2019-11-22 10:47+1000\n" "Last-Translator: Antecer \n" "Language: zh\n" @@ -12,91 +12,91 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.9.1\n" -#: loadScreen.py:208 +#: loadScreen.py:224 msgid "Skipped!" msgstr "跳过!" -#: loadScreen.py:209 +#: loadScreen.py:225 msgid "Version: " msgstr "版本:" -#: app/optionWindow.py:260 app/packageMan.py:116 app/selector_win.py:699 -#: loadScreen.py:210 +#: app/optionWindow.py:258 app/packageMan.py:116 app/selector_win.py:874 +#: loadScreen.py:226 msgid "Cancel" msgstr "取消" -#: app/UI.py:1724 loadScreen.py:211 +#: app/paletteUI.py:104 loadScreen.py:227 msgid "Clear" msgstr "清空模板" -#: loadScreen.py:212 +#: loadScreen.py:228 msgid "Copy" msgstr "复制" -#: loadScreen.py:213 +#: loadScreen.py:229 msgid "Show:" msgstr "显示:" -#: loadScreen.py:214 +#: loadScreen.py:230 msgid "Logs - {}" msgstr "日志 - {}" -#: loadScreen.py:216 +#: loadScreen.py:232 msgid "Debug messages" msgstr "调试信息" -#: loadScreen.py:217 +#: loadScreen.py:233 msgid "Default" msgstr "默认值" -#: loadScreen.py:218 +#: loadScreen.py:234 msgid "Warnings Only" msgstr "仅记录警告" -#: loadScreen.py:228 +#: loadScreen.py:244 msgid "Packages" msgstr "资源包" -#: loadScreen.py:229 +#: loadScreen.py:245 msgid "Loading Objects" msgstr "正在载入对象" -#: loadScreen.py:230 +#: loadScreen.py:246 msgid "Initialising UI" msgstr "初始化界面" -#: loadScreen.py:231 +#: loadScreen.py:247 msgid "Better Extended Editor for Portal 2" msgstr "更棒的传送门2扩展编辑器" -#: utils.py:724 +#: localisation.py:102 msgid "__LANG_USE_SANS_SERIF__" msgstr "" -#: app/CheckDetails.py:219 +#: app/CheckDetails.py:220 msgid "Toggle all checkboxes." msgstr "反选所有复选框" -#: app/CompilerPane.py:69 +#: app/CompilerPane.py:70 msgid "ATLAS" msgstr "ATLAS(蓝色机器人)" -#: app/CompilerPane.py:70 +#: app/CompilerPane.py:71 msgid "P-Body" msgstr "P-Body(橙色机器人)" -#: app/CompilerPane.py:71 app/voiceEditor.py:41 +#: app/CompilerPane.py:72 app/voiceEditor.py:42 msgid "Chell" msgstr "Chell(主角雪儿)" -#: app/CompilerPane.py:72 app/voiceEditor.py:40 +#: app/CompilerPane.py:73 app/voiceEditor.py:41 msgid "Bendy" msgstr "Bendy(纸片人小黑)" #. i18n: Progress bar description -#: app/CompilerPane.py:119 +#: app/CompilerPane.py:123 msgid "" "Brushes form the walls or other parts of the test chamber. If this is " "high, it may help to reduce the size of the map or remove intricate " @@ -107,7 +107,7 @@ msgstr "" "(注:减少复杂的地图结构能有效提升画面渲染性能!)" #. i18n: Progress bar description -#: app/CompilerPane.py:126 +#: app/CompilerPane.py:132 #, fuzzy msgid "" "Entities are the things in the map that have functionality. Removing " @@ -123,7 +123,7 @@ msgstr "" "(注:地图内的实体数量被限制为2048个,超过这个数量有可能导致游戏崩溃!)" #. i18n: Progress bar description -#: app/CompilerPane.py:136 +#: app/CompilerPane.py:144 #, fuzzy msgid "" "Overlays are smaller images affixed to surfaces, like signs or indicator " @@ -133,25 +133,25 @@ msgstr "" "标记是地面(或墙面)上较小的图片,例如标志或指示灯。\n" "隐藏蚂蚁线或将其设置为标志将有助于升画面渲染性能。" -#: app/CompilerPane.py:268 +#: app/CompilerPane.py:277 msgid "Corridor" msgstr "走廊" -#: app/CompilerPane.py:308 +#: app/CompilerPane.py:316 msgid "" "Randomly choose a corridor. This is saved in the puzzle data and will not" " change." msgstr "随机选择一个走廊,被随机选中的走廊将保存在地图编辑器中且不会再次变更。" -#: app/CompilerPane.py:314 app/UI.py:642 +#: app/CompilerPane.py:322 app/UI.py:662 msgid "Random" msgstr "随机" -#: app/CompilerPane.py:417 +#: app/CompilerPane.py:425 msgid "Image Files" msgstr "图片文件" -#: app/CompilerPane.py:489 +#: app/CompilerPane.py:497 msgid "" "Options on this panel can be changed \n" "without exporting or restarting the game." @@ -159,35 +159,35 @@ msgstr "" "修改此面板的选项将立即生效,\n" "而无需导出或重新启动游戏。" -#: app/CompilerPane.py:504 +#: app/CompilerPane.py:512 msgid "Map Settings" msgstr "地图设置" -#: app/CompilerPane.py:509 +#: app/CompilerPane.py:517 msgid "Compile Settings" msgstr "编译设置" -#: app/CompilerPane.py:523 +#: app/CompilerPane.py:531 msgid "Thumbnail" msgstr "地图预览快照" -#: app/CompilerPane.py:531 +#: app/CompilerPane.py:539 msgid "Auto" msgstr "自动获取截图" -#: app/CompilerPane.py:539 +#: app/CompilerPane.py:547 msgid "PeTI" msgstr "编辑器预览图" -#: app/CompilerPane.py:547 +#: app/CompilerPane.py:555 msgid "Custom:" msgstr "自定义预览图" -#: app/CompilerPane.py:562 +#: app/CompilerPane.py:570 msgid "Cleanup old screenshots" msgstr "清理过时的旧截图" -#: app/CompilerPane.py:572 +#: app/CompilerPane.py:578 msgid "" "Override the map image to use a screenshot automatically taken from the " "beginning of a chamber. Press F5 to take a new screenshot. If the map has" @@ -197,11 +197,11 @@ msgstr "" "使用实验室开始的自动截图作为地图的介绍图,或者使用F5拍摄的新截图。\n" "如果最近几小时内未预览地图,则使用默认的编辑器预览图。" -#: app/CompilerPane.py:580 +#: app/CompilerPane.py:586 msgid "Use the normal editor view for the map preview image." msgstr "使用默认编辑器视图作为地图预览图像。" -#: app/CompilerPane.py:582 +#: app/CompilerPane.py:588 msgid "" "Use a custom image for the map preview image. Click the screenshot to " "select.\n" @@ -210,7 +210,7 @@ msgstr "" "将自定义图片用作地图预览图片,点击快照窗口选择图片。\n" "如果需要,图片将转换为JPEG格式。" -#: app/CompilerPane.py:599 +#: app/CompilerPane.py:603 msgid "" "Automatically delete unused Automatic screenshots. Disable if you want to" " keep things in \"portal2/screenshots\". " @@ -218,19 +218,25 @@ msgstr "" "自动删除未使用的自动截图,如果你想保留这些截图请取消勾选。\n" "这些截图将保留在此路径“portal2/screenshots”" -#: app/CompilerPane.py:610 +#: app/CompilerPane.py:615 msgid "Lighting:" msgstr "光影渲染模式" -#: app/CompilerPane.py:617 +#: app/CompilerPane.py:622 msgid "Fast" msgstr "最快速度" -#: app/CompilerPane.py:624 +#: app/CompilerPane.py:629 msgid "Full" msgstr "最佳画质" -#: app/CompilerPane.py:632 +#: app/CompilerPane.py:635 +msgid "" +"You can hold down Shift during the start of the Lighting stage to invert " +"this configuration on the fly." +msgstr "" + +#: app/CompilerPane.py:639 msgid "" "Compile with lower-quality, fast lighting. This speeds up compile times, " "but does not appear as good. Some shadows may appear wrong.\n" @@ -239,7 +245,7 @@ msgstr "" "使用低质量的光照以加快渲染,但将导致画面变得糟糕,部分阴影可能会导致失真。\n" "发布地图时,这个选项将被忽略。" -#: app/CompilerPane.py:639 +#: app/CompilerPane.py:644 #, fuzzy msgid "" "Compile with high-quality lighting. This looks correct, but takes longer " @@ -250,11 +256,11 @@ msgstr "" "如果你希望布置酷炫的光线效果,请使用此选项。\n" "发布地图时,将始终使用此选项。" -#: app/CompilerPane.py:646 +#: app/CompilerPane.py:652 msgid "Dump packed files to:" msgstr "转储打包后的文件" -#: app/CompilerPane.py:671 +#: app/CompilerPane.py:675 msgid "" "When compiling, dump all files which were packed into the map. Useful if " "you're intending to edit maps in Hammer." @@ -262,37 +268,37 @@ msgstr "" "编译时,转储地图会用到的所有数据包文件,用于Hammer编辑地图。\n" "(必须使用全英文路径,并建立新文件夹,转储数据时会清空同名文件夹内原有的内容)" -#: app/CompilerPane.py:677 +#: app/CompilerPane.py:682 msgid "Last Compile:" msgstr "编译数据统计" -#: app/CompilerPane.py:687 +#: app/CompilerPane.py:692 msgid "Entity" msgstr "实体" -#: app/CompilerPane.py:707 +#: app/CompilerPane.py:712 msgid "Overlay" msgstr "标记" -#: app/CompilerPane.py:726 +#: app/CompilerPane.py:729 msgid "" "Refresh the compile progress bars. Press after a compile has been " "performed to show the new values." msgstr "刷新数据统计。 执行编译后点击,以更新数据。" -#: app/CompilerPane.py:732 +#: app/CompilerPane.py:736 msgid "Brush" msgstr "画刷" -#: app/CompilerPane.py:760 +#: app/CompilerPane.py:764 msgid "Voicelines:" msgstr "声线选择" -#: app/CompilerPane.py:767 +#: app/CompilerPane.py:771 msgid "Use voiceline priorities" msgstr "使用高优先级声线" -#: app/CompilerPane.py:773 +#: app/CompilerPane.py:775 msgid "" "Only choose the highest-priority voicelines. This means more generic " "lines will can only be chosen if few test elements are in the map. If " @@ -301,19 +307,25 @@ msgstr "" "仅选择优先级最高的声线,这意味着当地图中测试元素很少时,仅能选择通用的声线。\n" "如果禁用,将可使用任何可选的声线。" -#: app/CompilerPane.py:780 +#: app/CompilerPane.py:783 msgid "Spawn at:" msgstr "出生位置" -#: app/CompilerPane.py:790 +#: app/CompilerPane.py:793 msgid "Entry Door" msgstr "入口" -#: app/CompilerPane.py:796 +#: app/CompilerPane.py:799 msgid "Elevator" msgstr "电梯" -#: app/CompilerPane.py:806 +#: app/CompilerPane.py:807 +msgid "" +"You can hold down Shift during the start of the Geometry stage to quickly" +" swap whichlocation you spawn at on the fly." +msgstr "" + +#: app/CompilerPane.py:811 #, fuzzy msgid "" "When previewing in SP, spawn inside the entry elevator. Use this to " @@ -323,68 +335,68 @@ msgstr "" "这会禁用到达出口后地图重新开始。\n" "使用此选项来检查入口走廊和出口走廊是否令人满意。" -#: app/CompilerPane.py:811 +#: app/CompilerPane.py:815 #, fuzzy msgid "When previewing in SP, spawn just before the entry door." msgstr "" "单人模式预览地图时,在入口前出生(跳过入口走廊)。\n" "当你到达出口时,地图将重新开始。(以便快速检查地图的谜题)" -#: app/CompilerPane.py:817 +#: app/CompilerPane.py:822 msgid "Corridor:" msgstr "走廊样式" -#: app/CompilerPane.py:823 +#: app/CompilerPane.py:828 msgid "Singleplayer Entry Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:824 +#: app/CompilerPane.py:829 msgid "Singleplayer Exit Corridor" msgstr "" #. i18n: corridor selector window title. -#: app/CompilerPane.py:825 +#: app/CompilerPane.py:830 msgid "Coop Exit Corridor" msgstr "" -#: app/CompilerPane.py:835 +#: app/CompilerPane.py:840 msgid "SP Entry:" msgstr "单人模式入口" -#: app/CompilerPane.py:840 +#: app/CompilerPane.py:845 msgid "SP Exit:" msgstr "单人模式出口" -#: app/CompilerPane.py:845 +#: app/CompilerPane.py:850 msgid "Coop Exit:" msgstr "" -#: app/CompilerPane.py:851 +#: app/CompilerPane.py:856 msgid "Player Model (SP):" msgstr "玩家模型(单人模式)" -#: app/CompilerPane.py:882 +#: app/CompilerPane.py:887 msgid "Compile Options" msgstr "编译选项" -#: app/CompilerPane.py:900 +#: app/CompilerPane.py:905 msgid "Compiler Options - {}" msgstr "开发者选项 - {}" -#: app/StyleVarPane.py:28 +#: app/StyleVarPane.py:27 msgid "Multiverse Cave" msgstr "启用Cave谈论平行宇宙的语音" -#: app/StyleVarPane.py:30 +#: app/StyleVarPane.py:29 msgid "Play the Workshop Cave Johnson lines on map start." msgstr "进入地图后播放Cave谈论平行宇宙的语音" -#: app/StyleVarPane.py:35 +#: app/StyleVarPane.py:34 msgid "Prevent Portal Bump (fizzler)" msgstr "阻止传送门跃过消散力场" -#: app/StyleVarPane.py:37 +#: app/StyleVarPane.py:36 msgid "" "Add portal bumpers to make it more difficult to portal across fizzler " "edges. This can prevent placing portals in tight spaces near fizzlers, or" @@ -394,19 +406,19 @@ msgstr "" "这可以防止将传送门放置在消散立场与墙壁之间的夹缝中,\n" "同时也阻止通过挤门的方式把传送门开启到消散立场的另一边。" -#: app/StyleVarPane.py:44 +#: app/StyleVarPane.py:45 msgid "Suppress Mid-Chamber Dialogue" msgstr "禁用实验室内的AI语音" -#: app/StyleVarPane.py:46 +#: app/StyleVarPane.py:47 msgid "Disable all voicelines other than entry and exit lines." msgstr "禁用在入口通道和出口通道以外的地区播放电脑语音。" -#: app/StyleVarPane.py:51 +#: app/StyleVarPane.py:52 msgid "Unlock Default Items" msgstr "允许编辑默认物品" -#: app/StyleVarPane.py:53 +#: app/StyleVarPane.py:54 msgid "" "Allow placing and deleting the mandatory Entry/Exit Doors and Large " "Observation Room. Use with caution, this can have weird results!" @@ -414,11 +426,11 @@ msgstr "" "允许放置和删除强制性的进/出门和大型观察室。\n" "谨慎使用,可能会产生奇怪的结果!" -#: app/StyleVarPane.py:60 +#: app/StyleVarPane.py:63 msgid "Allow Adding Goo Mist" msgstr "允许为致命酸液添加雾气效果" -#: app/StyleVarPane.py:62 +#: app/StyleVarPane.py:65 msgid "" "Add mist particles above Toxic Goo in certain styles. This can increase " "the entity count significantly with large, complex goo pits, so disable " @@ -427,11 +439,11 @@ msgstr "" "在某些风格中的致命酸液上添加带有雾气的粒子效果。\n" "但这会增加实体的数量,如果有复杂的酸液池,可以考虑禁用此特效以提升性能。" -#: app/StyleVarPane.py:69 +#: app/StyleVarPane.py:74 msgid "Light Reversible Excursion Funnels" msgstr "允许牵引光束转换极性" -#: app/StyleVarPane.py:71 +#: app/StyleVarPane.py:76 msgid "" "Funnels emit a small amount of light. However, if multiple funnels are " "near each other and can reverse polarity, this can cause lighting issues." @@ -441,11 +453,11 @@ msgstr "" "牵引光束会发出少量的光。然而,多个牵引光束彼此靠近且可以反转极性时,则可能导致照明出现问题。\n" "禁用此功能可以通过关闭发光来避免这个问题,不可逆的牵引光束没有此问题。" -#: app/StyleVarPane.py:79 +#: app/StyleVarPane.py:86 msgid "Enable Shape Framing" msgstr "为蚂蚁标志添加边框" -#: app/StyleVarPane.py:81 +#: app/StyleVarPane.py:88 msgid "" "After 10 shape-type antlines are used, the signs repeat. With this " "enabled, colored frames will be added to distinguish them." @@ -453,91 +465,94 @@ msgstr "" "当蚂蚁线标志超过10个种类时会出现符号重复。\n" "启用此功能可以通过添加彩色边框的方式区分它们。" -#: app/StyleVarPane.py:158 +#. i18n: StyleVar default value. +#: app/StyleVarPane.py:161 msgid "Default: On" msgstr "默认:开启" -#: app/StyleVarPane.py:160 +#: app/StyleVarPane.py:161 msgid "Default: Off" msgstr "默认:关闭" -#: app/StyleVarPane.py:164 +#. i18n: StyleVar which is totally unstyled. +#: app/StyleVarPane.py:165 msgid "Styles: Unstyled" msgstr "风格:无" -#: app/StyleVarPane.py:174 +#. i18n: StyleVar which matches all styles. +#: app/StyleVarPane.py:176 msgid "Styles: All" msgstr "风格:所有" -#: app/StyleVarPane.py:182 +#: app/StyleVarPane.py:185 msgid "Style: {}" msgid_plural "Styles: {}" msgstr[0] "风格:{}" -#: app/StyleVarPane.py:235 +#: app/StyleVarPane.py:238 msgid "Style/Item Properties" msgstr "风格/物品 属性" -#: app/StyleVarPane.py:254 +#: app/StyleVarPane.py:257 msgid "Styles" msgstr "风格属性" -#: app/StyleVarPane.py:273 +#: app/StyleVarPane.py:276 msgid "All:" msgstr "所有风格:" -#: app/StyleVarPane.py:276 +#: app/StyleVarPane.py:279 msgid "Selected Style:" msgstr "所选风格:" -#: app/StyleVarPane.py:284 +#: app/StyleVarPane.py:287 msgid "Other Styles:" msgstr "其它风格:" -#: app/StyleVarPane.py:289 +#: app/StyleVarPane.py:292 msgid "No Options!" msgstr "没有选项!" -#: app/StyleVarPane.py:295 +#: app/StyleVarPane.py:298 msgid "None!" msgstr "无!" -#: app/StyleVarPane.py:364 +#: app/StyleVarPane.py:361 msgid "Items" msgstr "物品属性" -#: app/SubPane.py:86 +#: app/SubPane.py:87 msgid "Hide/Show the \"{}\" window." msgstr "隐藏/显示 \"{}\" 窗口。" -#: app/UI.py:77 +#: app/UI.py:85 msgid "Export..." msgstr "导出到..." -#: app/UI.py:576 +#: app/UI.py:589 msgid "Select Skyboxes" msgstr "选择天空布景" -#: app/UI.py:577 +#: app/UI.py:590 msgid "" "The skybox decides what the area outside the chamber is like. It chooses " "the colour of sky (seen in some items), the style of bottomless pit (if " "present), as well as color of \"fog\" (seen in larger chambers)." msgstr "天空布景决定了实验室外部空间的样子。它选择天空的颜色(在某些项目中可见),底板悬空的风格(如果存在)以及“雾”的颜色(在较大的实验室可见)。" -#: app/UI.py:585 +#: app/UI.py:599 msgid "3D Skybox" msgstr "3D天空布景" -#: app/UI.py:586 +#: app/UI.py:600 msgid "Fog Color" msgstr "迷雾颜色" -#: app/UI.py:593 +#: app/UI.py:608 msgid "Select Additional Voice Lines" msgstr "选择其它语音声线" -#: app/UI.py:594 +#: app/UI.py:609 msgid "" "Voice lines choose which extra voices play as the player enters or exits " "a chamber. They are chosen based on which items are present in the map. " @@ -548,31 +563,31 @@ msgstr "" "它们会根据地图中存在哪些物品来选择播放哪一段语音。\n" "附加的“Cave谈论平行宇宙”的语音在“风格属性”中单独控制。" -#: app/UI.py:599 +#: app/UI.py:615 msgid "Add no extra voice lines, only Multiverse Cave if enabled." msgstr "不添加额外的声线,仅使用Cave关于多元宇宙的谈话。(如果它被勾选)" -#: app/UI.py:601 +#: app/UI.py:617 msgid "" msgstr "<仅使用平行宇宙的凯文>" -#: app/UI.py:605 +#: app/UI.py:621 msgid "Characters" msgstr "角色" -#: app/UI.py:606 +#: app/UI.py:622 msgid "Turret Shoot Monitor" msgstr "炮塔会射击显示器" -#: app/UI.py:607 +#: app/UI.py:623 msgid "Monitor Visuals" msgstr "显示器视觉效果" -#: app/UI.py:614 +#: app/UI.py:631 msgid "Select Style" msgstr "选择风格" -#: app/UI.py:615 +#: app/UI.py:632 msgid "" "The Style controls many aspects of the map. It decides the materials used" " for walls, the appearance of entrances and exits, the design for most " @@ -581,15 +596,15 @@ msgid "" "The style broadly defines the time period a chamber is set in." msgstr "地图的风格决定了墙壁的材质,出入口的外观,大多数物品的外观以及其它设置。" -#: app/UI.py:626 +#: app/UI.py:644 msgid "Elevator Videos" msgstr "电梯间墙壁上播放的影像" -#: app/UI.py:633 +#: app/UI.py:652 msgid "Select Elevator Video" msgstr "选择电梯间播放的影响" -#: app/UI.py:634 +#: app/UI.py:653 msgid "" "Set the video played on the video screens in modern Aperture elevator " "rooms. Not all styles feature these. If set to \"None\", a random video " @@ -598,23 +613,23 @@ msgstr "" "设置现代光圈科技电梯间墙壁上播放的影像,并非所有风格都有这个影像。\n" "如果设置为无,将在每次载入地图时随机选择影像,就像默认编辑器里的一样。" -#: app/UI.py:638 +#: app/UI.py:658 msgid "This style does not have a elevator video screen." msgstr "这种风格的电梯间没有影像屏幕。" -#: app/UI.py:643 +#: app/UI.py:663 msgid "Choose a random video." msgstr "随机选择一个影像。" -#: app/UI.py:647 +#: app/UI.py:667 msgid "Multiple Orientations" msgstr "多种方向" -#: app/UI.py:879 +#: app/UI.py:827 msgid "Selected Items and Style successfully exported!" msgstr "所选物品和风格已成功导出!" -#: app/UI.py:881 +#: app/UI.py:829 msgid "" "\n" "\n" @@ -625,187 +640,163 @@ msgstr "" "\n" "警告:VPK文件未导出,退出 Portal 2 和 Hammer 以应用地图编辑器的物品栏更改。" -#: app/UI.py:1113 -msgid "Delete Palette \"{}\"" -msgstr "删除模板 \"{}\"" - -#: app/UI.py:1201 -msgid "BEE2 - Save Palette" -msgstr "BEE2 - 保存模板" - -#: app/UI.py:1202 -msgid "Enter a name:" -msgstr "键入模板名称:" - -#: app/UI.py:1211 -msgid "This palette already exists. Overwrite?" -msgstr "已经存在同名模板,是否覆盖?" - -#: app/UI.py:1247 app/gameMan.py:1352 -msgid "Are you sure you want to delete \"{}\"?" -msgstr "你确定要删除 \"{}\" 吗?" - -#: app/UI.py:1275 -msgid "Clear Palette" -msgstr "清空模板内容" - -#: app/UI.py:1311 app/UI.py:1729 -msgid "Delete Palette" -msgstr "删除选中模板" - -#: app/UI.py:1331 +#: app/UI.py:1124 msgid "Save Palette..." msgstr "保存模板..." -#: app/UI.py:1337 app/UI.py:1754 +#: app/UI.py:1131 app/paletteUI.py:147 msgid "Save Palette As..." msgstr "另存为模板..." -#: app/UI.py:1348 app/UI.py:1741 +#: app/UI.py:1137 app/paletteUI.py:134 msgid "Save Settings in Palettes" msgstr "在模板中保存配置" -#: app/UI.py:1366 app/music_conf.py:204 +#: app/UI.py:1155 app/music_conf.py:222 msgid "Music: " msgstr "音乐选择:" -#: app/UI.py:1392 +#: app/UI.py:1181 msgid "{arr} Use Suggested {arr}" msgstr "{arr} 使用推荐配置 {arr}" -#: app/UI.py:1408 +#: app/UI.py:1197 msgid "Style: " msgstr "物品风格" -#: app/UI.py:1410 +#: app/UI.py:1199 msgid "Voice: " msgstr "旁白语音" -#: app/UI.py:1411 +#: app/UI.py:1200 msgid "Skybox: " msgstr "天空布景" -#: app/UI.py:1412 +#: app/UI.py:1201 msgid "Elev Vid: " msgstr "电梯视频" -#: app/UI.py:1430 +#: app/UI.py:1219 msgid "" "Enable or disable particular voice lines, to prevent them from being " "added." msgstr "启用或禁用特定的台词,以防止添加它们。" -#: app/UI.py:1518 +#: app/UI.py:1307 msgid "All Items: " msgstr "所有物品:" -#: app/UI.py:1648 +#: app/UI.py:1438 msgid "Export to \"{}\"..." msgstr "导出到 \"{}\"..." -#: app/UI.py:1676 app/backup.py:874 +#: app/UI.py:1466 app/backup.py:873 msgid "File" msgstr "文件" -#: app/UI.py:1683 +#: app/UI.py:1473 msgid "Export" msgstr "导出" -#: app/UI.py:1690 app/backup.py:878 +#: app/UI.py:1480 app/backup.py:877 msgid "Add Game" msgstr "添加游戏" -#: app/UI.py:1694 +#: app/UI.py:1484 msgid "Uninstall from Selected Game" msgstr "删除选中的游戏" -#: app/UI.py:1698 +#: app/UI.py:1488 msgid "Backup/Restore Puzzles..." msgstr "备份/恢复 谜题..." -#: app/UI.py:1702 +#: app/UI.py:1492 msgid "Manage Packages..." msgstr "管理资源包..." -#: app/UI.py:1707 +#: app/UI.py:1497 msgid "Options" msgstr "设置" -#: app/UI.py:1712 app/gameMan.py:1100 +#: app/UI.py:1502 app/gameMan.py:1130 msgid "Quit" msgstr "退出" -#: app/UI.py:1722 +#: app/UI.py:1512 msgid "Palette" msgstr "模板" -#: app/UI.py:1734 -msgid "Fill Palette" -msgstr "重置模板" - -#: app/UI.py:1748 -msgid "Save Palette" -msgstr "保存模板" - -#: app/UI.py:1764 +#: app/UI.py:1515 msgid "View" msgstr "" -#: app/UI.py:1878 +#: app/UI.py:1628 msgid "Palettes" msgstr "模板列表" -#: app/UI.py:1903 +#: app/UI.py:1665 msgid "Export Options" msgstr "导出设置" -#: app/UI.py:1935 +#: app/UI.py:1697 msgid "Fill empty spots in the palette with random items." msgstr "随机选择物品填充物品栏中的空位。" -#: app/backup.py:79 +#: app/__init__.py:93 +msgid "BEEMOD {} Error!" +msgstr "" + +#: app/__init__.py:94 +msgid "" +"An error occurred: \n" +"{}\n" +"\n" +"This has been copied to the clipboard." +msgstr "" + +#: app/backup.py:78 msgid "Copying maps" msgstr "正在复制地图..." -#: app/backup.py:84 +#: app/backup.py:83 msgid "Loading maps" msgstr "正在载入地图..." -#: app/backup.py:89 +#: app/backup.py:88 msgid "Deleting maps" msgstr "正在删除地图..." -#: app/backup.py:140 +#: app/backup.py:139 msgid "Failed to parse this puzzle file. It can still be backed up." msgstr "无法解析此谜题文件,但它仍然可以备份。" -#: app/backup.py:144 +#: app/backup.py:143 msgid "No description found." msgstr "找不到描述。" -#: app/backup.py:175 +#: app/backup.py:174 msgid "Coop" msgstr "合作模式" -#: app/backup.py:175 +#: app/backup.py:174 msgid "SP" msgstr "单人模式" -#: app/backup.py:337 +#: app/backup.py:336 msgid "This filename is already in the backup.Do you wish to overwrite it? ({})" msgstr "" "已存在同名的备份文件,是否要覆盖它?\n" "({})" -#: app/backup.py:443 +#: app/backup.py:442 msgid "BEE2 Backup" msgstr "BEE2 备份" -#: app/backup.py:444 +#: app/backup.py:443 msgid "No maps were chosen to backup!" msgstr "请选择要备份的地图!" -#: app/backup.py:504 +#: app/backup.py:503 msgid "" "This map is already in the game directory.Do you wish to overwrite it? " "({})" @@ -813,189 +804,189 @@ msgstr "" "游戏目录中已存在同名的地图文件,是否要覆盖它?\n" "({})" -#: app/backup.py:566 +#: app/backup.py:565 msgid "Load Backup" msgstr "载入备份" -#: app/backup.py:567 app/backup.py:626 +#: app/backup.py:566 app/backup.py:625 msgid "Backup zip" msgstr "备份为zip文件" -#: app/backup.py:600 +#: app/backup.py:599 msgid "Unsaved Backup" msgstr "未保持的备份" -#: app/backup.py:625 app/backup.py:872 +#: app/backup.py:624 app/backup.py:871 msgid "Save Backup As" msgstr "将备份另存为" -#: app/backup.py:722 +#: app/backup.py:721 msgid "Confirm Deletion" msgstr "确认删除" -#: app/backup.py:723 +#: app/backup.py:722 msgid "Do you wish to delete {} map?\n" msgid_plural "Do you wish to delete {} maps?\n" msgstr[0] "确定要删除 {} 张地图?\n" -#: app/backup.py:760 +#: app/backup.py:759 msgid "Restore:" msgstr "还原:" -#: app/backup.py:761 +#: app/backup.py:760 msgid "Backup:" msgstr "备份:" -#: app/backup.py:798 +#: app/backup.py:797 msgid "Checked" msgstr "已选" -#: app/backup.py:806 +#: app/backup.py:805 msgid "Delete Checked" msgstr "删除已选" -#: app/backup.py:856 +#: app/backup.py:855 msgid "BEEMOD {} - Backup / Restore Puzzles" msgstr "BEEMOD {} - 备份/恢复 谜题" -#: app/backup.py:869 app/backup.py:997 +#: app/backup.py:868 app/backup.py:996 msgid "New Backup" msgstr "新建备份" -#: app/backup.py:870 app/backup.py:1004 +#: app/backup.py:869 app/backup.py:1003 msgid "Open Backup" msgstr "打开备份" -#: app/backup.py:871 app/backup.py:1011 +#: app/backup.py:870 app/backup.py:1010 msgid "Save Backup" msgstr "保存备份" -#: app/backup.py:879 +#: app/backup.py:878 msgid "Remove Game" msgstr "删除游戏" -#: app/backup.py:882 +#: app/backup.py:881 msgid "Game" msgstr "游戏" -#: app/backup.py:928 +#: app/backup.py:927 msgid "Automatic Backup After Export" msgstr "导出后自动备份" -#: app/backup.py:960 +#: app/backup.py:959 msgid "Keep (Per Game):" msgstr "保持(每N场游戏)" -#: app/backup.py:978 +#: app/backup.py:977 msgid "Backup/Restore Puzzles" msgstr "备份/恢复 谜题" -#: app/contextWin.py:84 +#: app/contextWin.py:82 msgid "This item may not be rotated." msgstr "该物品可能无法旋转。" -#: app/contextWin.py:85 +#: app/contextWin.py:83 msgid "This item can be pointed in 4 directions." msgstr "该物品可以面向4个方向。" -#: app/contextWin.py:86 +#: app/contextWin.py:84 msgid "This item can be positioned on the sides and center." msgstr "该物品可以放在侧面和中间。" -#: app/contextWin.py:87 +#: app/contextWin.py:85 msgid "This item can be centered in two directions, plus on the sides." msgstr "该物品可以向三个方向延伸。" -#: app/contextWin.py:88 +#: app/contextWin.py:86 msgid "This item can be placed like light strips." msgstr "该物品可以像灯条一样放置。" -#: app/contextWin.py:89 +#: app/contextWin.py:87 msgid "This item can be rotated on the floor to face 360 degrees." msgstr "该物品可以在地面上360°旋转。" -#: app/contextWin.py:90 +#: app/contextWin.py:88 msgid "This item is positioned using a catapult trajectory." msgstr "该物品用于定位弹射板的弹道及落点。" -#: app/contextWin.py:91 +#: app/contextWin.py:89 msgid "This item positions the dropper to hit target locations." msgstr "该物品定位物品掉落器击中目标的位置。" -#: app/contextWin.py:93 +#: app/contextWin.py:91 msgid "This item does not accept any inputs." msgstr "该物品不会接收任何输入信号。" -#: app/contextWin.py:94 +#: app/contextWin.py:92 msgid "This item accepts inputs." msgstr "该物品可以接收信号输入以改变状态。" -#: app/contextWin.py:95 +#: app/contextWin.py:93 msgid "This item has two input types (A and B), using the Input A and B items." msgstr "该物品具有两种输入类型(A和B),使用A物品和B物品进行输入。" -#: app/contextWin.py:97 +#: app/contextWin.py:95 msgid "This item does not output." msgstr "该物品不会输出信号。" -#: app/contextWin.py:98 +#: app/contextWin.py:96 msgid "This item has an output." msgstr "该物品可以输出信号。" -#: app/contextWin.py:99 +#: app/contextWin.py:97 msgid "This item has a timed output." msgstr "该物品具有延时输出信号的功能。" -#: app/contextWin.py:101 +#: app/contextWin.py:99 msgid "This item does not take up any space inside walls." msgstr "该物品不会占据墙壁后面的空间。" -#: app/contextWin.py:102 +#: app/contextWin.py:100 msgid "This item takes space inside the wall." msgstr "该物品需要占据墙壁后面的空间。" -#: app/contextWin.py:104 +#: app/contextWin.py:102 msgid "This item cannot be placed anywhere..." msgstr "该物品不能手动放置" -#: app/contextWin.py:105 +#: app/contextWin.py:103 msgid "This item can only be attached to ceilings." msgstr "该物品只能放置在天花板上。" -#: app/contextWin.py:106 +#: app/contextWin.py:104 msgid "This item can only be placed on the floor." msgstr "该物品只能放置在地板上。" -#: app/contextWin.py:107 +#: app/contextWin.py:105 msgid "This item can be placed on floors and ceilings." msgstr "该物品可以放置在地板和天花板上。" -#: app/contextWin.py:108 +#: app/contextWin.py:106 msgid "This item can be placed on walls only." msgstr "该物品只能放置在墙上。" -#: app/contextWin.py:109 +#: app/contextWin.py:107 msgid "This item can be attached to walls and ceilings." msgstr "该物品可以放置在墙壁和天花板上。" -#: app/contextWin.py:110 +#: app/contextWin.py:108 msgid "This item can be placed on floors and walls." msgstr "该物品可以放置在地板和墙壁上。" -#: app/contextWin.py:111 +#: app/contextWin.py:109 msgid "This item can be placed in any orientation." msgstr "该物品可以放置在任何方向。" -#: app/contextWin.py:226 +#: app/contextWin.py:227 #, fuzzy msgid "No Alternate Versions" msgstr "无替代版本!" -#: app/contextWin.py:320 +#: app/contextWin.py:321 msgid "Excursion Funnels accept a on/off input and a directional input." msgstr "牵引光束接受“开/关”控制和“方向”转换控制。" -#: app/contextWin.py:371 +#: app/contextWin.py:372 msgid "" "This item can be rotated on the floor to face 360 degrees, for Reflection" " Cubes only." @@ -1012,44 +1003,44 @@ msgid "" " placed in a map at once." msgstr "用于此物品的实体数量。起源引擎将其限制为2048。这里提供了一篇可以一次性放置多少物品在地图里的指南。" -#: app/contextWin.py:489 +#: app/contextWin.py:491 msgid "Description:" msgstr "描述:" -#: app/contextWin.py:529 +#: app/contextWin.py:532 msgid "" "Failed to open a web browser. Do you wish for the URL to be copied to the" " clipboard instead?" msgstr "无法打开浏览器。你希望将网址复制到剪贴板吗?" -#: app/contextWin.py:543 +#: app/contextWin.py:547 msgid "More Info>>" msgstr "更多信息>>" -#: app/contextWin.py:560 +#: app/contextWin.py:564 msgid "Change Defaults..." msgstr "更改默认值..." -#: app/contextWin.py:566 +#: app/contextWin.py:570 msgid "Change the default settings for this item when placed." msgstr "修改该物品在放置时的默认设置。" -#: app/gameMan.py:766 app/gameMan.py:858 +#: app/gameMan.py:788 app/gameMan.py:880 msgid "BEE2 - Export Failed!" msgstr "BEE2 - 导出失败!" -#: app/gameMan.py:767 +#: app/gameMan.py:789 msgid "" "Compiler file {file} missing. Exit Steam applications, then press OK to " "verify your game cache. You can then export again." msgstr "编译器文件 {file} 丢失。退出Steam程序,然后点击OK以校验你的游戏缓存。然后再次尝试导出。" -#: app/gameMan.py:859 +#: app/gameMan.py:881 #, fuzzy msgid "Copying compiler file {file} failed. Ensure {game} is not running." msgstr "复制编译器文件 {file} 失败。确保 {game} 未运行" -#: app/gameMan.py:1157 +#: app/gameMan.py:1187 msgid "" "Ap-Tag Coop gun instance not found!\n" "Coop guns will not work - verify cache to fix." @@ -1057,48 +1048,48 @@ msgstr "" "找不到光圈科技附加传送枪的实例!\n" "传送器将不起作用 - 校验缓存以修复此问题。" -#: app/gameMan.py:1161 +#: app/gameMan.py:1191 msgid "BEE2 - Aperture Tag Files Missing" msgstr "BEE2 - 光圈科技附加文件丢失" -#: app/gameMan.py:1275 +#: app/gameMan.py:1304 msgid "Select the folder where the game executable is located ({appname})..." msgstr "选择游戏可执行文件的路径 {appname}" -#: app/gameMan.py:1278 app/gameMan.py:1293 app/gameMan.py:1303 -#: app/gameMan.py:1310 app/gameMan.py:1319 app/gameMan.py:1328 +#: app/gameMan.py:1308 app/gameMan.py:1323 app/gameMan.py:1333 +#: app/gameMan.py:1340 app/gameMan.py:1349 app/gameMan.py:1358 msgid "BEE2 - Add Game" msgstr "BEE2 - 添加游戏" -#: app/gameMan.py:1281 +#: app/gameMan.py:1311 msgid "Find Game Exe" msgstr "查找游戏的exe文件" -#: app/gameMan.py:1282 +#: app/gameMan.py:1312 msgid "Executable" msgstr "可执行文件" -#: app/gameMan.py:1290 +#: app/gameMan.py:1320 msgid "This does not appear to be a valid game folder!" msgstr "这似乎不是一个有效的游戏文件夹!" -#: app/gameMan.py:1300 +#: app/gameMan.py:1330 msgid "Portal Stories: Mel doesn't have an editor!" msgstr "没有《传送门:梅尔的故事》的编辑器!" -#: app/gameMan.py:1311 +#: app/gameMan.py:1341 msgid "Enter the name of this game:" msgstr "输入此游戏的名称:" -#: app/gameMan.py:1318 +#: app/gameMan.py:1348 msgid "This name is already taken!" msgstr "该名称已经存在!" -#: app/gameMan.py:1327 +#: app/gameMan.py:1357 msgid "Please enter a name for this game!" msgstr "请为此游戏创建一个名称!" -#: app/gameMan.py:1346 +#: app/gameMan.py:1375 msgid "" "\n" " (BEE2 will quit, this is the last game set!)" @@ -1106,82 +1097,86 @@ msgstr "" "\n" "(BEE2即将退出,这是最后一次修改游戏的配置!)" -#: app/helpMenu.py:57 +#: app/gameMan.py:1381 app/paletteUI.py:272 +msgid "Are you sure you want to delete \"{}\"?" +msgstr "你确定要删除 \"{}\" 吗?" + +#: app/helpMenu.py:60 msgid "Wiki..." msgstr "维基百科..." -#: app/helpMenu.py:59 +#: app/helpMenu.py:62 msgid "Original Items..." msgstr "默认物品..." #. i18n: The chat program. -#: app/helpMenu.py:64 +#: app/helpMenu.py:67 msgid "Discord Server..." msgstr "Discord服务器..." -#: app/helpMenu.py:65 +#: app/helpMenu.py:68 msgid "aerond's Music Changer..." msgstr "BEEMod音乐转换器..." -#: app/helpMenu.py:67 +#: app/helpMenu.py:70 msgid "Application Repository..." msgstr "软件开源代码库..." -#: app/helpMenu.py:68 +#: app/helpMenu.py:71 msgid "Items Repository..." msgstr "物品开源代码库..." -#: app/helpMenu.py:70 +#: app/helpMenu.py:73 msgid "Submit Application Bugs..." msgstr "反馈程序Bug..." -#: app/helpMenu.py:71 +#: app/helpMenu.py:74 msgid "Submit Item Bugs..." msgstr "反馈物品Bug..." #. i18n: Original Palette -#: app/helpMenu.py:73 app/paletteLoader.py:35 +#: app/helpMenu.py:76 app/paletteLoader.py:36 msgid "Portal 2" msgstr "传送门2" #. i18n: Aperture Tag's palette -#: app/helpMenu.py:74 app/paletteLoader.py:37 +#: app/helpMenu.py:77 app/paletteLoader.py:38 msgid "Aperture Tag" msgstr "Aperture Tag" -#: app/helpMenu.py:75 +#: app/helpMenu.py:78 msgid "Portal Stories: Mel" msgstr "传送门:梅尔的故事" -#: app/helpMenu.py:76 +#: app/helpMenu.py:79 msgid "Thinking With Time Machine" msgstr "传送门:时间机器" -#: app/helpMenu.py:298 app/itemPropWin.py:343 +#: app/helpMenu.py:474 app/itemPropWin.py:355 msgid "Close" msgstr "关闭" -#: app/helpMenu.py:322 +#: app/helpMenu.py:498 msgid "Help" msgstr "帮助" -#: app/helpMenu.py:332 +#: app/helpMenu.py:508 msgid "BEE2 Credits" msgstr "BEE2 声明" -#: app/helpMenu.py:349 +#: app/helpMenu.py:525 msgid "Credits..." msgstr "声明..." -#: app/itemPropWin.py:39 +#: app/itemPropWin.py:41 msgid "Start Position" msgstr "开始位置" -#: app/itemPropWin.py:40 +#: app/itemPropWin.py:42 msgid "End Position" msgstr "结束位置" -#: app/itemPropWin.py:41 +#: app/itemPropWin.py:43 msgid "" "Delay \n" "(0=infinite)" @@ -1189,23 +1184,31 @@ msgstr "" "延时\n" "(0=无限)" -#: app/itemPropWin.py:342 +#: app/itemPropWin.py:354 msgid "No Properties available!" msgstr "没有可用的属性!" +#: app/itemPropWin.py:604 +msgid "Settings for \"{}\"" +msgstr "" + +#: app/itemPropWin.py:605 +msgid "BEE2 - {}" +msgstr "" + #: app/item_search.py:67 msgid "Search:" msgstr "" -#: app/itemconfig.py:612 +#: app/itemconfig.py:622 msgid "Choose a Color" msgstr "选择一种颜色" -#: app/music_conf.py:132 +#: app/music_conf.py:147 msgid "Select Background Music - Base" msgstr "选择背景音乐 - 基础" -#: app/music_conf.py:133 +#: app/music_conf.py:148 msgid "" "This controls the background music used for a map. Expand the dropdown to" " set tracks for specific test elements." @@ -1213,189 +1216,189 @@ msgstr "" "这控制用于地图的背景音乐。\n" "展开下拉菜单以设置特定试验元素的声音。" -#: app/music_conf.py:137 +#: app/music_conf.py:152 msgid "" "Add no music to the map at all. Testing Element-specific music may still " "be added." msgstr "不添在地图内加任何背景音乐。某些特定试验元素的声音仍然可能被添加。" -#: app/music_conf.py:142 +#: app/music_conf.py:157 msgid "Propulsion Gel SFX" msgstr "加速凝胶的音效" -#: app/music_conf.py:143 +#: app/music_conf.py:158 msgid "Repulsion Gel SFX" msgstr "斥力凝胶的音效" -#: app/music_conf.py:144 +#: app/music_conf.py:159 msgid "Excursion Funnel Music" msgstr "牵引光束的音效" -#: app/music_conf.py:145 app/music_conf.py:160 +#: app/music_conf.py:160 app/music_conf.py:176 msgid "Synced Funnel Music" msgstr "同步牵引光束的音效" -#: app/music_conf.py:152 +#: app/music_conf.py:168 msgid "Select Excursion Funnel Music" msgstr "选择牵引光束的音效" -#: app/music_conf.py:153 +#: app/music_conf.py:169 msgid "Set the music used while inside Excursion Funnels." msgstr "设置玩家处于牵引光束内部时的音效。" -#: app/music_conf.py:156 +#: app/music_conf.py:172 msgid "Have no music playing when inside funnels." msgstr "玩家处于牵引光束内部时不播放音效。" -#: app/music_conf.py:167 +#: app/music_conf.py:184 msgid "Select Repulsion Gel Music" msgstr "选择斥力凝胶的音效" -#: app/music_conf.py:168 +#: app/music_conf.py:185 msgid "Select the music played when players jump on Repulsion Gel." msgstr "选择当玩家跳上斥力凝胶时的音效。" -#: app/music_conf.py:171 +#: app/music_conf.py:188 msgid "Add no music when jumping on Repulsion Gel." msgstr "玩家跳上斥力凝胶时不播放音效。" -#: app/music_conf.py:179 +#: app/music_conf.py:197 msgid "Select Propulsion Gel Music" msgstr "选择加速凝胶的音效" -#: app/music_conf.py:180 +#: app/music_conf.py:198 msgid "" "Select music played when players have large amounts of horizontal " "velocity." msgstr "选择玩家在加速凝胶上奔跑时的音效。" -#: app/music_conf.py:183 +#: app/music_conf.py:201 msgid "Add no music while running fast." msgstr "玩家在加速凝胶上移动时不播放音效。" -#: app/music_conf.py:218 +#: app/music_conf.py:236 msgid "Base: " msgstr "基础音乐" -#: app/music_conf.py:251 +#: app/music_conf.py:269 msgid "Funnel:" msgstr "牵引光束" -#: app/music_conf.py:252 +#: app/music_conf.py:270 msgid "Bounce:" msgstr "弹性凝胶" -#: app/music_conf.py:253 +#: app/music_conf.py:271 msgid "Speed:" msgstr "加速凝胶" -#: app/optionWindow.py:46 +#: app/optionWindow.py:44 #, fuzzy msgid "" "\n" "Launch Game?" msgstr "启动游戏" -#: app/optionWindow.py:48 +#: app/optionWindow.py:46 #, fuzzy msgid "" "\n" "Minimise BEE2?" msgstr "最小化BEE2" -#: app/optionWindow.py:49 +#: app/optionWindow.py:47 msgid "" "\n" "Launch Game and minimise BEE2?" msgstr "" -#: app/optionWindow.py:51 +#: app/optionWindow.py:49 msgid "" "\n" "Quit BEE2?" msgstr "" -#: app/optionWindow.py:52 +#: app/optionWindow.py:50 msgid "" "\n" "Launch Game and quit BEE2?" msgstr "" -#: app/optionWindow.py:71 +#: app/optionWindow.py:69 msgid "BEE2 Options" msgstr "BEE2 选项" -#: app/optionWindow.py:109 +#: app/optionWindow.py:107 msgid "" "Package cache times have been reset. These will now be extracted during " "the next export." msgstr "" -#: app/optionWindow.py:126 +#: app/optionWindow.py:124 msgid "\"Preserve Game Resources\" has been disabled." msgstr "" -#: app/optionWindow.py:138 +#: app/optionWindow.py:136 msgid "Packages Reset" msgstr "" -#: app/optionWindow.py:219 +#: app/optionWindow.py:217 msgid "General" msgstr "常规" -#: app/optionWindow.py:225 +#: app/optionWindow.py:223 msgid "Windows" msgstr "窗口" -#: app/optionWindow.py:231 +#: app/optionWindow.py:229 msgid "Development" msgstr "开发者选项" -#: app/optionWindow.py:255 app/packageMan.py:110 app/selector_win.py:677 +#: app/optionWindow.py:253 app/packageMan.py:110 app/selector_win.py:850 msgid "OK" msgstr "确定" -#: app/optionWindow.py:286 +#: app/optionWindow.py:284 msgid "After Export:" msgstr "导出后:" -#: app/optionWindow.py:303 +#: app/optionWindow.py:301 msgid "Do Nothing" msgstr "什么都不做" -#: app/optionWindow.py:309 +#: app/optionWindow.py:307 msgid "Minimise BEE2" msgstr "最小化BEE2" -#: app/optionWindow.py:315 +#: app/optionWindow.py:313 msgid "Quit BEE2" msgstr "退出BEE2" -#: app/optionWindow.py:323 +#: app/optionWindow.py:321 msgid "After exports, do nothing and keep the BEE2 in focus." msgstr "导出后,什么也不做,保持BEE2窗口处于焦点。" -#: app/optionWindow.py:325 +#: app/optionWindow.py:323 msgid "After exports, minimise to the taskbar/dock." msgstr "导出后,最小化到任务栏。" -#: app/optionWindow.py:326 +#: app/optionWindow.py:324 msgid "After exports, quit the BEE2." msgstr "导出后,退出BEE2。" -#: app/optionWindow.py:333 +#: app/optionWindow.py:331 msgid "Launch Game" msgstr "启动游戏" -#: app/optionWindow.py:334 +#: app/optionWindow.py:332 msgid "After exporting, launch the selected game automatically." msgstr "导出后,自动启动目标游戏" -#: app/optionWindow.py:342 app/optionWindow.py:348 +#: app/optionWindow.py:340 app/optionWindow.py:346 msgid "Play Sounds" msgstr "播放音效" -#: app/optionWindow.py:353 +#: app/optionWindow.py:351 msgid "" "Pyglet is either not installed or broken.\n" "Sound effects have been disabled." @@ -1403,97 +1406,97 @@ msgstr "" "Pyglet未安装或损坏。\n" "音效已被禁用。" -#: app/optionWindow.py:360 +#: app/optionWindow.py:358 msgid "Reset Package Caches" msgstr "重置数据包缓存" -#: app/optionWindow.py:366 +#: app/optionWindow.py:364 msgid "Force re-extracting all package resources." msgstr "" -#: app/optionWindow.py:375 +#: app/optionWindow.py:373 msgid "Keep windows inside screen" msgstr "保持窗口在屏幕内" -#: app/optionWindow.py:376 +#: app/optionWindow.py:374 msgid "" "Prevent sub-windows from moving outside the screen borders. If you have " "multiple monitors, disable this." msgstr "防止子窗口移出屏幕边界。 如果您有多台显示器,请禁用此功能。" -#: app/optionWindow.py:386 +#: app/optionWindow.py:384 #, fuzzy msgid "Keep loading screens on top" msgstr "清理过时的旧截图" -#: app/optionWindow.py:388 +#: app/optionWindow.py:386 msgid "" "Force loading screens to be on top of other windows. Since they don't " "appear on the taskbar/dock, they can't be brought to the top easily " "again." msgstr "" -#: app/optionWindow.py:397 +#: app/optionWindow.py:395 msgid "Reset All Window Positions" msgstr "重置所有窗口的位置" -#: app/optionWindow.py:411 +#: app/optionWindow.py:409 msgid "Log missing entity counts" msgstr "记录丢失的实体数量" -#: app/optionWindow.py:412 +#: app/optionWindow.py:410 msgid "" "When loading items, log items with missing entity counts in their " "properties.txt file." msgstr "加载物品时,将缺少实体的物品数量记录在properties.txt文件中。" -#: app/optionWindow.py:420 +#: app/optionWindow.py:418 msgid "Log when item doesn't have a style" msgstr "记录缺失风格的物品" -#: app/optionWindow.py:421 +#: app/optionWindow.py:419 msgid "" "Log items have no applicable version for a particular style.This usually " "means it will look very bad." msgstr "记录没有当前风格版本的物品。这通常意味着它看起来很违和。" -#: app/optionWindow.py:429 +#: app/optionWindow.py:427 msgid "Log when item uses parent's style" msgstr "记录使用父风格的物品" -#: app/optionWindow.py:430 +#: app/optionWindow.py:428 msgid "" "Log when an item reuses a variant from a parent style (1970s using 1950s " "items, for example). This is usually fine, but may need to be fixed." msgstr "记录重复使用父风格变体的物品(例如1970s年代使用1950s年代的物品)。这通常没问题,但是可能需要修复。" -#: app/optionWindow.py:439 +#: app/optionWindow.py:437 msgid "Log missing packfile resources" msgstr "记录丢失的数据包" -#: app/optionWindow.py:440 +#: app/optionWindow.py:438 msgid "" "Log when the resources a \"PackList\" refers to are not present in the " "zip. This may be fine (in a prerequisite zip), but it often indicates an " "error." msgstr "记录“数据包列表”里丢失的zip文件。这可能没问题(必备的数据包还在),但通常会报错。" -#: app/optionWindow.py:450 +#: app/optionWindow.py:448 #, fuzzy msgid "Development Mode" msgstr "开发者选项" -#: app/optionWindow.py:451 +#: app/optionWindow.py:449 msgid "" "Enables displaying additional UI specific for development purposes. " "Requires restart to have an effect." msgstr "" -#: app/optionWindow.py:459 +#: app/optionWindow.py:457 msgid "Preserve Game Directories" msgstr "保留游戏目录" -#: app/optionWindow.py:461 +#: app/optionWindow.py:459 #, fuzzy msgid "" "When exporting, do not copy resources to \n" @@ -1504,19 +1507,19 @@ msgstr "" "当你导出时,不要覆盖写入目录“bee2/”和“sdk_content/maps/bee2/”。\n" "除非你正在建造新的内容,但要确定它不会被覆盖写入。" -#: app/optionWindow.py:472 +#: app/optionWindow.py:470 msgid "Show Log Window" msgstr "显示日志窗口" -#: app/optionWindow.py:474 +#: app/optionWindow.py:472 msgid "Show the log file in real-time." msgstr "实时显示日志文件。" -#: app/optionWindow.py:481 +#: app/optionWindow.py:479 msgid "Force Editor Models" msgstr "强制使用编辑器模型" -#: app/optionWindow.py:482 +#: app/optionWindow.py:480 msgid "" "Make all props_map_editor models available for use. Portal 2 has a limit " "of 1024 models loaded in memory at once, so we need to disable unused " @@ -1525,15 +1528,23 @@ msgstr "" "使所有props_map_editor模型可用。\n" "Portal 2一次最多可将1024个模型加载到内存中,因此我们需要禁用未使用的模型以释放此模型。" -#: app/optionWindow.py:493 +#: app/optionWindow.py:491 #, fuzzy msgid "Dump All objects" msgstr "反选所有复选框" -#: app/optionWindow.py:499 +#: app/optionWindow.py:497 msgid "Dump Items list" msgstr "" +#: app/optionWindow.py:502 +msgid "Reload Images" +msgstr "" + +#: app/optionWindow.py:505 +msgid "Reload all images in the app. Expect the app to freeze momentarily." +msgstr "" + #: app/packageMan.py:64 msgid "BEE2 - Restart Required!" msgstr "BEE2 - 需要重新启动!" @@ -1551,156 +1562,211 @@ msgid "BEE2 - Manage Packages" msgstr "BEE2 - 管理数据包" #. i18n: Last exported items -#: app/paletteLoader.py:25 +#: app/paletteLoader.py:26 msgid "" msgstr "<上次导出的模板>" #. i18n: Empty palette name -#: app/paletteLoader.py:27 +#: app/paletteLoader.py:28 msgid "Blank" msgstr "空白" #. i18n: BEEmod 1 palette. -#: app/paletteLoader.py:30 +#: app/paletteLoader.py:31 msgid "BEEMod" msgstr "BEE模组物品" #. i18n: Default items merged together -#: app/paletteLoader.py:32 +#: app/paletteLoader.py:33 msgid "Portal 2 Collapsed" msgstr "传送门2(已折叠)" -#: app/richTextBox.py:183 +#: app/paletteUI.py:66 +msgid "Clear Palette" +msgstr "清空模板内容" + +#: app/paletteUI.py:92 app/paletteUI.py:109 +msgid "Delete Palette" +msgstr "删除选中模板" + +#: app/paletteUI.py:115 +#, fuzzy +msgid "Change Palette Group..." +msgstr "另存为模板..." + +#: app/paletteUI.py:121 +#, fuzzy +msgid "Rename Palette..." +msgstr "保存模板..." + +#: app/paletteUI.py:127 +msgid "Fill Palette" +msgstr "重置模板" + +#: app/paletteUI.py:141 +msgid "Save Palette" +msgstr "保存模板" + +#: app/paletteUI.py:187 +msgid "Builtin / Readonly" +msgstr "" + +#: app/paletteUI.py:246 +msgid "Delete Palette \"{}\"" +msgstr "删除模板 \"{}\"" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "BEE2 - Save Palette" +msgstr "BEE2 - 保存模板" + +#: app/paletteUI.py:296 app/paletteUI.py:316 +msgid "Enter a name:" +msgstr "键入模板名称:" + +#: app/paletteUI.py:334 +msgid "BEE2 - Change Palette Group" +msgstr "" + +#: app/paletteUI.py:335 +msgid "Enter the name of the group for this palette, or \"\" to ungroup." +msgstr "" + +#: app/richTextBox.py:197 msgid "Open \"{}\" in the default browser?" msgstr "在默认浏览器中打开 \"{}\" ?" +#: app/selector_win.py:491 +msgid "{} Preview" +msgstr "" + #. i18n: 'None' item description -#: app/selector_win.py:378 +#: app/selector_win.py:556 msgid "Do not add anything." msgstr "不要添加任何东西。" #. i18n: 'None' item name. -#: app/selector_win.py:382 +#: app/selector_win.py:560 msgid "" msgstr "<无>" -#: app/selector_win.py:562 app/selector_win.py:567 -msgid "Suggested" -msgstr "建议" - -#: app/selector_win.py:614 +#: app/selector_win.py:794 msgid "Play a sample of this item." msgstr "播放该物品的示例" -#: app/selector_win.py:688 -msgid "Reset to Default" -msgstr "重置为默认值" +#: app/selector_win.py:862 +#, fuzzy +msgid "Select Suggested" +msgstr "建议" -#: app/selector_win.py:859 +#: app/selector_win.py:1058 msgid "Other" msgstr "其它" -#: app/selector_win.py:1076 +#: app/selector_win.py:1311 msgid "Author: {}" msgid_plural "Authors: {}" msgstr[0] "作者: {}" #. i18n: Tooltip for colour swatch. -#: app/selector_win.py:1139 +#: app/selector_win.py:1379 msgid "Color: R={r}, G={g}, B={b}" msgstr "颜色: R={r}, G={g}, B={b}" -#: app/signage_ui.py:138 app/signage_ui.py:274 +#: app/selector_win.py:1592 app/selector_win.py:1598 +msgid "Suggested" +msgstr "建议" + +#: app/signage_ui.py:134 app/signage_ui.py:270 msgid "Configure Signage" msgstr "设置指示标志" -#: app/signage_ui.py:142 +#: app/signage_ui.py:138 msgid "Selected" msgstr "所选风格" -#: app/signage_ui.py:209 +#: app/signage_ui.py:205 msgid "Signage: {}" msgstr "指示标志: {}" -#: app/voiceEditor.py:36 +#: app/voiceEditor.py:37 msgid "Singleplayer" msgstr "单人模式" -#: app/voiceEditor.py:37 +#: app/voiceEditor.py:38 msgid "Cooperative" msgstr "合作模式" -#: app/voiceEditor.py:38 +#: app/voiceEditor.py:39 msgid "ATLAS (SP/Coop)" msgstr "" -#: app/voiceEditor.py:39 +#: app/voiceEditor.py:40 msgid "P-Body (SP/Coop)" msgstr "" -#: app/voiceEditor.py:42 +#: app/voiceEditor.py:43 msgid "Human characters (Bendy and Chell)" msgstr "人物角色(小黑人和雪儿)" -#: app/voiceEditor.py:43 +#: app/voiceEditor.py:44 msgid "AI characters (ATLAS, P-Body, or Coop)" msgstr "AI字符(ATLAS,P-Body或Coop)" -#: app/voiceEditor.py:50 +#: app/voiceEditor.py:51 msgid "Death - Toxic Goo" msgstr "致命 - 剧毒酸液" -#: app/voiceEditor.py:51 +#: app/voiceEditor.py:52 msgid "Death - Turrets" msgstr "致命 - 炮塔" -#: app/voiceEditor.py:52 +#: app/voiceEditor.py:53 msgid "Death - Crusher" msgstr "致命 - 钉板" -#: app/voiceEditor.py:53 +#: app/voiceEditor.py:54 msgid "Death - LaserField" msgstr "致命 - 激光力场" -#: app/voiceEditor.py:106 +#: app/voiceEditor.py:107 msgid "Transcript:" msgstr "台词:" -#: app/voiceEditor.py:145 +#: app/voiceEditor.py:146 msgid "Save" msgstr "保存" -#: app/voiceEditor.py:220 +#: app/voiceEditor.py:221 msgid "Resp" msgstr "响应" -#: app/voiceEditor.py:237 +#: app/voiceEditor.py:238 msgid "BEE2 - Configure \"{}\"" msgstr "BEE2 - 配置 \"{}\"" -#: app/voiceEditor.py:314 +#: app/voiceEditor.py:315 msgid "Mid - Chamber" msgstr "在测试室内" -#: app/voiceEditor.py:316 +#: app/voiceEditor.py:317 msgid "" "Lines played during the actual chamber, after specific events have " "occurred." msgstr "在测试室内触发特定事件后播放的台词。" -#: app/voiceEditor.py:322 +#: app/voiceEditor.py:323 msgid "Responses" msgstr "回应" -#: app/voiceEditor.py:324 +#: app/voiceEditor.py:325 msgid "Lines played in response to certain events in Coop." msgstr "在合作模式中触发特定事件后播放的台词。" -#: app/voiceEditor.py:422 +#: app/voiceEditor.py:423 msgid "No Name!" msgstr "没有名称!" -#: app/voiceEditor.py:458 +#: app/voiceEditor.py:459 msgid "No Name?" msgstr "没有名称?" @@ -1750,3 +1816,9 @@ msgstr "没有名称?" #~ msgid "Enables displaying additional UI specific for development purposes." #~ msgstr "" +#~ msgid "This palette already exists. Overwrite?" +#~ msgstr "已经存在同名模板,是否覆盖?" + +#~ msgid "Reset to Default" +#~ msgstr "重置为默认值" + diff --git a/images/BEE2/corr_generic.png b/images/BEE2/corr_generic.png index 58f8919dc..fc3ae955b 100644 Binary files a/images/BEE2/corr_generic.png and b/images/BEE2/corr_generic.png differ diff --git a/images/BEE2/menu.png b/images/BEE2/menu.png index deee83ad4..5b0350b02 100644 Binary files a/images/BEE2/menu.png and b/images/BEE2/menu.png differ diff --git a/images/splash_screen/splash_bts.jpg b/images/splash_screen/splash_bts.jpg deleted file mode 100644 index ae85bdb2a..000000000 Binary files a/images/splash_screen/splash_bts.jpg and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 621c29504..478e9f77f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ attrs>=20.3.0 mistletoe>=0.7.2 -Pillow>=8.1.1 -pyglet>=1.5.7 -PyInstaller>=4.2 +Pillow>=8.3.2 +pyglet==1.5.18 +PyInstaller>=4.5.1 pygtrie>=2.4.0 atomicwrites>=1.4.0 -srctools @ git+https://github.com/TeamSpen210/srctools.git +babel>=2.9.1 +trio>=0.19.0 +versioningit>=0.3.0 +srctools ~= 2.2.0 diff --git a/src/BEE2.spec b/src/BEE2.spec index 7b31b83b7..57e6eb161 100644 --- a/src/BEE2.spec +++ b/src/BEE2.spec @@ -1,7 +1,6 @@ import os import sys from pathlib import Path -import srctools import contextlib from babel.messages import Catalog import babel.messages.frontend @@ -9,9 +8,14 @@ import babel.messages.extract from babel.messages.pofile import read_po, write_po from babel.messages.mofile import write_mo - ico_path = os.path.realpath(os.path.join(os.getcwd(), "../bee2.ico")) +# Injected by PyInstaller. +workpath: str +SPECPATH: str +# Allow importing utils. +sys.path.append(SPECPATH) +import utils # src -> build subfolder. data_files = [ @@ -23,7 +27,7 @@ data_files = [ ] -def do_localisation(): +def do_localisation() -> None: """Build localisation.""" # Make the directories. @@ -135,8 +139,7 @@ EXCLUDES = [ 'numpy', # PIL.ImageFilter imports, we don't need NumPy! 'bz2', # We aren't using this compression format (shutil, zipfile etc handle ImportError).. - - 'sqlite3', # Imported from aenum, but we don't use that enum subclass. + 'importlib_resources', # 3.6 backport. # Imported by logging handlers which we don't use.. 'win32evtlog', @@ -149,16 +152,23 @@ EXCLUDES = [ 'argparse', ] -if sys.version_info >= (3, 7): - # Only needed on 3.6, it's in the stdlib thereafter. - EXCLUDES += ['importlib_resources'] +binaries = [] +if utils.WIN: + lib_path = Path(SPECPATH, '..', 'lib-' + utils.BITNESS).absolute() + try: + for dll in lib_path.iterdir(): + if dll.suffix == '.dll': + binaries.append((str(dll), '.')) + except FileNotFoundError: + lib_path.mkdir(exist_ok=True) + raise ValueError(f'FFmpeg dlls should be downloaded into "{lib_path}".') -bee_version = input('BEE2 Version (x.y.z): ') # Write this to the temp folder, so it's picked up and included. # Don't write it out though if it's the same, so PyInstaller doesn't reparse. -version_val = 'BEE_VERSION=' + repr(bee_version) -version_filename = os.path.join(workpath, 'BUILD_CONSTANTS.py') +version_val = 'BEE_VERSION=' + repr(utils.get_git_version(SPECPATH)) +print(version_val) +version_filename = os.path.join(workpath, '_compiled_version.py') with contextlib.suppress(FileNotFoundError), open(version_filename) as f: if f.read().strip() == version_val: @@ -181,11 +191,12 @@ data_files.append((f'../dist/{bitness}bit/compiler/', 'compiler')) bee2_a = Analysis( ['BEE2_launch.pyw'], - pathex=[workpath, os.path.dirname(srctools.__path__[0])], + pathex=[workpath], datas=data_files, hiddenimports=[ 'PIL._tkinter_finder', ], + binaries=binaries, hookspath=[], runtime_hooks=[], excludes=EXCLUDES, @@ -215,7 +226,7 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, console=False, windowed=True, icon='../BEE2.ico' diff --git a/src/BEE2_config.py b/src/BEE2_config.py index 2b65dbab6..f09d4a6cf 100644 --- a/src/BEE2_config.py +++ b/src/BEE2_config.py @@ -10,7 +10,7 @@ """ from configparser import ConfigParser, NoOptionError, SectionProxy, ParsingError from pathlib import Path -from typing import Any, Mapping, Optional +from typing import Any, Mapping, Optional, Callable, Iterator from threading import Lock, Event from atomicwrites import atomic_write @@ -23,32 +23,38 @@ LOGGER = srctools.logger.get_logger(__name__) # Functions for saving or loading application settings. -# Call with a block to load, or with no args to return the current -# values. -option_handler = utils.FuncLookup('OptionHandlers') +# The palette attribute indicates if this will be persisted in palettes. +OPTION_LOAD: utils.FuncLookup[Callable[[Property], None]] = utils.FuncLookup('LoadHandler', attrs=['from_palette']) +OPTION_SAVE: utils.FuncLookup[Callable[[], Property]] = utils.FuncLookup('SaveHandler', attrs=['to_palette']) -def get_curr_settings() -> Property: +def get_curr_settings(*, is_palette: bool) -> Property: """Return a property tree defining the current options.""" - props = Property('', []) + props = Property.root() - for opt_id, opt_func in option_handler.items(): - opt_prop: Property = opt_func() + for opt_id, opt_func in OPTION_SAVE.items(): + # Skip if it opts out of being on the palette. + if is_palette and not getattr(opt_func, 'to_palette', True): + continue + opt_prop = opt_func() opt_prop.name = opt_id.title() props.append(opt_prop) return props -def apply_settings(props: Property) -> None: +def apply_settings(props: Property, *, is_palette: bool) -> None: """Given a property tree, apply it to the widgets.""" for opt_prop in props: try: - func = option_handler[opt_prop.name] + func = OPTION_LOAD[opt_prop.name] except KeyError: LOGGER.warning('No handler for option type "{}"!', opt_prop.real_name) - else: - func(opt_prop) + continue + # Skip if it opts out of being on the palette. + if is_palette and not getattr(func, 'from_palette', True): + continue + func(opt_prop) def read_settings() -> None: @@ -68,20 +74,34 @@ def read_settings() -> None: path.replace(path.with_suffix('.err.vdf')) except IOError: pass - apply_settings(props) + apply_settings(props, is_palette=False) def write_settings() -> None: """Write the settings to disk.""" - props = get_curr_settings() - props.name = None + props = get_curr_settings(is_palette=False) with atomic_write( utils.conf_location('config/config.vdf'), encoding='utf8', overwrite=True, ) as file: - for line in props.export(): - file.write(line) + for prop in props: + for line in prop.export(): + file.write(line) + + +def get_package_locs() -> Iterator[Path]: + """Return all the package search locations from the config.""" + section = GEN_OPTS['Directories'] + yield Path(section['package']) + i = 1 + while True: + try: + val = section[f'package{i}'] + except KeyError: + return + yield Path(val) + i += 1 class ConfigFile(ConfigParser): @@ -160,6 +180,8 @@ def save(self) -> None: if self.filename is None: raise ValueError('No filename provided!') + # Create the parent if it hasn't already. + self.filename.parent.mkdir(parents=True, exist_ok=True) with atomic_write(self.filename, overwrite=True, encoding='utf8') as conf: self.write(conf) self.has_changed.clear() @@ -201,7 +223,7 @@ def __getitem__(self, section: str) -> SectionProxy: self[section] = {} return super().__getitem__(section) - def getboolean(self, section: str, value: str, default: bool=False) -> bool: + def getboolean(self, section: str, value: str, default: bool=False, **kwargs) -> bool: """Get the value in the specified section, coercing to a Boolean. If either does not exist, set to the default and return it. @@ -209,7 +231,7 @@ def getboolean(self, section: str, value: str, default: bool=False) -> bool: if section not in self: self[section] = {} try: - return super().getboolean(section, value) + return super().getboolean(section, value, **kwargs) except (ValueError, NoOptionError): # Invalid boolean, or not found self.has_changed.set() @@ -218,7 +240,7 @@ def getboolean(self, section: str, value: str, default: bool=False) -> bool: get_bool = getboolean - def getint(self, section: str, value: str, default: int=0) -> int: + def getint(self, section: str, value: str, default: int=0, **kwargs) -> int: """Get the value in the specified section, coercing to a Integer. If either does not exist, set to the default and return it. @@ -226,7 +248,7 @@ def getint(self, section: str, value: str, default: int=0) -> int: if section not in self: self[section] = {} try: - return super().getint(section, value) + return super().getint(section, value, **kwargs) except (ValueError, NoOptionError): self.has_changed.set() self[section][value] = str(int(default)) @@ -235,24 +257,23 @@ def getint(self, section: str, value: str, default: int=0) -> int: get_int = getint def add_section(self, section: str) -> None: + """Add a file section.""" self.has_changed.set() super().add_section(section) def remove_section(self, section: str) -> bool: + """Remove a file section.""" self.has_changed.set() return super().remove_section(section) - def set(self, section: str, option: str, value: str) -> None: + def set(self, section: str, option: str, value: Any=None) -> None: + """Set an option, marking the file dirty if this changed it.""" orig_val = self.get(section, option, fallback=None) value = str(value) if orig_val is None or orig_val != value: self.has_changed.set() super().set(section, option, value) - add_section.__doc__ = ConfigParser.add_section.__doc__ - remove_section.__doc__ = ConfigParser.remove_section.__doc__ - set.__doc__ = ConfigParser.set.__doc__ - # Define this here so app modules can easily access the config # Don't load it though, since this is imported by VBSP too. diff --git a/src/BEE2_launch.pyw b/src/BEE2_launch.pyw index cf9e05597..4cee6b065 100644 --- a/src/BEE2_launch.pyw +++ b/src/BEE2_launch.pyw @@ -3,7 +3,7 @@ from multiprocessing import freeze_support, set_start_method import os import sys -# We need to add dummy files if these are None - MultiProccessing tries to flush +# We need to add dummy files if these are None - multiprocessing tries to flush # them. if sys.stdout is None: sys.stdout = open(os.devnull, 'w') @@ -12,16 +12,16 @@ if sys.stderr is None: if sys.stdin is None: sys.stdin = open(os.devnull, 'r') -if sys.platform == "darwin": - # Disable here, can't get this to work. - sys.modules['pyglet'] = None - - # Fork breaks on Mac, so override. - set_start_method('spawn') - freeze_support() if __name__ == '__main__': + if sys.platform == "darwin": + # Disable here, can't get this to work. + sys.modules['pyglet'] = None # type: ignore + + # Fork breaks on Mac, so override. + set_start_method('spawn') + import srctools.logger from app import on_error, TK_ROOT import utils @@ -41,26 +41,30 @@ if __name__ == '__main__': __name__, on_error=on_error, ) - utils.setup_localisations(LOGGER) - LOGGER.info('Arguments: {}', sys.argv) LOGGER.info('Running "{}", version {}:', app_name, utils.BEE_VERSION) + # Warn if srctools Cython code isn't installed. + utils.check_cython(LOGGER.warning) + + import localisation + localisation.setup(LOGGER) + if app_name == 'bee2': from app import BEE2 + BEE2.start_main() elif app_name == 'backup': from app import backup backup.init_application() + TK_ROOT.mainloop() elif app_name == 'compilepane': from app import CompilerPane CompilerPane.init_application() + TK_ROOT.mainloop() elif app_name.startswith('test_'): import importlib mod = importlib.import_module('app.' + sys.argv[1][5:]) - mod.test() + mod.test() # type: ignore + TK_ROOT.mainloop() else: raise ValueError(f'Invalid component name "{app_name}"!') - - # Run the TK loop forever. - TK_ROOT.mainloop() - diff --git a/src/app/BEE2.py b/src/app/BEE2.py index 8c3ccf3b1..346186cfb 100644 --- a/src/app/BEE2.py +++ b/src/app/BEE2.py @@ -1,14 +1,15 @@ """Run the BEE2.""" -# BEE2_config creates this config file to allow easy cross-module access -from BEE2_config import GEN_OPTS +import trio -from app import gameMan, paletteLoader, UI, music_conf, logWindow, img, TK_ROOT +from BEE2_config import GEN_OPTS, get_package_locs +from app import gameMan, UI, music_conf, logWindow, img, TK_ROOT, DEV_MODE, tk_error, sound import loadScreen import packages import utils import srctools.logger LOGGER = srctools.logger.get_logger('BEE2') +APP_NURSERY: trio.Nursery DEFAULT_SETTINGS = { 'Directories': { @@ -38,6 +39,9 @@ # Warn if a file is missing that a packfile refers to 'log_incorrect_packfile': '0', + # Determines if additional options are displayed. + 'development_mode': '0', + # Show the log window on startup 'show_log_win': '0', # The lowest level which will be shown. @@ -45,71 +49,105 @@ }, } -GEN_OPTS.load() -GEN_OPTS.set_defaults(DEFAULT_SETTINGS) - -LOGGER.debug('Starting loading screen...') -loadScreen.main_loader.set_length('UI', 15) -loadScreen.set_force_ontop(GEN_OPTS.get_bool('General', 'splash_stay_ontop')) -loadScreen.show_main_loader(GEN_OPTS.get_bool('General', 'compact_splash')) - -# OS X starts behind other windows, fix that. -if utils.MAC: - TK_ROOT.lift() - -logWindow.HANDLER.set_visible(GEN_OPTS.get_bool('Debug', 'show_log_win')) -logWindow.HANDLER.setLevel(GEN_OPTS['Debug']['window_log_level']) -LOGGER.debug('Loading settings...') - -UI.load_settings() - -gameMan.load() -gameMan.set_game_by_name( - GEN_OPTS.get_val('Last_Selected', 'Game', ''), +async def init_app(): + """Initialise the application.""" + GEN_OPTS.load() + GEN_OPTS.set_defaults(DEFAULT_SETTINGS) + + # Special case, load in this early so it applies. + utils.DEV_MODE = GEN_OPTS.get_bool('Debug', 'development_mode') + DEV_MODE.set(utils.DEV_MODE) + + LOGGER.debug('Starting loading screen...') + loadScreen.main_loader.set_length('UI', 16) + loadScreen.set_force_ontop(GEN_OPTS.get_bool('General', 'splash_stay_ontop')) + loadScreen.show_main_loader(GEN_OPTS.get_bool('General', 'compact_splash')) + + # OS X starts behind other windows, fix that. + if utils.MAC: + TK_ROOT.lift() + + logWindow.HANDLER.set_visible(GEN_OPTS.get_bool('Debug', 'show_log_win')) + logWindow.HANDLER.setLevel(GEN_OPTS['Debug']['window_log_level']) + + LOGGER.debug('Loading settings...') + + UI.load_settings() + + gameMan.load() + gameMan.set_game_by_name( + GEN_OPTS.get_val('Last_Selected', 'Game', ''), + ) + gameMan.scan_music_locs() + + LOGGER.info('Loading Packages...') + package_sys = await packages.load_packages( + list(get_package_locs()), + loader=loadScreen.main_loader, + log_item_fallbacks=GEN_OPTS.get_bool( + 'Debug', 'log_item_fallbacks'), + log_missing_styles=GEN_OPTS.get_bool( + 'Debug', 'log_missing_styles'), + log_missing_ent_count=GEN_OPTS.get_bool( + 'Debug', 'log_missing_ent_count'), + log_incorrect_packfile=GEN_OPTS.get_bool( + 'Debug', 'log_incorrect_packfile'), + has_tag_music=gameMan.MUSIC_TAG_LOC is not None, + has_mel_music=gameMan.MUSIC_MEL_VPK is not None, + ) + loadScreen.main_loader.step('UI', 'pre_ui') + APP_NURSERY.start_soon(img.init, package_sys) + APP_NURSERY.start_soon(sound.sound_task) + + # Load filesystems into various modules + music_conf.load_filesystems(package_sys.values()) + gameMan.load_filesystems(package_sys.values()) + UI.load_packages() + loadScreen.main_loader.step('UI', 'package_load') + LOGGER.info('Done!') + + # Check games for Portal 2's basemodui.txt file, so we can translate items. + LOGGER.info('Loading Item Translations...') + for game in gameMan.all_games: + game.init_trans() + + LOGGER.info('Initialising UI...') + await UI.init_windows() # create all windows + LOGGER.info('UI initialised!') + + loadScreen.main_loader.destroy() + # Delay this until the loop has actually run. + # Directly run TK_ROOT.lift() in TCL, instead + # of building a callable. + TK_ROOT.tk.call('after', 10, 'raise', TK_ROOT) + + +async def app_main() -> None: + """The main loop for Trio.""" + global APP_NURSERY + LOGGER.debug('Opening nursery...') + try: + async with trio.open_nursery() as nursery: + APP_NURSERY = nursery + await init_app() + await trio.sleep_forever() + except Exception as exc: + tk_error(type(exc), exc, exc.__traceback__) + raise + + +def done_callback(trio_main_outcome): + """The app finished, quit.""" + from app import UI + UI.quit_application() + + +def start_main() -> None: + LOGGER.debug('Starting Trio loop.') + trio.lowlevel.start_guest_run( + app_main, + run_sync_soon_threadsafe=TK_ROOT.after_idle, + done_callback=done_callback, ) -gameMan.scan_music_locs() - -LOGGER.info('Loading Packages...') -package_sys = packages.load_packages( - GEN_OPTS['Directories']['package'], - loader=loadScreen.main_loader, - log_item_fallbacks=GEN_OPTS.get_bool( - 'Debug', 'log_item_fallbacks'), - log_missing_styles=GEN_OPTS.get_bool( - 'Debug', 'log_missing_styles'), - log_missing_ent_count=GEN_OPTS.get_bool( - 'Debug', 'log_missing_ent_count'), - log_incorrect_packfile=GEN_OPTS.get_bool( - 'Debug', 'log_incorrect_packfile'), - has_tag_music=gameMan.MUSIC_TAG_LOC is not None, - has_mel_music=gameMan.MUSIC_MEL_VPK is not None, -) - -# Load filesystems into various modules -music_conf.load_filesystems(package_sys.values()) -img.load_filesystems(package_sys) -gameMan.load_filesystems(package_sys.values()) - -UI.load_packages() -loadScreen.main_loader.step('UI') -LOGGER.info('Done!') - -LOGGER.info('Loading Palettes...') -paletteLoader.load_palettes() -LOGGER.info('Done!') - -# Check games for Portal 2's basemodui.txt file, so we can translate items. -LOGGER.info('Loading Item Translations...') -for game in gameMan.all_games: - game.init_trans() - -LOGGER.info('Initialising UI...') -UI.init_windows() # create all windows -LOGGER.info('UI initialised!') - -loadScreen.main_loader.destroy() -# Delay this until the loop has actually run. -# Directly run TK_ROOT.lift() in TCL, instead -# of building a callable. -TK_ROOT.tk.call('after', 10, 'raise', TK_ROOT) + TK_ROOT.mainloop() diff --git a/src/app/CheckDetails.py b/src/app/CheckDetails.py index d59d36713..f47df03ec 100644 --- a/src/app/CheckDetails.py +++ b/src/app/CheckDetails.py @@ -13,6 +13,7 @@ from app.tooltip import add_tooltip, set_tooltip from app import tk_tools +from localisation import gettext from typing import List, Iterator, Optional @@ -216,7 +217,7 @@ def __init__(self, parent, items=(), headers=(), add_sizegrip=False): ) self.wid_head_check.grid(row=0, column=0) - add_tooltip(self.wid_head_check, _("Toggle all checkboxes.")) + add_tooltip(self.wid_head_check, gettext("Toggle all checkboxes.")) def checkbox_enter(e): """When hovering over the 'all' checkbox, highlight the others.""" @@ -400,6 +401,9 @@ def refresh(self, e=None): Must be called when self.items is changed, or when window is resized. """ + # Don't bother if the window isn't actually visible. + if not self.winfo_ismapped(): + return header_sizes = [ (head.winfo_x(), head.winfo_width()) for head in @@ -520,4 +524,4 @@ def unchecked(self) -> Iterator[Item]: TK_ROOT.columnconfigure(0, weight=1) TK_ROOT.rowconfigure(0, weight=1) TK_ROOT.deiconify() - TK_ROOT.mainloop() \ No newline at end of file + TK_ROOT.mainloop() diff --git a/src/app/CompilerPane.py b/src/app/CompilerPane.py index a7266e6b7..6b2bd6d96 100644 --- a/src/app/CompilerPane.py +++ b/src/app/CompilerPane.py @@ -19,7 +19,8 @@ from app import selector_win, TK_ROOT from app.tooltip import add_tooltip, set_tooltip from app import tkMarkdown, SubPane, img, tk_tools -from BEE2_config import ConfigFile, option_handler +from localisation import gettext +import BEE2_config import utils @@ -29,7 +30,7 @@ PETI_WIDTH = 555 PETI_HEIGHT = 312 -CORRIDOR: dict[str, selector_win.selWin] = {} +CORRIDOR: dict[str, selector_win.SelectorWin] = {} CORRIDOR_DATA: dict[tuple[str, int], CorrDesc] = {} CORRIDOR_DESC = tkMarkdown.convert('', None) @@ -43,7 +44,7 @@ 'spawn_elev': 'True', 'player_model': 'PETI', 'force_final_light': '0', - 'use_voice_priority': '1', + 'voiceline_priority': '0', 'packfile_dump_dir': '', 'packfile_dump_enable': '0', }, @@ -66,15 +67,15 @@ } PLAYER_MODELS = { - 'ATLAS': _('ATLAS'), - 'PBODY': _('P-Body'), - 'SP': _('Chell'), - 'PETI': _('Bendy'), + 'ATLAS': gettext('ATLAS'), + 'PBODY': gettext('P-Body'), + 'SP': gettext('Chell'), + 'PETI': gettext('Bendy'), } PLAYER_MODEL_ORDER = ['PETI', 'SP', 'ATLAS', 'PBODY'] PLAYER_MODELS_REV = {value: key for key, value in PLAYER_MODELS.items()} -COMPILE_CFG = ConfigFile('compile.cfg') +COMPILE_CFG = BEE2_config.ConfigFile('compile.cfg') COMPILE_CFG.set_defaults(COMPILE_DEFAULTS) window: Union[SubPane.SubPane, tk.Tk, None] = None UI: dict[str, tk.Widget] = {} @@ -87,7 +88,7 @@ # Location we copy custom screenshots to SCREENSHOT_LOC = str(utils.conf_location('screenshot.jpg')) -VOICE_PRIORITY_VAR = tk.IntVar(value=COMPILE_CFG.get_bool('General', 'use_voice_priority', True)) +VOICE_PRIORITY_VAR = tk.IntVar(value=COMPILE_CFG.get_bool('General', 'voiceline_priority', False)) player_model_var = tk.StringVar( value=PLAYER_MODELS.get( @@ -101,6 +102,9 @@ packfile_dump_enable = tk.IntVar(value=COMPILE_CFG.get_bool('General', 'packfile_dump_enable')) +default_lrg_icon = img.Handle.builtin('BEE2/corr_generic', selector_win.ICON_SIZE, selector_win.ICON_SIZE) +default_sml_icon = default_lrg_icon.crop(selector_win.ICON_CROP_SHRINK) + count_brush = tk.IntVar(value=0) count_entity = tk.IntVar(value=0) count_overlay = tk.IntVar(value=0) @@ -116,26 +120,32 @@ ( count_brush, 'brush', 8192, # i18n: Progress bar description - _("Brushes form the walls or other parts of the test chamber. If this " - "is high, it may help to reduce the size of the map or remove " - "intricate shapes.") + gettext( + "Brushes form the walls or other parts of the test chamber. If this " + "is high, it may help to reduce the size of the map or remove " + "intricate shapes." + ) ), ( count_entity, 'entity', 2048, # i18n: Progress bar description - _("Entities are the things in the map that have functionality. Removing " - "complex moving items will help reduce this. Items have their entity " - "count listed in the item description window.\n\n" - "This isn't completely accurate, some entity types are counted here " - "but don't affect the ingame limit, while others may generate additional " - "entities at runtime."), + gettext( + "Entities are the things in the map that have functionality. " + "Removing complex moving items will help reduce this. Items have " + "their entity count listed in the item description window.\n\nThis " + "isn't completely accurate, some entity types are counted here but " + "don't affect the ingame limit, while others may generate " + "additional entities at runtime." + ), ), ( count_overlay, 'overlay', 512, # i18n: Progress bar description - _("Overlays are smaller images affixed to surfaces, like signs or " - "indicator lights. Hiding complex antlines or setting them to signage " - "will reduce this.") + gettext( + "Overlays are smaller images affixed to surfaces, like signs or " + "indicator lights. Hiding complex antlines or setting them to " + "signage will reduce this." + ) ), ] @@ -143,48 +153,49 @@ cleanup_screenshot = tk.IntVar(value=COMPILE_CFG.get_bool('Screenshot', 'del_old', True)) -@option_handler('CompilerPane') -def save_load_compile_pane(props: Optional[Property]=None) -> Optional[Property]: - """Save/load compiler options from the palette. +@BEE2_config.OPTION_SAVE('CompilerPane') +def save_handler() -> Property: + """Save the compiler pane to the palette. Note: We specifically do not save/load the following: - packfile dumping - compile counts This is because these are more system-dependent than map dependent. """ - if props is None: # Saving - corr_prop = Property('corridor', []) - props = Property('', [ - Property('sshot_type', chosen_thumb.get()), - Property('sshot_cleanup', str(cleanup_screenshot.get())), - Property('spawn_elev', str(start_in_elev.get())), - Property('player_model', PLAYER_MODELS_REV[player_model_var.get()]), - Property('use_voice_priority', str(VOICE_PRIORITY_VAR.get())), - corr_prop, - ]) - for group, win in CORRIDOR.items(): - corr_prop[group] = win.chosen_id or '' - - # Embed the screenshot in so we can load it later. - if chosen_thumb.get() == 'CUST': - # encodebytes() splits it into multiple lines, which we write - # in individual blocks to prevent having a massively long line - # in the file. - with open(SCREENSHOT_LOC, 'rb') as f: - screenshot_data = base64.encodebytes(f.read()) - props.append(Property( - 'sshot_data', - [ - Property('b64', data) - for data in - screenshot_data.decode('ascii').splitlines() - ] - )) - - return props - - # else: Loading + corr_prop = Property('corridor', []) + props = Property('', [ + Property('sshot_type', chosen_thumb.get()), + Property('sshot_cleanup', str(cleanup_screenshot.get())), + Property('spawn_elev', str(start_in_elev.get())), + Property('player_model', PLAYER_MODELS_REV[player_model_var.get()]), + Property('voiceline_priority', str(VOICE_PRIORITY_VAR.get())), + corr_prop, + ]) + for group, win in CORRIDOR.items(): + corr_prop[group] = win.chosen_id or '' + + # Embed the screenshot in so we can load it later. + if chosen_thumb.get() == 'CUST': + # encodebytes() splits it into multiple lines, which we write + # in individual blocks to prevent having a massively long line + # in the file. + with open(SCREENSHOT_LOC, 'rb') as f: + screenshot_data = base64.encodebytes(f.read()) + props.append(Property( + 'sshot_data', + [ + Property('b64', data) + for data in + screenshot_data.decode('ascii').splitlines() + ] + )) + + return props + +@BEE2_config.OPTION_LOAD('CompilerPane') +def load_handler(props: Property) -> None: + """Load compiler options from the palette.""" chosen_thumb.set(props['sshot_type', chosen_thumb.get()]) cleanup_screenshot.set(props.bool('sshot_cleanup', cleanup_screenshot.get())) @@ -212,9 +223,9 @@ def save_load_compile_pane(props: Optional[Property]=None) -> Optional[Property] player_model_var.set(PLAYER_MODELS[player_mdl]) COMPILE_CFG['General']['player_model'] = player_mdl - VOICE_PRIORITY_VAR.set(props.bool('use_voice_priority', VOICE_PRIORITY_VAR.get())) + VOICE_PRIORITY_VAR.set(props.bool('voiceline_priority', VOICE_PRIORITY_VAR.get())) - corr_prop = props.find_key('corridor', []) + corr_prop = props.find_block('corridor', or_blank=True) for group, win in CORRIDOR.items(): try: sel_id = corr_prop[group] @@ -247,8 +258,6 @@ def set_corridors(config: dict[tuple[str, int], CorrDesc]) -> None: CORRIDOR_DATA.clear() CORRIDOR_DATA.update(config) - default_icon = img.Handle.builtin('BEE2/corr_generic', 64, 64) - corridor_conf = COMPILE_CFG['CorridorNames'] for group, length in CORRIDOR_COUNTS.items(): @@ -265,7 +274,7 @@ def set_corridors(config: dict[tuple[str, int], CorrDesc]) -> None: corridor_conf['{}_{}_icon'.format(group, ind)] = str(data.icon) # Note: default corridor description - desc = data.name or _('Corridor') + desc = data.name or gettext('Corridor') item.longName = item.shortName = item.context_lbl = item.name + ': ' + desc if data.icon: @@ -273,12 +282,10 @@ def set_corridors(config: dict[tuple[str, int], CorrDesc]) -> None: data.icon, *selector_win.ICON_SIZE_LRG, ) - item.icon = img.Handle.parse_uri( - data.icon, - selector_win.ICON_SIZE, selector_win.ICON_SIZE, - ) + item.icon = None else: - item.icon = item.large_icon = default_icon + item.icon = default_sml_icon + item.large_icon = default_lrg_icon if data.desc: item.desc = tkMarkdown.convert(data.desc, None) @@ -295,7 +302,7 @@ def make_corr_wid(corr_name: str, title: str) -> None: """Create the corridor widget and items.""" length = CORRIDOR_COUNTS[corr_name] - CORRIDOR[corr_name] = sel = selector_win.selWin( + CORRIDOR[corr_name] = sel = selector_win.SelectorWin( TK_ROOT, [ selector_win.Item( @@ -304,14 +311,15 @@ def make_corr_wid(corr_name: str, title: str) -> None: ) for i in range(1, length + 1) ], + save_id='corr_' + corr_name, title=title, - none_desc=_( + none_desc=gettext( 'Randomly choose a corridor. ' 'This is saved in the puzzle data ' 'and will not change.' ), none_icon=img.Handle.builtin('BEE2/random', 96, 96), - none_name=_('Random'), + none_name=gettext('Random'), callback=sel_corr_callback, callback_params=[corr_name], ) @@ -414,8 +422,8 @@ def find_screenshot(e=None) -> None: title='Find Screenshot', filetypes=[ # note: File type description - (_('Image Files'), '*.jpg *.jpeg *.jpe *.jfif *.png *.bmp' - '*.tiff *.tga *.ico *.psd'), + (gettext('Image Files'), '*.jpg *.jpeg *.jpe *.jfif *.png *.bmp' + '*.tiff *.tga *.ico *.psd'), ], initialdir='C:', ) @@ -435,7 +443,7 @@ def set_screen_type() -> None: UI['thumb_label'].grid(row=2, column=0, columnspan=2, sticky='EW') else: UI['thumb_label'].grid_forget() - UI['thumb_label'].update() + UI['thumb_label'].update_idletasks() # Resize the pane to accommodate the shown/hidden image window.geometry('{}x{}'.format( window.winfo_width(), @@ -481,12 +489,12 @@ def make_widgets() -> None: """Create the compiler options pane. """ - make_setter('General', 'use_voice_priority', VOICE_PRIORITY_VAR) + make_setter('General', 'voiceline_priority', VOICE_PRIORITY_VAR) make_setter('General', 'spawn_elev', start_in_elev) make_setter('Screenshot', 'del_old', cleanup_screenshot) make_setter('General', 'vrad_force_full', vrad_light_type) - ttk.Label(window, justify='center', text=_( + ttk.Label(window, justify='center', text=gettext( "Options on this panel can be changed \n" "without exporting or restarting the game." )).grid(row=0, column=0, sticky='ew', padx=2, pady=2) @@ -501,12 +509,12 @@ def make_widgets() -> None: map_frame = ttk.Frame(nbook) # note: Tab name - nbook.add(map_frame, text=_('Map Settings')) + nbook.add(map_frame, text=gettext('Map Settings')) make_map_widgets(map_frame) comp_frame = ttk.Frame(nbook) # note: Tab name - nbook.add(comp_frame, text=_('Compile Settings')) + nbook.add(comp_frame, text=gettext('Compile Settings')) make_comp_widgets(comp_frame) @@ -520,7 +528,7 @@ def make_comp_widgets(frame: ttk.Frame): thumb_frame = ttk.LabelFrame( frame, - text=_('Thumbnail'), + text=gettext('Thumbnail'), labelanchor=tk.N, ) thumb_frame.grid(row=0, column=0, sticky=tk.EW) @@ -528,7 +536,7 @@ def make_comp_widgets(frame: ttk.Frame): UI['thumb_auto'] = ttk.Radiobutton( thumb_frame, - text=_('Auto'), + text=gettext('Auto'), value='AUTO', variable=chosen_thumb, command=set_screen_type, @@ -536,7 +544,7 @@ def make_comp_widgets(frame: ttk.Frame): UI['thumb_peti'] = ttk.Radiobutton( thumb_frame, - text=_('PeTI'), + text=gettext('PeTI'), value='PETI', variable=chosen_thumb, command=set_screen_type, @@ -544,7 +552,7 @@ def make_comp_widgets(frame: ttk.Frame): UI['thumb_custom'] = ttk.Radiobutton( thumb_frame, - text=_('Custom:'), + text=gettext('Custom:'), value='CUST', variable=chosen_thumb, command=set_screen_type, @@ -559,7 +567,7 @@ def make_comp_widgets(frame: ttk.Frame): UI['thumb_cleanup'] = ttk.Checkbutton( thumb_frame, - text=_('Cleanup old screenshots'), + text=gettext('Cleanup old screenshots'), variable=cleanup_screenshot, ) @@ -567,19 +575,17 @@ def make_comp_widgets(frame: ttk.Frame): UI['thumb_peti'].grid(row=0, column=1, sticky='W') UI['thumb_custom'].grid(row=1, column=0, columnspan=2, sticky='NEW') UI['thumb_cleanup'].grid(row=3, columnspan=2, sticky='W') - add_tooltip( - UI['thumb_auto'], - _("Override the map image to use a screenshot automatically taken " - "from the beginning of a chamber. Press F5 to take a new " - "screenshot. If the map has not been previewed recently " - "(within the last few hours), the default PeTI screenshot " - "will be used instead.") - ) + add_tooltip(UI['thumb_auto'], gettext( + "Override the map image to use a screenshot automatically taken from " + "the beginning of a chamber. Press F5 to take a new screenshot. If the " + "map has not been previewed recently (within the last few hours), the " + "default PeTI screenshot will be used instead." + )) add_tooltip( UI['thumb_peti'], - _("Use the normal editor view for the map preview image.") + gettext("Use the normal editor view for the map preview image.") ) - custom_tooltip = _( + custom_tooltip = gettext( "Use a custom image for the map preview image. Click the " "screenshot to select.\n" "Images will be converted to JPEGs if needed." @@ -594,11 +600,10 @@ def make_comp_widgets(frame: ttk.Frame): custom_tooltip, ) - add_tooltip( - UI['thumb_cleanup'], - _('Automatically delete unused Automatic screenshots. ' - 'Disable if you want to keep things in "portal2/screenshots". ') - ) + add_tooltip(UI['thumb_cleanup'], gettext( + 'Automatically delete unused Automatic screenshots. Disable if you want ' + 'to keep things in "portal2/screenshots". ' + )) if chosen_thumb.get() == 'CUST': # Show this if the user has set it before @@ -607,43 +612,44 @@ def make_comp_widgets(frame: ttk.Frame): vrad_frame = ttk.LabelFrame( frame, - text=_('Lighting:'), + text=gettext('Lighting:'), labelanchor='n', ) vrad_frame.grid(row=1, column=0, sticky='ew') UI['light_fast'] = ttk.Radiobutton( vrad_frame, - text=_('Fast'), + text=gettext('Fast'), value=0, variable=vrad_light_type, ) UI['light_fast'].grid(row=0, column=0) UI['light_full'] = ttk.Radiobutton( vrad_frame, - text=_('Full'), + text=gettext('Full'), value=1, variable=vrad_light_type, ) UI['light_full'].grid(row=0, column=1) - add_tooltip( - UI['light_fast'], - _("Compile with lower-quality, fast lighting. This speeds " - "up compile times, but does not appear as good. Some " - "shadows may appear wrong.\n" - "When publishing, this is ignored.") - ) - add_tooltip( - UI['light_full'], - _("Compile with high-quality lighting. This looks correct, " - "but takes longer to compute. Use if you're arranging lights.\n" - "When publishing, this is always used.") - ) + light_conf_swap = gettext( + "You can hold down Shift during the start of the Lighting stage to invert this " + "configuration on the fly." + ) + add_tooltip(UI['light_fast'], gettext( + "Compile with lower-quality, fast lighting. This speeds up compile " + "times, but does not appear as good. Some shadows may appear " + "wrong.\nWhen publishing, this is ignored." + ) + "\n\n" + light_conf_swap) + add_tooltip(UI['light_full'], gettext( + "Compile with high-quality lighting. This looks correct, but takes " + "longer to compute. Use if you're arranging lights.\nWhen " + "publishing, this is always used." + ) + "\n\n" + light_conf_swap) packfile_enable = ttk.Checkbutton( frame, - text=_('Dump packed files to:'), + text=gettext('Dump packed files to:'), variable=packfile_dump_enable, command=set_pack_dump_enabled, ) @@ -666,15 +672,14 @@ def make_comp_widgets(frame: ttk.Frame): set_pack_dump_enabled() - add_tooltip( - packfile_enable, - _("When compiling, dump all files which were packed into the map. Useful" - " if you're intending to edit maps in Hammer.") - ) + add_tooltip(packfile_enable, gettext( + "When compiling, dump all files which were packed into the map. " + "Useful if you're intending to edit maps in Hammer." + )) count_frame = ttk.LabelFrame( frame, - text=_('Last Compile:'), + text=gettext('Last Compile:'), labelanchor='n', ) @@ -684,7 +689,7 @@ def make_comp_widgets(frame: ttk.Frame): ttk.Label( count_frame, - text=_('Entity'), + text=gettext('Entity'), anchor='n', ).grid(row=0, column=0, columnspan=3, sticky='ew') @@ -704,7 +709,7 @@ def make_comp_widgets(frame: ttk.Frame): ttk.Label( count_frame, - text=_('Overlay'), + text=gettext('Overlay'), anchor='center', ).grid(row=2, column=0, sticky='ew') UI['count_overlay'] = ttk.Progressbar( @@ -721,15 +726,14 @@ def make_comp_widgets(frame: ttk.Frame): refresh_counts, ) UI['refresh_counts'].grid(row=3, column=1) - add_tooltip( - UI['refresh_counts'], - _("Refresh the compile progress bars. Press after a compile has been " - "performed to show the new values."), - ) + add_tooltip(UI['refresh_counts'],gettext( + "Refresh the compile progress bars. Press after a compile has been " + "performed to show the new values." + )) ttk.Label( count_frame, - text=_('Brush'), + text=gettext('Brush'), anchor='center', ).grid(row=2, column=2, sticky=tk.EW) UI['count_brush'] = ttk.Progressbar( @@ -757,27 +761,26 @@ def make_map_widgets(frame: ttk.Frame): voice_frame = ttk.LabelFrame( frame, - text=_('Voicelines:'), + text=gettext('Voicelines:'), labelanchor='nw', ) voice_frame.grid(row=1, column=0, sticky='ew') voice_priority = ttk.Checkbutton( voice_frame, - text=_("Use voiceline priorities"), + text=gettext("Use voiceline priorities"), variable=VOICE_PRIORITY_VAR, ) voice_priority.grid(row=0, column=0) - add_tooltip( - voice_priority, - _("Only choose the highest-priority voicelines. This means more " - "generic lines will can only be chosen if few test elements are in " - "the map. If disabled any applicable lines will be used."), - ) + add_tooltip(voice_priority, gettext( + "Only choose the highest-priority voicelines. This means more generic " + "lines will can only be chosen if few test elements are in the map. " + "If disabled any applicable lines will be used." + )) elev_frame = ttk.LabelFrame( frame, - text=_('Spawn at:'), + text=gettext('Spawn at:'), labelanchor='n', ) @@ -787,13 +790,13 @@ def make_map_widgets(frame: ttk.Frame): elev_preview = ttk.Radiobutton( elev_frame, - text=_('Entry Door'), + text=gettext('Entry Door'), value=0, variable=start_in_elev, ) elev_elevator = ttk.Radiobutton( elev_frame, - text=_('Elevator'), + text=gettext('Elevator'), value=1, variable=start_in_elev, ) @@ -801,28 +804,30 @@ def make_map_widgets(frame: ttk.Frame): elev_preview.grid(row=0, column=0, sticky='w') elev_elevator.grid(row=0, column=1, sticky='w') - add_tooltip( - elev_elevator, - _("When previewing in SP, spawn inside the entry elevator. " - "Use this to examine the entry and exit corridors.") - ) - add_tooltip( - elev_preview, - _("When previewing in SP, spawn just before the entry door.") + elev_conf_swap = gettext( + "You can hold down Shift during the start of the Geometry stage to quickly swap which" + "location you spawn at on the fly." ) + add_tooltip(elev_elevator, gettext( + "When previewing in SP, spawn inside the entry elevator. Use this to " + "examine the entry and exit corridors." + ) + "\n\n" + elev_conf_swap) + add_tooltip(elev_preview, gettext( + "When previewing in SP, spawn just before the entry door." + ) + "\n\n" + elev_conf_swap) corr_frame = ttk.LabelFrame( frame, width=18, - text=_('Corridor:'), + text=gettext('Corridor:'), labelanchor='n', ) corr_frame.grid(row=3, column=0, sticky='ew') corr_frame.columnconfigure(1, weight=1) - make_corr_wid('sp_entry', _('Singleplayer Entry Corridor')) # i18n: corridor selector window title. - make_corr_wid('sp_exit', _('Singleplayer Exit Corridor')) # i18n: corridor selector window title. - make_corr_wid('coop', _('Coop Exit Corridor')) # i18n: corridor selector window title. + make_corr_wid('sp_entry', gettext('Singleplayer Entry Corridor')) # i18n: corridor selector window title. + make_corr_wid('sp_exit', gettext('Singleplayer Exit Corridor')) # i18n: corridor selector window title. + make_corr_wid('coop', gettext('Coop Exit Corridor')) # i18n: corridor selector window title. load_corridors() @@ -832,23 +837,23 @@ def make_map_widgets(frame: ttk.Frame): ttk.Label( corr_frame, - text=_('SP Entry:'), + text=gettext('SP Entry:'), anchor='e', ).grid(row=0, column=0, sticky='ew', padx=2) ttk.Label( corr_frame, - text=_('SP Exit:'), + text=gettext('SP Exit:'), anchor='e', ).grid(row=1, column=0, sticky='ew', padx=2) ttk.Label( corr_frame, - text=_('Coop Exit:'), + text=gettext('Coop Exit:'), anchor='e', ).grid(row=2, column=0, sticky='ew', padx=2) model_frame = ttk.LabelFrame( frame, - text=_('Player Model (SP):'), + text=gettext('Player Model (SP):'), labelanchor='n', ) model_frame.grid(row=4, column=0, sticky='ew') @@ -879,7 +884,7 @@ def make_pane(tool_frame: tk.Frame, menu_bar: tk.Menu) -> None: global window window = SubPane.SubPane( TK_ROOT, - title=_('Compile Options'), + title=gettext('Compile Options'), name='compiler', menu_bar=menu_bar, resize_x=True, @@ -897,7 +902,7 @@ def init_application() -> None: """Initialise when standalone.""" global window window = TK_ROOT - window.title(_('Compiler Options - {}').format(utils.BEE_VERSION)) + window.title(gettext('Compiler Options - {}').format(utils.BEE_VERSION)) window.resizable(True, False) make_widgets() diff --git a/src/app/StyleVarPane.py b/src/app/StyleVarPane.py index 798047330..f27236e9f 100644 --- a/src/app/StyleVarPane.py +++ b/src/app/StyleVarPane.py @@ -1,98 +1,106 @@ +"""The Style Properties tab, for configuring style-specific properties.""" +from __future__ import annotations from tkinter import * from tkinter import ttk -from typing import Union, List, Dict, Callable, Optional -from collections import namedtuple +from typing import Callable, Optional import operator +import itertools from srctools import Property from srctools.logger import get_logger -import packages +from packages import Style, StyleVar from app.SubPane import SubPane from app import tooltip, TK_ROOT, itemconfig, tk_tools +from localisation import ngettext, gettext import BEE2_config LOGGER = get_logger(__name__) - -stylevar = namedtuple('stylevar', 'id name default desc') - -# Special StyleVars that are hardcoded into the BEE2 +# Special StyleVars that are hardcoded into the BEE2. # These are effectively attributes of Portal 2 itself, and always work # in every style. styleOptions = [ - # ID, Name, default value - stylevar( + StyleVar.unstyled( id='MultiverseCave', - name=_('Multiverse Cave'), - default=1, - desc=_('Play the Workshop Cave Johnson lines on map start.') + name=gettext('Multiverse Cave'), + default=True, + desc=gettext('Play the Workshop Cave Johnson lines on map start.'), ), - stylevar( + StyleVar.unstyled( id='FixFizzlerBump', - name=_('Prevent Portal Bump (fizzler)'), - default=0, - desc=_('Add portal bumpers to make it more difficult to portal across ' - 'fizzler edges. This can prevent placing portals in tight ' - 'spaces near fizzlers, or fizzle portals on activation.') + name=gettext('Prevent Portal Bump (fizzler)'), + default=False, + desc=gettext( + 'Add portal bumpers to make it more difficult to portal across ' + 'fizzler edges. This can prevent placing portals in tight spaces ' + 'near fizzlers, or fizzle portals on activation.' + ), ), - stylevar( + StyleVar.unstyled( id='NoMidVoices', - name=_('Suppress Mid-Chamber Dialogue'), - default=0, - desc=_('Disable all voicelines other than entry and exit lines.') + name=gettext('Suppress Mid-Chamber Dialogue'), + default=False, + desc=gettext('Disable all voicelines other than entry and exit lines.'), ), - stylevar( + StyleVar.unstyled( id='UnlockDefault', - name=_('Unlock Default Items'), - default=0, - desc=_('Allow placing and deleting the mandatory Entry/Exit Doors and ' - 'Large Observation Room. Use with caution, this can have weird ' - 'results!') + name=gettext('Unlock Default Items'), + default=False, + desc=gettext( + 'Allow placing and deleting the mandatory Entry/Exit Doors and ' + 'Large Observation Room. Use with caution, this can have weird ' + 'results!' + ), ), - stylevar( + StyleVar.unstyled( id='AllowGooMist', - name=_('Allow Adding Goo Mist'), - default=1, - desc=_('Add mist particles above Toxic Goo in certain styles. This can ' - 'increase the entity count significantly with large, complex ' - 'goo pits, so disable if needed.') + name=gettext('Allow Adding Goo Mist'), + default=True, + desc=gettext( + 'Add mist particles above Toxic Goo in certain styles. This can ' + 'increase the entity count significantly with large, complex goo ' + 'pits, so disable if needed.' + ), ), - stylevar( + StyleVar.unstyled( id='FunnelAllowSwitchedLights', - name=_('Light Reversible Excursion Funnels'), - default=1, - desc=_('Funnels emit a small amount of light. However, if multiple funnels ' - 'are near each other and can reverse polarity, this can cause ' - 'lighting issues. Disable this to prevent that by disabling ' - 'lights. Non-reversible Funnels do not have this issue.'), + name=gettext('Light Reversible Excursion Funnels'), + default=True, + desc=gettext( + 'Funnels emit a small amount of light. However, if multiple funnels ' + 'are near each other and can reverse polarity, this can cause ' + 'lighting issues. Disable this to prevent that by disabling ' + 'lights. Non-reversible Funnels do not have this issue.' + ), ), - stylevar( + StyleVar.unstyled( id='EnableShapeSignageFrame', - name=_('Enable Shape Framing'), - default=1, - desc=_('After 10 shape-type antlines are used, the signs repeat. ' - 'With this enabled, colored frames will be added to ' - 'distinguish them.'), + name=gettext('Enable Shape Framing'), + default=True, + desc=gettext( + 'After 10 shape-type antlines are used, the signs repeat. With this' + ' enabled, colored frames will be added to distinguish them.' + ), ), ] -checkbox_all = {} -checkbox_chosen = {} -checkbox_other = {} -tk_vars = {} # type: Dict[str, IntVar] +checkbox_all: dict[str, ttk.Checkbutton] = {} +checkbox_chosen: dict[str, ttk.Checkbutton] = {} +checkbox_other: dict[str, ttk.Checkbutton] = {} +tk_vars: dict[str, IntVar] = {} -VAR_LIST: List[packages.StyleVar] = [] -STYLES = {} +VAR_LIST: list[StyleVar] = [] +STYLES: dict[str, Style] = {} -window = None +window: Optional[SubPane] = None UI = {} # Callback triggered whenever we reload vars. This is used to update items @@ -103,48 +111,43 @@ def mandatory_unlocked() -> bool: """Return whether mandatory items are unlocked currently.""" try: - return tk_vars['UnlockDefault'].get() + return tk_vars['UnlockDefault'].get() != 0 except KeyError: # Not loaded yet return False -def add_vars(style_vars, styles): - """ - Add the given stylevars to our list. +@BEE2_config.OPTION_SAVE('StyleVar') +def save_handler() -> Property: + """Save variables to configs.""" + props = Property('', []) + for var_id, var in sorted(tk_vars.items()): + props[var_id] = str(int(var.get())) + return props - """ - VAR_LIST.clear() - VAR_LIST.extend( - sorted(style_vars, key=operator.attrgetter('id')) - ) - - for var in VAR_LIST: - var.enabled = BEE2_config.GEN_OPTS.get_bool('StyleVar', var.id, var.default) - for style in styles: - STYLES[style.id] = style +@BEE2_config.OPTION_LOAD('StyleVar') +def load_handler(props: Property) -> None: + """Load variables from configs.""" + for prop in props: + try: + tk_vars[prop.real_name].set(prop.value) + except KeyError: + LOGGER.warning('No stylevar "{}", skipping.', prop.real_name) + if _load_cback is not None: + _load_cback() -@BEE2_config.option_handler('StyleVar') -def save_load_stylevars(props: Property=None): - """Save and load variables from configs.""" - if props is None: - props = Property('', []) - for var_id, var in sorted(tk_vars.items()): - props[var_id] = str(int(var.get())) - return props - else: - # Loading - for prop in props: - try: - tk_vars[prop.real_name].set(prop.value) - except KeyError: - LOGGER.warning('No stylevar "{}", skipping.', prop.real_name) - if _load_cback is not None: - _load_cback() +def export_data(chosen_style: Style) -> dict[str, bool]: + """Construct a dict containing the current stylevar settings.""" + return { + var.id: (tk_vars[var.id].get() == 1) + for var in + itertools.chain(VAR_LIST, styleOptions) + if var.applies_to_style(chosen_style) + } -def make_desc(var: Union[packages.StyleVar, stylevar], is_hardcoded=False): +def make_desc(var: StyleVar) -> str: """Generate the description text for a StyleVar. This adds 'Default: on/off', and which styles it's used in. @@ -154,14 +157,12 @@ def make_desc(var: Union[packages.StyleVar, stylevar], is_hardcoded=False): else: desc = [] - desc.append( - _('Default: On') - if var.default else - _('Default: Off') - ) + # i18n: StyleVar default value. + desc.append(gettext('Default: On') if var.default else gettext('Default: Off')) - if is_hardcoded or var.styles is None: - desc.append(_('Styles: Unstyled')) + if var.styles is None: + # i18n: StyleVar which is totally unstyled. + desc.append(gettext('Styles: Unstyled')) else: app_styles = [ style @@ -171,21 +172,23 @@ def make_desc(var: Union[packages.StyleVar, stylevar], is_hardcoded=False): ] if len(app_styles) == len(STYLES): - desc.append(_('Styles: All')) + # i18n: StyleVar which matches all styles. + desc.append(gettext('Styles: All')) else: style_list = sorted( style.selitem_data.short_name for style in app_styles ) - desc.append( - ngettext('Style: {}', 'Styles: {}', len(style_list) + desc.append(ngettext( + # i18n: The styles a StyleVar is allowed for. + 'Style: {}', 'Styles: {}', len(style_list), ).format(', '.join(style_list))) return '\n'.join(desc) -def refresh(selected_style): +def refresh(selected_style: Style) -> None: """Move the stylevars to the correct position. This depends on which apply to the current style. @@ -222,7 +225,7 @@ def refresh(selected_style): UI['stylevar_other_none'].grid_remove() -def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], None]): +def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], None]) -> None: """Create the styleVar pane. update_item_vis is the callback fired whenever change defaults changes. @@ -232,7 +235,7 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N window = SubPane( TK_ROOT, - title=_('Style/Item Properties'), + title=gettext('Style/Item Properties'), name='style', menu_bar=menu_bar, resize_y=True, @@ -251,7 +254,7 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N stylevar_frame = ttk.Frame(nbook) stylevar_frame.rowconfigure(0, weight=1) stylevar_frame.columnconfigure(0, weight=1) - nbook.add(stylevar_frame, text=_('Styles')) + nbook.add(stylevar_frame, text=gettext('Styles')) canvas = Canvas(stylevar_frame, highlightthickness=0) # need to use a canvas to allow scrolling @@ -270,10 +273,10 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N canvas_frame = ttk.Frame(canvas) - frame_all = ttk.Labelframe(canvas_frame, text=_("All:")) + frame_all = ttk.Labelframe(canvas_frame, text=gettext("All:")) frame_all.grid(row=0, sticky='EW') - frm_chosen = ttk.Labelframe(canvas_frame, text=_("Selected Style:")) + frm_chosen = ttk.Labelframe(canvas_frame, text=gettext("Selected Style:")) frm_chosen.grid(row=1, sticky='EW') ttk.Separator( @@ -281,22 +284,24 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N orient=HORIZONTAL, ).grid(row=2, sticky='EW', pady=(10, 5)) - frm_other = ttk.Labelframe(canvas_frame, text=_("Other Styles:")) + frm_other = ttk.Labelframe(canvas_frame, text=gettext("Other Styles:")) frm_other.grid(row=3, sticky='EW') UI['stylevar_chosen_none'] = ttk.Label( frm_chosen, - text=_('No Options!'), + text=gettext('No Options!'), font='TkMenuFont', justify='center', ) UI['stylevar_other_none'] = ttk.Label( frm_other, - text=_('None!'), + text=gettext('None!'), font='TkMenuFont', justify='center', ) + VAR_LIST[:] = sorted(StyleVar.all(), key=operator.attrgetter('id')) + all_pos = 0 for all_pos, var in enumerate(styleOptions): # Add the special stylevars which apply to all styles @@ -313,17 +318,14 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N if var.id == 'UnlockDefault': checkbox_all[var.id]['command'] = lambda: update_item_vis() - tooltip.add_tooltip( - checkbox_all[var.id], - make_desc(var, is_hardcoded=True), - ) + tooltip.add_tooltip(checkbox_all[var.id], make_desc(var)) for var in VAR_LIST: tk_vars[var.id] = IntVar(value=var.enabled) args = { 'variable': tk_vars[var.id], 'text': var.name, - } + } desc = make_desc(var) if var.applies_to_all(): # Available in all styles - put with the hardcoded variables. @@ -336,14 +338,9 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N # Swap between checkboxes depending on style. checkbox_chosen[var.id] = ttk.Checkbutton(frm_chosen, **args) checkbox_other[var.id] = ttk.Checkbutton(frm_other, **args) - tooltip.add_tooltip( - checkbox_chosen[var.id], - desc, - ) - tooltip.add_tooltip( - checkbox_other[var.id], - desc, - ) + + tooltip.add_tooltip(checkbox_chosen[var.id], desc) + tooltip.add_tooltip(checkbox_other[var.id], desc) canvas.create_window(0, 0, window=canvas_frame, anchor="nw") canvas.update_idletasks() @@ -361,5 +358,5 @@ def make_pane(tool_frame: Frame, menu_bar: Menu, update_item_vis: Callable[[], N canvas.bind('', lambda e: canvas.configure(scrollregion=canvas.bbox(ALL))) item_config_frame = ttk.Frame(nbook) - nbook.add(item_config_frame, text=_('Items')) + nbook.add(item_config_frame, text=gettext('Items')) itemconfig.make_pane(item_config_frame) diff --git a/src/app/SubPane.py b/src/app/SubPane.py index 1003dfad1..3ead9ec02 100644 --- a/src/app/SubPane.py +++ b/src/app/SubPane.py @@ -7,6 +7,7 @@ from app import tooltip from app import tk_tools from app.img import Handle as ImgHandle, apply as apply_img +from localisation import gettext import utils import srctools from app import sound @@ -83,7 +84,7 @@ def __init__( ) tooltip.add_tooltip( self.tool_button, - text=_('Hide/Show the "{}" window.').format(title)) + text=gettext('Hide/Show the "{}" window.').format(title)) menu_bar.add_checkbutton( label=title, variable=self.visible, @@ -96,7 +97,7 @@ def __init__( tk_tools.set_window_icon(self) self.protocol("WM_DELETE_WINDOW", self.hide_win) - parent.bind('', self.follow_main, add='+') + parent.bind('', self.follow_main, add=True) self.bind('', self.snap_win) self.bind('', self.enable_snap) diff --git a/src/app/UI.py b/src/app/UI.py index e550c3a33..16e6e9339 100644 --- a/src/app/UI.py +++ b/src/app/UI.py @@ -1,27 +1,32 @@ """Main UI module, brings everything together.""" -from tkinter import * # ui library +import tkinter as tk from tkinter import ttk # themed ui components that match the OS from tkinter import messagebox # simple, standard modal dialogs +from typing import List, Dict, Tuple, Optional, Set, Iterator, Callable, Any import itertools import operator import random +import functools import math from srctools import Property -from app import music_conf, TK_ROOT +import srctools.logger +import trio + +import loadScreen +from app import TK_ROOT from app.itemPropWin import PROP_TYPES from BEE2_config import ConfigFile, GEN_OPTS -from app.selector_win import selWin, Item as selWinItem, AttrDef as SelAttr +from app.selector_win import SelectorWin, Item as selWinItem, AttrDef as SelAttr from loadScreen import main_loader as loader -import srctools.logger from app import sound as snd import BEE2_config -from app import paletteLoader import packages from app import img from app import itemconfig import utils import consts +from localisation import gettext from app import ( tk_tools, SubPane, @@ -37,10 +42,10 @@ backup as backup_win, tooltip, signage_ui, + paletteUI, + music_conf, ) -from typing import List, Dict, Tuple, Optional, Set, Iterator - LOGGER = srctools.logger.get_logger(__name__) @@ -50,14 +55,20 @@ UI = {} menus = {} +# These panes. +skybox_win: SelectorWin +voice_win: SelectorWin +style_win: SelectorWin +elev_win: SelectorWin + # Items chosen for the palette. -pal_picked = [] # type: List[PalItem] +pal_picked: List['PalItem'] = [] # Array of the "all items" icons -pal_items = [] # type: List[PalItem] +pal_items: List['PalItem'] = [] # Labels used for the empty palette positions -pal_picked_fake = [] # type: List[ttk.Label] +pal_picked_fake: List[ttk.Label] = [] # Labels for empty picker positions -pal_items_fake = [] # type: List[ttk.Label] +pal_items_fake: List[ttk.Label] = [] # The current filtering state. cur_filter: Optional[Set[Tuple[str, int]]] = None @@ -70,25 +81,16 @@ IMG_BLANK = img.Handle.color(img.PETI_ITEM_BG, 64, 64) selected_style = "BEE2_CLEAN" -selectedPalette = 0 -# fake value the menu radio buttons set -selectedPalette_radio = IntVar(value=0) # Variable used for export button (changes to include game name) -EXPORT_CMD_VAR = StringVar(value=_('Export...')) -# If set, save settings into the palette in addition to items. -var_pal_save_settings = BooleanVar(value=True) +EXPORT_CMD_VAR = tk.StringVar(value=gettext('Export...')) # Maps item IDs to our wrapper for the object. -item_list = {} # type: Dict[str, Item] +item_list: Dict[str, 'Item'] = {} item_opts = ConfigFile('item_configs.cfg') # A config file which remembers changed property options, chosen # versions, etc -# "Wheel of Dharma" / white sun, close enough and should be -# in most fonts. -CHR_GEAR = '☼ ' - class Item: """Represents an item that can appear on the list.""" @@ -104,7 +106,6 @@ class Item: 'pak_id', 'pak_name', 'names', - 'url', ] def __init__(self, item: packages.Item) -> None: @@ -121,33 +122,24 @@ def __init__(self, item: packages.Item) -> None: self.selected_ver = item.def_ver.id self.item = item - self.def_data = self.item.def_ver.def_style + self.def_data = item.def_ver.def_style # The indexes of subtypes that are actually visible. self.visual_subtypes = [ ind for ind, sub in enumerate(self.def_data.editor.subtypes) if sub.pal_name or sub.pal_icon ] - if not self.visual_subtypes: - # We need at least one subtype, otherwise something's wrong - # with the file. - raise Exception('Item {} has no visible subtypes!'.format(item.id)) self.authors = self.def_data.authors self.id = item.id self.pak_id = item.pak_id self.pak_name = item.pak_name - self.load_data() + self.data = item.versions[self.selected_ver].styles.get(selected_style, self.def_data) def load_data(self) -> None: - """Load data from the item.""" - version = self.item.versions[self.selected_ver] - self.data = version.styles.get( - selected_style, - self.def_data, - ) - self.url = self.data.url + """Reload data from the item.""" + self.data = self.item.versions[self.selected_ver].styles.get(selected_style, self.def_data) def get_tags(self, subtype: int) -> Iterator[str]: """Return all the search keywords for this item/subtype.""" @@ -207,16 +199,16 @@ def get_icon(self, subKey, allow_single=False, single_num=1) -> img.Handle: return img.Handle.error(64, 64) return img.Handle.file(utils.PackagePath( - self.pak_id, str(subtype.pal_icon) + self.data.pak_id, str(subtype.pal_icon) ), 64, 64) - def properties(self): + def properties(self) -> Iterator[str]: """Iterate through all properties for this item.""" for prop_name, prop in self.data.editor.properties.items(): if prop.allow_user_default: yield prop_name - def get_properties(self): + def get_properties(self) -> Dict[str, Any]: """Return a dictionary of properties and the current value for them. """ @@ -240,15 +232,13 @@ def get_properties(self): ) return result - def set_properties(self, props): + def set_properties(self, props: Dict[str, Any]) -> None: """Apply the properties to the item.""" for prop, value in props.items(): item_opts[self.id]['PROP_' + prop] = str(value) - def refresh_subitems(self): - """Call load_data() on all our subitems, so they reload icons and names. - - """ + def refresh_subitems(self) -> None: + """Call load_data() on all our subitems, so they reload icons and names.""" for refresh_cmd, subitem_list in [ (flow_preview, pal_picked), (flow_picker, pal_items), @@ -258,7 +248,7 @@ def refresh_subitems(self): item.load_data() refresh_cmd() - def change_version(self, version): + def change_version(self, version: str) -> None: item_opts[self.id]['sel_version'] = version self.selected_ver = version self.load_data() @@ -283,26 +273,31 @@ def get_version_names(self): ] -class PalItem(Label): +class PalItem: """The icon and associated data for a single subitem.""" - def __init__(self, frame, item: Item, sub: int, is_pre): + def __init__(self, frame, item: Item, sub: int, is_pre: bool) -> None: """Create a label to show an item onscreen.""" - super().__init__(frame) self.item = item self.subKey = sub self.id = item.id + # Cached translated palette name. + self.name = '??' # Used to distinguish between picker and palette items self.is_pre = is_pre self.needs_unlock = item.item.needs_unlock - self.load_data() - self.bind(tk_tools.EVENTS['LEFT'], drag_start) - self.bind(tk_tools.EVENTS['LEFT_SHIFT'], drag_fast) - self.bind("", self.rollover) - self.bind("", self.rollout) + # Location this item was present at previously when dragging it. + self.pre_x = self.pre_y = -1 + + self.label = lbl = tk.Label(frame) + + lbl.bind(tk_tools.EVENTS['LEFT'], functools.partial(drag_start, self)) + lbl.bind(tk_tools.EVENTS['LEFT_SHIFT'], functools.partial(drag_fast, self)) + lbl.bind("", self.rollover) + lbl.bind("", self.rollout) - self.info_btn = Label( - self, + self.info_btn = tk.Label( + lbl, relief='ridge', width=12, height=12, @@ -310,7 +305,7 @@ def __init__(self, frame, item: Item, sub: int, is_pre): img.apply(self.info_btn, ICO_GEAR) click_func = contextWin.open_event(self) - tk_tools.bind_rightclick(self, click_func) + tk_tools.bind_rightclick(lbl, click_func) @tk_tools.bind_leftclick(self.info_btn) def info_button_click(e): @@ -322,25 +317,25 @@ def info_button_click(e): # Rightclick does the same as the icon. tk_tools.bind_rightclick(self.info_btn, click_func) - def rollover(self, _): + def rollover(self, _: tk.Event) -> None: """Show the name of a subitem and info button when moused over.""" set_disp_name(self) - self.lift() - self['relief'] = 'ridge' + self.label.lift() + self.label['relief'] = 'ridge' padding = 2 if utils.WIN else 0 self.info_btn.place( - x=self.winfo_width() - padding, - y=self.winfo_height() - padding, - anchor=SE, + x=self.label.winfo_width() - padding, + y=self.label.winfo_height() - padding, + anchor='se', ) - def rollout(self, _): + def rollout(self, _: tk.Event) -> None: """Reset the item name display and hide the info button when the mouse leaves.""" clear_disp_name() - self['relief'] = 'flat' + self.label['relief'] = 'flat' self.info_btn.place_forget() - def change_subtype(self, ind): + def change_subtype(self, ind) -> None: """Change the subtype of this icon. This removes duplicates from the palette if needed. @@ -350,10 +345,10 @@ def change_subtype(self, ind): item.kill() self.subKey = ind self.load_data() - self.master.update() # Update the frame + self.label.master.update() # Update the frame flow_preview() - def open_menu_at_sub(self, ind): + def open_menu_at_sub(self, ind: int) -> None: """Make the contextWin open itself at the indicated subitem. """ @@ -372,7 +367,6 @@ def load_data(self) -> None: Call whenever the style changes, so the icons update. """ - self.img = self.item.get_icon(self.subKey, self.is_pre) try: self.name = gameMan.translate(self.item.data.editor.subtypes[self.subKey].name) except IndexError: @@ -380,8 +374,8 @@ def load_data(self) -> None: 'Item <{}> in <{}> style has mismatched subtype count!', self.id, selected_style, ) - self.name = '??' - img.apply(self, self.img) + self.name = '???' + img.apply(self.label, self.item.get_icon(self.subKey, self.is_pre)) def clear(self) -> bool: """Remove any items matching ourselves from the palette. @@ -399,10 +393,11 @@ def clear(self) -> bool: def kill(self) -> None: """Hide and destroy this widget.""" - if self in pal_picked: - pal_picked.remove(self) - self.place_forget() - self.destroy() + for i, item in enumerate(pal_picked): + if item is self: + del pal_picked[i] + break + self.label.place_forget() def on_pal(self) -> bool: """Determine if this item is on the palette.""" @@ -414,15 +409,19 @@ def on_pal(self) -> bool: def copy(self, frame): return PalItem(frame, self.item, self.subKey, self.is_pre) - def __repr__(self): - return '<' + str(self.id) + ":" + str(self.subKey) + '>' + def __repr__(self) -> str: + return f'<{self.id}:{self.subKey}>' def quit_application() -> None: """Do a last-minute save of our config files, and quit the app.""" import sys, logging - + from app import BEE2 LOGGER.info('Shutting down application.') + try: + BEE2.APP_NURSERY.cancel_scope.cancel() + except AttributeError: + pass # If our window isn't actually visible, this is set to nonsense - # ignore those values. @@ -430,15 +429,27 @@ def quit_application() -> None: GEN_OPTS['win_state']['main_window_x'] = str(TK_ROOT.winfo_rootx()) GEN_OPTS['win_state']['main_window_y'] = str(TK_ROOT.winfo_rooty()) - BEE2_config.write_settings() - GEN_OPTS.save_check() + try: + BEE2_config.write_settings() + except Exception: + pass + try: + GEN_OPTS.save_check() + except Exception: + pass item_opts.save_check() CompilerPane.COMPILE_CFG.save_check() - gameMan.save() + try: + gameMan.save() + except Exception: + pass + # Clean this out. + snd.clean_sample_folder() # Destroy the TK windows, finalise logging, then quit. logging.shutdown() TK_ROOT.quit() + loadScreen.shutdown() sys.exit(0) gameMan.quit_application = quit_application @@ -446,19 +457,12 @@ def quit_application() -> None: def load_settings(): """Load options from the general config file.""" - global selectedPalette - try: - selectedPalette = GEN_OPTS.get_int('Last_Selected', 'palette') - except (KeyError, ValueError): - pass # It'll be set to the first palette by default, and then saved - selectedPalette_radio.set(selectedPalette) - optionWindow.load() -@BEE2_config.option_handler('LastSelected') -def save_load_selector_win(props: Property=None): - """Save and load options on the selector window.""" +@BEE2_config.OPTION_SAVE('LastSelected') +def save_last_selected() -> Property: + """Save the last selected objects.""" sel_win = [ ('Style', style_win), ('Skybox', skybox_win), @@ -468,14 +472,24 @@ def save_load_selector_win(props: Property=None): for channel, win in music_conf.WINDOWS.items(): sel_win.append(('Music_' + channel.name.title(), win)) - # Saving - if props is None: - props = Property('', []) - for win_name, win in sel_win: - props.append(Property(win_name, win.chosen_id or '')) - return props + props = Property('', []) + for win_name, win in sel_win: + props.append(Property(win_name, win.chosen_id or '')) + return props + + +@BEE2_config.OPTION_LOAD('LastSelected') +def load_last_selected(props: Property) -> None: + """Load the last selected objects.""" + sel_win = [ + ('Style', style_win), + ('Skybox', skybox_win), + ('Voice', voice_win), + ('Elevator', elev_win), + ] + for channel, win in music_conf.WINDOWS.items(): + sel_win.append(('Music_' + channel.name.title(), win)) - # Loading for win_name, win in sel_win: try: win.sel_item_id(props[win_name]) @@ -495,12 +509,10 @@ def load_packages() -> None: for item in packages.Item.all(): item_list[item.id] = Item(item) - StyleVarPane.add_vars(packages.StyleVar.all(), packages.Style.all()) - - sky_list = [] # type: List[selWinItem] - voice_list = [] # type: List[selWinItem] - style_list = [] # type: List[selWinItem] - elev_list = [] # type: List[selWinItem] + sky_list: list[selWinItem] = [] + voice_list: list[selWinItem] = [] + style_list: list[selWinItem] = [] + elev_list: list[selWinItem] = [] # These don't need special-casing, and act the same. # The attrs are a map from selectorWin attributes, to the attribute on @@ -570,52 +582,58 @@ def voice_callback(voice_id): pass suggested_refresh() - skybox_win = selWin( + skybox_win = SelectorWin( TK_ROOT, sky_list, - title=_('Select Skyboxes'), - desc=_('The skybox decides what the area outside the chamber is like.' - ' It chooses the colour of sky (seen in some items), the style' - ' of bottomless pit (if present), as well as color of "fog" ' - '(seen in larger chambers).'), + save_id='skyboxes', + title=gettext('Select Skyboxes'), + desc=gettext( + 'The skybox decides what the area outside the chamber is like. It chooses the colour ' + 'of sky (seen in some items), the style of bottomless pit (if present), as well as ' + 'color of "fog" (seen in larger chambers).' + ), has_none=False, callback=win_callback, callback_params=['Skybox'], attributes=[ - SelAttr.bool('3D', _('3D Skybox'), False), - SelAttr.color('COLOR', _('Fog Color')), + SelAttr.bool('3D', gettext('3D Skybox'), False), + SelAttr.color('COLOR', gettext('Fog Color')), ], ) - voice_win = selWin( + voice_win = SelectorWin( TK_ROOT, voice_list, - title=_('Select Additional Voice Lines'), - desc=_('Voice lines choose which extra voices play as the player enters' - ' or exits a chamber. They are chosen based on which items are' - ' present in the map. The additional "Multiverse" Cave lines' - ' are controlled separately in Style Properties.'), + save_id='voicelines', + title=gettext('Select Additional Voice Lines'), + desc=gettext( + 'Voice lines choose which extra voices play as the player enters or exits a chamber. ' + 'They are chosen based on which items are present in the map. The additional ' + '"Multiverse" Cave lines are controlled separately in Style Properties.' + ), has_none=True, - none_desc=_('Add no extra voice lines, only Multiverse Cave if enabled.'), + none_desc=gettext('Add no extra voice lines, only Multiverse Cave if enabled.'), none_attrs={ - 'CHAR': [_('')], + 'CHAR': [gettext('')], }, callback=voice_callback, attributes=[ - SelAttr.list('CHAR', _('Characters'), ['??']), - SelAttr.bool('TURRET', _('Turret Shoot Monitor'), False), - SelAttr.bool('MONITOR', _('Monitor Visuals'), False), + SelAttr.list('CHAR', gettext('Characters'), ['??']), + SelAttr.bool('TURRET', gettext('Turret Shoot Monitor'), False), + SelAttr.bool('MONITOR', gettext('Monitor Visuals'), False), ], ) - style_win = selWin( + style_win = SelectorWin( TK_ROOT, style_list, - title=_('Select Style'), - desc=_('The Style controls many aspects of the map. It decides the ' - 'materials used for walls, the appearance of entrances and ' - 'exits, the design for most items as well as other settings.\n\n' - 'The style broadly defines the time period a chamber is set in.'), + save_id='styles', + title=gettext('Select Style'), + desc=gettext( + 'The Style controls many aspects of the map. It decides the materials used for walls, ' + 'the appearance of entrances and exits, the design for most items as well as other ' + 'settings.\n\nThe style broadly defines the time period a chamber is set in.' + ), has_none=False, has_def=False, # Selecting items changes much of the gui - don't allow when other @@ -623,28 +641,30 @@ def voice_callback(voice_id): modal=True, # callback set in the main initialisation function.. attributes=[ - SelAttr.bool('VID', _('Elevator Videos'), default=True), + SelAttr.bool('VID', gettext('Elevator Videos'), default=True), ] ) - elev_win = selWin( + elev_win = SelectorWin( TK_ROOT, elev_list, - title=_('Select Elevator Video'), - desc=_('Set the video played on the video screens in modern Aperture ' - 'elevator rooms. Not all styles feature these. If set to ' - '"None", a random video will be selected each time the map is ' - 'played, like in the default PeTI.'), - readonly_desc=_('This style does not have a elevator video screen.'), + save_id='elevators', + title=gettext('Select Elevator Video'), + desc=gettext( + 'Set the video played on the video screens in modern Aperture elevator rooms. Not all ' + 'styles feature these. If set to "None", a random video will be selected each time the ' + 'map is played, like in the default PeTI.' + ), + readonly_desc=gettext('This style does not have a elevator video screen.'), has_none=True, has_def=True, none_icon=img.Handle.builtin('BEE2/random', 96, 96), - none_name=_('Random'), - none_desc=_('Choose a random video.'), + none_name=gettext('Random'), + none_desc=gettext('Choose a random video.'), callback=win_callback, callback_params=['Elevator'], attributes=[ - SelAttr.bool('ORIENT', _('Multiple Orientations')), + SelAttr.bool('ORIENT', gettext('Multiple Orientations')), ] ) @@ -667,13 +687,13 @@ def current_style() -> packages.Style: def reposition_panes() -> None: """Position all the panes in the default places around the main window.""" comp_win = CompilerPane.window - style_win = StyleVarPane.window + stylevar_win = StyleVarPane.window opt_win = windows['opt'] pal_win = windows['pal'] # The x-pos of the right side of the main window xpos = min( TK_ROOT.winfo_screenwidth() - - style_win.winfo_reqwidth(), + - stylevar_win.winfo_reqwidth(), TK_ROOT.winfo_rootx() + TK_ROOT.winfo_reqwidth() @@ -699,13 +719,14 @@ def reposition_panes() -> None: opt_win.move( x=xpos, y=TK_ROOT.winfo_rooty()-40, - width=style_win.winfo_reqwidth()) - style_win.move( + width=stylevar_win.winfo_reqwidth()) + stylevar_win.move( x=xpos, y=TK_ROOT.winfo_rooty() + opt_win.winfo_reqheight() + 25) -def reset_panes(): +def reset_panes() -> None: + """Reset the position of all panes.""" reposition_panes() windows['pal'].save_conf() windows['opt'].save_conf() @@ -713,7 +734,7 @@ def reset_panes(): CompilerPane.window.save_conf() -def suggested_refresh(): +def suggested_refresh() -> None: """Enable or disable the suggestion setting button.""" if 'suggested_style' in UI: windows = [ @@ -728,199 +749,126 @@ def suggested_refresh(): UI['suggested_style'].state(['!disabled']) -def refresh_pal_ui() -> None: - """Update the UI to show the correct palettes.""" - global selectedPalette - cur_palette = paletteLoader.pal_list[selectedPalette] - paletteLoader.pal_list.sort(key=str) # sort by name - selectedPalette = paletteLoader.pal_list.index(cur_palette) - - listbox = UI['palette'] # type: Listbox - listbox.delete(0, END) - - for i, pal in enumerate(paletteLoader.pal_list): - if pal.settings is not None: - listbox.insert(i, CHR_GEAR + pal.name) - else: - listbox.insert(i, pal.name) - - if pal.prevent_overwrite: - listbox.itemconfig( - i, - foreground='grey', - background=tk_tools.LISTBOX_BG_COLOR, - selectbackground=tk_tools.LISTBOX_BG_SEL_COLOR, - ) - else: - listbox.itemconfig( - i, - foreground='black', - background=tk_tools.LISTBOX_BG_COLOR, - selectbackground=tk_tools.LISTBOX_BG_SEL_COLOR, - ) - - if len(paletteLoader.pal_list) < 2 or cur_palette.prevent_overwrite: - UI['pal_remove'].state(('disabled',)) - UI['pal_save'].state(('disabled', )) # Save As only. - menus['pal'].entryconfigure(menus['pal_delete_ind'], state=DISABLED) - menus['pal'].entryconfigure(menus['pal_save_ind'], state=DISABLED) - else: - UI['pal_remove'].state(('!disabled',)) - UI['pal_save'].state(('!disabled', )) - menus['pal'].entryconfigure(menus['pal_delete_ind'], state=NORMAL) - menus['pal'].entryconfigure(menus['pal_save_ind'], state=NORMAL) - - for ind in range(menus['pal'].index(END), 0, -1): - # Delete all the old radiobuttons - # Iterate backward to ensure indexes stay the same. - if menus['pal'].type(ind) == RADIOBUTTON: - menus['pal'].delete(ind) - # Add a set of options to pick the palette into the menu system - for val, pal in enumerate(paletteLoader.pal_list): - menus['pal'].add_radiobutton( - label=( - pal.name if pal.settings is None - else CHR_GEAR + pal.name - ), - variable=selectedPalette_radio, - value=val, - command=set_pal_radio, - ) - selectedPalette_radio.set(selectedPalette) - - -def export_editoritems(e=None): +def export_editoritems(pal_ui: paletteUI.PaletteUI) -> None: """Export the selected Items and Style into the chosen game.""" + # Disable, so you can't double-export. + UI['pal_export'].state(('disabled',)) + menus['file'].entryconfigure(menus['file'].export_btn_index, state='disabled') + TK_ROOT.update_idletasks() + try: + # Convert IntVar to boolean, and only export values in the selected style + chosen_style = current_style() - # Convert IntVar to boolean, and only export values in the selected style - style_vals = StyleVarPane.tk_vars - chosen_style = current_style() - style_vars = { - var.id: (style_vals[var.id].get() == 1) - for var in - StyleVarPane.VAR_LIST - if var.applies_to_style(chosen_style) - } - - # Add all of the special/hardcoded style vars - for var in StyleVarPane.styleOptions: - style_vars[var.id] = style_vals[var.id].get() == 1 - - # The chosen items on the palette - pal_data = [(it.id, it.subKey) for it in pal_picked] - - item_versions = { - it_id: item.selected_ver - for it_id, item in - item_list.items() - } + # The chosen items on the palette + pal_data = [(it.id, it.subKey) for it in pal_picked] - item_properties = { - it_id: { - key[5:]: value - for key, value in - section.items() if - key.startswith('prop_') + item_versions = { + it_id: item.selected_ver + for it_id, item in + item_list.items() } - for it_id, section in - item_opts.items() - } - - success, vpk_success = gameMan.selected_game.export( - style=chosen_style, - selected_objects={ - # Specify the 'chosen item' for each object type - packages.Music: music_conf.export_data(), - packages.Skybox: skybox_win.chosen_id, - packages.QuotePack: voice_win.chosen_id, - packages.Elevator: elev_win.chosen_id, - - packages.Item: (pal_data, item_versions, item_properties), - packages.StyleVar: style_vars, - packages.Signage: signage_ui.export_data(), - # The others don't have one, so it defaults to None. - }, - should_refresh=not GEN_OPTS.get_bool( - 'General', - 'preserve_BEE2_resource_dir', - False, - ) - ) - - if not success: - return - - export_filename = 'LAST_EXPORT' + paletteLoader.PAL_EXT - - for pal in paletteLoader.pal_list[:]: - if pal.filename == export_filename: - paletteLoader.pal_list.remove(pal) - - new_pal = paletteLoader.Palette( - '??', - pal_data, - # This makes it lookup the translated name - # instead of using a configured one. - trans_name='LAST_EXPORT', - # Use a specific filename - this replaces existing files. - filename=export_filename, - # And prevent overwrite - prevent_overwrite=True, - ) - paletteLoader.pal_list.append(new_pal) - new_pal.save(ignore_readonly=True) - - # Save the configs since we're writing to disk lots anyway. - GEN_OPTS.save_check() - item_opts.save_check() - BEE2_config.write_settings() + item_properties = { + it_id: { + key[5:]: value + for key, value in + section.items() if + key.startswith('prop_') + } + for it_id, section in + item_opts.items() + } - message = _('Selected Items and Style successfully exported!') - if not vpk_success: - message += _( - '\n\nWarning: VPK files were not exported, quit Portal 2 and ' - 'Hammer to ensure editor wall previews are changed.' + success, vpk_success = gameMan.selected_game.export( + style=chosen_style, + selected_objects={ + # Specify the 'chosen item' for each object type + packages.Music: music_conf.export_data(), + packages.Skybox: skybox_win.chosen_id, + packages.QuotePack: voice_win.chosen_id, + packages.Elevator: elev_win.chosen_id, + + packages.Item: (pal_data, item_versions, item_properties), + packages.StyleVar: StyleVarPane.export_data(chosen_style), + packages.Signage: signage_ui.export_data(), + + # The others don't have one, so it defaults to None. + }, + should_refresh=not GEN_OPTS.get_bool( + 'General', + 'preserve_BEE2_resource_dir', + False, + ) ) - chosen_action = optionWindow.AfterExport(optionWindow.AFTER_EXPORT_ACTION.get()) - want_launch = optionWindow.LAUNCH_AFTER_EXPORT.get() - - if want_launch or chosen_action is not optionWindow.AfterExport.NORMAL: - do_action = messagebox.askyesno( - 'BEEMOD2', - message + optionWindow.AFTER_EXPORT_TEXT[chosen_action, want_launch], - parent=TK_ROOT, - ) - else: # No action to do, so just show an OK. - messagebox.showinfo('BEEMOD2', message, parent=TK_ROOT) - do_action = False + if not success: + return - # Do the desired action - if quit, we don't bother to update UI. - if do_action: - # Launch first so quitting doesn't affect this. - if want_launch: - gameMan.selected_game.launch() + try: + last_export = pal_ui.palettes[paletteUI.UUID_EXPORT] + except KeyError: + last_export = pal_ui.palettes[paletteUI.UUID_EXPORT] = paletteUI.Palette( + '', + pal_data, + # This makes it lookup the translated name + # instead of using a configured one. + trans_name='LAST_EXPORT', + uuid=paletteUI.UUID_EXPORT, + readonly=True, + ) + last_export.pos = pal_data + last_export.save(ignore_readonly=True) + + # Save the configs since we're writing to disk lots anyway. + GEN_OPTS.save_check() + item_opts.save_check() + BEE2_config.write_settings() + + message = gettext('Selected Items and Style successfully exported!') + if not vpk_success: + message += gettext( + '\n\nWarning: VPK files were not exported, quit Portal 2 and ' + 'Hammer to ensure editor wall previews are changed.' + ) - if chosen_action is optionWindow.AfterExport.NORMAL: - pass - elif chosen_action is optionWindow.AfterExport.MINIMISE: - TK_ROOT.iconify() - elif chosen_action is optionWindow.AfterExport.QUIT: - quit_application() - # We never return from this. - else: - raise ValueError('Unknown action "{}"'.format(chosen_action)) + chosen_action = optionWindow.AfterExport(optionWindow.AFTER_EXPORT_ACTION.get()) + want_launch = optionWindow.LAUNCH_AFTER_EXPORT.get() - # Select the last_export palette, so reloading loads this item selection. - paletteLoader.pal_list.sort(key=str) - selectedPalette_radio.set(paletteLoader.pal_list.index(new_pal)) - set_pal_radio() + if want_launch or chosen_action is not optionWindow.AfterExport.NORMAL: + do_action = messagebox.askyesno( + 'BEEMOD2', + message + optionWindow.AFTER_EXPORT_TEXT[chosen_action, want_launch], + parent=TK_ROOT, + ) + else: # No action to do, so just show an OK. + messagebox.showinfo('BEEMOD2', message, parent=TK_ROOT) + do_action = False + + # Do the desired action - if quit, we don't bother to update UI. + if do_action: + # Launch first so quitting doesn't affect this. + if want_launch: + gameMan.selected_game.launch() + + if chosen_action is optionWindow.AfterExport.NORMAL: + pass + elif chosen_action is optionWindow.AfterExport.MINIMISE: + TK_ROOT.iconify() + elif chosen_action is optionWindow.AfterExport.QUIT: + quit_application() + # We never return from this. + else: + raise ValueError('Unknown action "{}"'.format(chosen_action)) - # Re-set this, so we clear the '*' on buttons if extracting cache. - set_game(gameMan.selected_game) + # Select the last_export palette, so reloading loads this item selection. + pal_ui.select_palette(paletteUI.UUID_EXPORT) + pal_ui.update_state() - refresh_pal_ui() + # Re-set this, so we clear the '*' on buttons if extracting cache. + set_game(gameMan.selected_game) + finally: + UI['pal_export'].state(('!disabled',)) + menus['file'].entryconfigure(menus['file'].export_btn_index, state='normal') def set_disp_name(item, e=None) -> None: @@ -939,18 +887,18 @@ def conv_screen_to_grid(x: float, y: float) -> Tuple[int, int]: ) -def drag_start(e: Event) -> None: +def drag_start(drag_item: PalItem, e: tk.Event) -> None: """Start dragging a palette item.""" drag_win = windows['drag_win'] - drag_win.drag_item = e.widget - set_disp_name(drag_win.drag_item) + drag_win.drag_item = drag_item + set_disp_name(drag_item) snd.fx('config') drag_win.passed_over_pal = False - if drag_win.drag_item.is_pre: # is the cursor over the preview pane? - drag_win.drag_item.kill() + if drag_item.is_pre: # is the cursor over the preview pane? + drag_item.kill() UI['pre_moving'].place( - x=drag_win.drag_item.pre_x*65 + 4, - y=drag_win.drag_item.pre_y*65 + 32, + x=drag_item.pre_x*65 + 4, + y=drag_item.pre_y*65 + 32, ) drag_win.from_pal = True @@ -959,19 +907,19 @@ def drag_start(e: Event) -> None: item.load_data() # When dragging off, switch to the single-only icon - img.apply(UI['drag_lbl'], drag_win.drag_item.item.get_icon( - drag_win.drag_item.subKey, + img.apply(UI['drag_lbl'], drag_item.item.get_icon( + drag_item.subKey, allow_single=False, )) else: drag_win.from_pal = False - img.apply(UI['drag_lbl'], drag_win.drag_item.item.get_icon( - drag_win.drag_item.subKey, + img.apply(UI['drag_lbl'], drag_item.item.get_icon( + drag_item.subKey, allow_single=True, single_num=0, )) drag_win.deiconify() - drag_win.lift(TK_ROOT) + drag_win.lift() # grab makes this window the only one to receive mouse events, so # it is guaranteed that it'll drop when the mouse is released. drag_win.grab_set_global() @@ -982,7 +930,7 @@ def drag_start(e: Event) -> None: UI['pre_sel_line'].lift() -def drag_stop(e) -> None: +def drag_stop(e: tk.Event) -> None: """User released the mouse button, complete the drag.""" drag_win = windows['drag_win'] @@ -1026,7 +974,7 @@ def drag_stop(e) -> None: drag_win.drag_item = None -def drag_move(e): +def drag_move(e: tk.Event) -> None: """Update the position of dragged items as they move around.""" drag_win = windows['drag_win'] @@ -1049,7 +997,7 @@ def drag_move(e): # special label for this. # The group item refresh will return this if nothing # changes. - img.apply(item, ICO_MOVING) + img.apply(item.label, ICO_MOVING) break drag_win.passed_over_pal = True @@ -1061,14 +1009,14 @@ def drag_move(e): UI['pre_sel_line'].place_forget() -def drag_fast(e): +def drag_fast(drag_item: PalItem, e: tk.Event) -> None: """Implement shift-clicking. When shift-clicking, an item will be immediately moved to the palette or deleted from it. """ pos_x, pos_y = conv_screen_to_grid(e.x_root, e.y_root) - e.widget.clear() + drag_item.clear() # Is the cursor over the preview pane? if 0 <= pos_x < 4: snd.fx('delete') @@ -1076,7 +1024,7 @@ def drag_fast(e): else: # over the picker if len(pal_picked) < 32: # can't copy if there isn't room snd.fx('config') - new_item = e.widget.copy(frames['preview']) + new_item = drag_item.copy(frames['preview']) new_item.is_pre = True pal_picked.append(new_item) else: @@ -1084,34 +1032,9 @@ def drag_fast(e): flow_preview() -def set_pal_radio(): - global selectedPalette - selectedPalette = selectedPalette_radio.get() - set_pal_listbox_selection() - set_palette() - - -def set_pal_listbox_selection(e=None): - """Select the currently chosen palette in the listbox.""" - UI['palette'].selection_clear(0, len(paletteLoader.pal_list)) - UI['palette'].selection_set(selectedPalette) - - -def set_palette(e=None): +def set_palette(chosen_pal: paletteUI.Palette) -> None: """Select a palette.""" - global selectedPalette - if selectedPalette >= len(paletteLoader.pal_list) or selectedPalette < 0: - LOGGER.warning('Invalid palette index!') - selectedPalette = 0 - - chosen_pal = paletteLoader.pal_list[selectedPalette] - - GEN_OPTS['Last_Selected']['palette'] = str(selectedPalette) pal_clear() - menus['pal'].entryconfigure( - 1, - label=_('Delete Palette "{}"').format(chosen_pal.name), - ) for item, sub in chosen_pal.pos: try: item_group = item_list[item] @@ -1134,18 +1057,7 @@ def set_palette(e=None): )) if chosen_pal.settings is not None: - BEE2_config.apply_settings(chosen_pal.settings) - - if len(paletteLoader.pal_list) < 2 or paletteLoader.pal_list[selectedPalette].prevent_overwrite: - UI['pal_remove'].state(('disabled',)) - UI['pal_save'].state(('disabled', )) # Save As only. - menus['pal'].entryconfigure(menus['pal_delete_ind'], state=DISABLED) - menus['pal'].entryconfigure(menus['pal_save_ind'], state=DISABLED) - else: - UI['pal_remove'].state(('!disabled',)) - UI['pal_save'].state(('!disabled', )) - menus['pal'].entryconfigure(menus['pal_delete_ind'], state=NORMAL) - menus['pal'].entryconfigure(menus['pal_save_ind'], state=NORMAL) + BEE2_config.apply_settings(chosen_pal.settings, is_palette=True) flow_preview() @@ -1160,7 +1072,7 @@ def pal_clear() -> None: def pal_shuffle() -> None: """Set the palette to a list of random items.""" mandatory_unlocked = StyleVarPane.mandatory_unlocked() - + if len(pal_picked) == 32: return @@ -1194,194 +1106,71 @@ def pal_shuffle() -> None: flow_preview() -def pal_save_as(e: Event=None) -> None: - """Save the palette with a new name.""" - while True: - name = tk_tools.prompt( - _("BEE2 - Save Palette"), - _("Enter a name:"), - ) - if name is None: - # Cancelled... - return - elif paletteLoader.check_exists(name): - if messagebox.askyesno( - icon=messagebox.QUESTION, - title='BEE2', - message=_('This palette already exists. Overwrite?'), - ): - break - else: - break - paletteLoader.save_pal( - [(it.id, it.subKey) for it in pal_picked], - name, - var_pal_save_settings.get(), - ) - refresh_pal_ui() - - -def pal_save(e=None) -> None: - """Save the current palette over the original name.""" - pal = paletteLoader.pal_list[selectedPalette] - if pal.prevent_overwrite: - pal_save_as() - else: - paletteLoader.save_pal( - [(it.id, it.subKey) for it in pal_picked], - pal.name, - var_pal_save_settings.get(), - ) - refresh_pal_ui() - - -def pal_remove() -> None: - global selectedPalette - pal = paletteLoader.pal_list[selectedPalette] - # Don't delete if there's only 1, or it's readonly. - if len(paletteLoader.pal_list) < 2 or pal.prevent_overwrite: - return - - if messagebox.askyesno( - title='BEE2', - message=_('Are you sure you want to delete "{}"?').format( - pal.name, - ), - parent=TK_ROOT, - ): - pal.delete_from_disk() - del paletteLoader.pal_list[selectedPalette] - selectedPalette -= 1 - selectedPalette_radio.set(selectedPalette) - refresh_pal_ui() - set_palette() - - # UI functions, each accepts the parent frame to place everything in. # initMainWind generates the main frames that hold all the panes to # make it easy to move them around if needed - -def init_palette(f) -> None: - """Initialises the palette pane. - - This lists all saved palettes and lets users choose from the list. - """ - f.rowconfigure(1, weight=1) - f.columnconfigure(0, weight=1) - - ttk.Button( - f, - text=_('Clear Palette'), - command=pal_clear, - ).grid(row=0, sticky="EW") - - UI['palette'] = listbox = Listbox(f, width=10) - listbox.grid(row=1, sticky="NSEW") - - def set_pal_listbox(e=None): - global selectedPalette - cur_selection = listbox.curselection() - if cur_selection: # Might be blank if none selected - selectedPalette = int(cur_selection[0]) - selectedPalette_radio.set(selectedPalette) - - # Actually set palette.. - set_palette() - else: - listbox.selection_set(selectedPalette, selectedPalette) - - listbox.bind("<>", set_pal_listbox) - listbox.bind("", set_pal_listbox_selection) - - # Set the selected state when hovered, so users can see which is - # selected. - listbox.selection_set(0) - - pal_scroll = tk_tools.HidingScroll( - f, - orient=VERTICAL, - command=listbox.yview, - ) - pal_scroll.grid(row=1, column=1, sticky="NS") - UI['palette']['yscrollcommand'] = pal_scroll.set - - UI['pal_remove'] = ttk.Button( - f, - text=_('Delete Palette'), - command=pal_remove, - ) - UI['pal_remove'].grid(row=2, sticky="EW") - - if tk_tools.USE_SIZEGRIP: - ttk.Sizegrip(f).grid(row=2, column=1) - - -def init_option(pane: SubPane) -> None: +def init_option(pane: SubPane, pal_ui: paletteUI.PaletteUI) -> None: """Initialise the options pane.""" pane.columnconfigure(0, weight=1) pane.rowconfigure(0, weight=1) frame = ttk.Frame(pane) - frame.grid(row=0, column=0, sticky=NSEW) + frame.grid(row=0, column=0, sticky='nsew') frame.columnconfigure(0, weight=1) - UI['pal_save'] = ttk.Button( + pal_save = ttk.Button( frame, - text=_("Save Palette..."), - command=pal_save, + text=gettext("Save Palette..."), + command=pal_ui.event_save, ) - UI['pal_save'].grid(row=0, sticky="EW", padx=5) + pal_save.grid(row=0, sticky="EW", padx=5) + pal_ui.save_btn_state = pal_save.state ttk.Button( frame, - text=_("Save Palette As..."), - command=pal_save_as, + text=gettext("Save Palette As..."), + command=pal_ui.event_save_as, ).grid(row=1, sticky="EW", padx=5) - def save_settings_changed() -> None: - GEN_OPTS['General'][ - 'palette_save_settings' - ] = srctools.bool_as_int(var_pal_save_settings.get()) - ttk.Checkbutton( frame, - text=_('Save Settings in Palettes'), - variable=var_pal_save_settings, - command=save_settings_changed, + text=gettext('Save Settings in Palettes'), + variable=pal_ui.var_save_settings, + command=pal_ui.event_save_settings_changed, ).grid(row=2, sticky="EW", padx=5) - var_pal_save_settings.set(GEN_OPTS.get_bool('General', 'palette_save_settings')) ttk.Separator(frame, orient='horizontal').grid(row=3, sticky="EW") - ttk.Button( + UI['pal_export'] = ttk.Button( frame, textvariable=EXPORT_CMD_VAR, - command=export_editoritems, - ).grid(row=4, sticky="EW", padx=5) + command=functools.partial(export_editoritems, pal_ui), + ) + UI['pal_export'].grid(row=4, sticky="EW", padx=5) props = ttk.Frame(frame, width="50") props.columnconfigure(1, weight=1) props.grid(row=5, sticky="EW") - music_frame = ttk.Labelframe(props, text=_('Music: ')) + music_frame = ttk.Labelframe(props, text=gettext('Music: ')) music_win = music_conf.make_widgets(music_frame, pane) - def suggested_style_set(): - """Set music, skybox, voices, etc to the settings defined for a style. - - """ - sugg = current_style().suggested + def suggested_style_set() -> None: + """Set music, skybox, voices, etc to the settings defined for a style.""" win_types = (voice_win, music_win, skybox_win, elev_win) - for win, sugg_val in zip(win_types, sugg): - win.sel_item_id(sugg_val) - UI['suggested_style'].state(['disabled']) - - def suggested_style_mousein(_): + has_suggest = False + for win in win_types: + win.sel_suggested() + if win.can_suggest(): + has_suggest = True + UI['suggested_style'].state(('!disabled', ) if has_suggest else ('disabled', )) + + def suggested_style_mousein(_: tk.Event) -> None: """When mousing over the button, show the suggested items.""" for win in (voice_win, music_win, skybox_win, elev_win): win.rollover_suggest() - def suggested_style_mouseout(_): + def suggested_style_mouseout(_: tk.Event) -> None: """Return text to the normal value on mouseout.""" for win in (voice_win, music_win, skybox_win, elev_win): win.set_disp() @@ -1389,14 +1178,14 @@ def suggested_style_mouseout(_): UI['suggested_style'] = ttk.Button( props, # '\u2193' is the downward arrow symbol. - text=_("{arr} Use Suggested {arr}").format(arr='\u2193'), + text=gettext("{arr} Use Suggested {arr}").format(arr='\u2193'), command=suggested_style_set, ) UI['suggested_style'].grid(row=1, column=1, columnspan=2, sticky="EW", padx=0) UI['suggested_style'].bind('', suggested_style_mousein) UI['suggested_style'].bind('', suggested_style_mouseout) - def configure_voice(): + def configure_voice() -> None: """Open the voiceEditor window to configure a Quote Pack.""" try: chosen_voice = packages.QuotePack.by_id(voice_win.chosen_id) @@ -1405,11 +1194,11 @@ def configure_voice(): else: voiceEditor.show(chosen_voice) for ind, name in enumerate([ - _("Style: "), + gettext("Style: "), None, - _("Voice: "), - _("Skybox: "), - _("Elev Vid: "), + gettext("Voice: "), + gettext("Skybox: "), + gettext("Elev Vid: "), ]): if name is None: # This is the "Suggested" button! @@ -1427,10 +1216,9 @@ def configure_voice(): img.apply(UI['conf_voice'], ICO_GEAR_DIS) tooltip.add_tooltip( UI['conf_voice'], - _('Enable or disable particular voice lines, to prevent them from ' - 'being added.'), + gettext('Enable or disable particular voice lines, to prevent them from being added.'), ) - + if utils.WIN: # On windows, the buttons get inset on the left a bit. Inset everything # else to adjust. @@ -1453,7 +1241,7 @@ def configure_voice(): sizegrip.grid(row=2, column=5, rowspan=2, sticky="NS") -def flow_preview(): +def flow_preview() -> None: """Position all the preview icons based on the array. Run to refresh if items are moved around. @@ -1462,10 +1250,10 @@ def flow_preview(): # these can be referred to to figure out where it is item.pre_x = i % 4 item.pre_y = i // 4 - item.place(x=(i % 4*65 + 4), y=(i // 4*65 + 32)) + item.label.place(x=(i % 4*65 + 4), y=(i // 4*65 + 32)) # Check to see if this should use the single-icon item.load_data() - item.lift() + item.label.lift() item_count = len(pal_picked) for ind, fake in enumerate(pal_picked_fake): @@ -1477,23 +1265,23 @@ def flow_preview(): UI['pre_sel_line'].lift() -def init_preview(f): +def init_preview(f: tk.Frame) -> None: """Generate the preview pane. This shows the items that will export to the palette. """ - UI['pre_bg_img'] = Label(f, bg=ItemsBG) + UI['pre_bg_img'] = tk.Label(f, bg=ItemsBG) UI['pre_bg_img'].grid(row=0, column=0) - img.apply(UI['pre_bg_img'], img.Handle.builtin('BEE2/menu', 271, 563)) + img.apply(UI['pre_bg_img'], img.Handle.builtin('BEE2/menu', 271, 573)) UI['pre_disp_name'] = ttk.Label( f, text="", style='BG.TLabel', ) - UI['pre_disp_name'].place(x=10, y=552) + UI['pre_disp_name'].place(x=10, y=554) - UI['pre_sel_line'] = Label( + UI['pre_sel_line'] = tk.Label( f, bg="#F0F0F0", borderwidth=0, @@ -1511,11 +1299,12 @@ def init_preview(f): flow_preview() -def init_picker(f): +def init_picker(f: tk.Frame) -> None: + """Construct the frame holding all the items.""" global frmScroll, pal_canvas ttk.Label( f, - text=_("All Items: "), + text=gettext("All Items: "), anchor="center", ).grid( row=0, @@ -1531,7 +1320,7 @@ def init_picker(f): f.rowconfigure(1, weight=1) f.columnconfigure(0, weight=1) - pal_canvas = Canvas(cframe) + pal_canvas = tk.Canvas(cframe) # need to use a canvas to allow scrolling pal_canvas.grid(row=0, column=0, sticky="NSEW") cframe.rowconfigure(0, weight=1) @@ -1539,7 +1328,7 @@ def init_picker(f): scroll = tk_tools.HidingScroll( cframe, - orient=VERTICAL, + orient=tk.VERTICAL, command=pal_canvas.yview, ) scroll.grid(column=1, row=0, sticky="NS") @@ -1563,7 +1352,7 @@ def init_picker(f): f.bind("", flow_picker) -def flow_picker(e=None): +def flow_picker(e=None) -> None: """Update the picker box so all items are positioned corrctly. Should be run (e arg is ignored) whenever the items change, or the @@ -1588,13 +1377,13 @@ def flow_picker(e=None): if visible: item.is_pre = False - item.place( + item.label.place( x=((i % width) * 65 + 1), y=((i // width) * 65 + 1), ) i += 1 else: - item.place_forget() + item.label.place_forget() num_items = i @@ -1619,7 +1408,8 @@ def flow_picker(e=None): def init_drag_icon() -> None: - drag_win = Toplevel(TK_ROOT) + """Create the window for rendering held items.""" + drag_win = tk.Toplevel(TK_ROOT) # this prevents stuff like the title bar, normal borders etc from # appearing in this window. drag_win.overrideredirect(True) @@ -1628,7 +1418,7 @@ def init_drag_icon() -> None: drag_win.transient(master=TK_ROOT) drag_win.withdraw() # starts hidden drag_win.bind(tk_tools.EVENTS['LEFT_RELEASE'], drag_stop) - UI['drag_lbl'] = Label(drag_win) + UI['drag_lbl'] = ttk.Label(drag_win) img.apply(UI['drag_lbl'], IMG_BLANK) UI['drag_lbl'].grid(row=0, column=0) windows['drag_win'] = drag_win @@ -1638,14 +1428,14 @@ def init_drag_icon() -> None: drag_win.drag_item = None # the item currently being moved -def set_game(game: 'gameMan.Game'): +def set_game(game: 'gameMan.Game') -> None: """Callback for when the game is changed. This updates the title bar to match, and saves it into the config. """ TK_ROOT.title('BEEMOD {} - {}'.format(utils.BEE_VERSION, game.name)) GEN_OPTS['Last_Selected']['game'] = game.name - text = _('Export to "{}"...').format(game.name) + text = gettext('Export to "{}"...').format(game.name) if game.cache_invalid(): # Mark that it needs extractions @@ -1658,58 +1448,58 @@ def set_game(game: 'gameMan.Game'): EXPORT_CMD_VAR.set(text) -def init_menu_bar(win: Toplevel) -> Menu: +def init_menu_bar(win: tk.Toplevel, export: Callable[[], None]) -> Tuple[tk.Menu, tk.Menu]: """Create the top menu bar. - This returns the View menu, for later population. + This returns the View and palette menus, for later population. """ - bar = Menu(win) + bar = tk.Menu(win) # Suppress ability to make each menu a separate window - weird old # TK behaviour win.option_add('*tearOff', '0') if utils.MAC: # Name is used to make this the special 'BEE2' menu item - file_menu = menus['file'] = Menu(bar, name='apple') + file_menu = menus['file'] = tk.Menu(bar, name='apple') else: - file_menu = menus['file'] = Menu(bar) + file_menu = menus['file'] = tk.Menu(bar) - bar.add_cascade(menu=file_menu, label=_('File')) + bar.add_cascade(menu=file_menu, label=gettext('File')) # Assign the bar as the main window's menu. # Must be done after creating the apple menu. win['menu'] = bar file_menu.add_command( - label=_("Export"), - command=export_editoritems, + label=gettext("Export"), + command=export, accelerator=tk_tools.ACCEL_EXPORT, ) file_menu.export_btn_index = 0 # Change this if the menu is reordered file_menu.add_command( - label=_("Add Game"), + label=gettext("Add Game"), command=gameMan.add_game, ) file_menu.add_command( - label=_("Uninstall from Selected Game"), + label=gettext("Uninstall from Selected Game"), command=gameMan.remove_game, ) file_menu.add_command( - label=_("Backup/Restore Puzzles..."), + label=gettext("Backup/Restore Puzzles..."), command=backup_win.show_window, ) file_menu.add_command( - label=_("Manage Packages..."), + label=gettext("Manage Packages..."), command=packageMan.show, ) file_menu.add_separator() file_menu.add_command( - label=_("Options"), + label=gettext("Options"), command=optionWindow.show, ) if not utils.MAC: file_menu.add_command( - label=_("Quit"), + label=gettext("Quit"), command=quit_application, ) file_menu.add_separator() @@ -1717,66 +1507,23 @@ def init_menu_bar(win: Toplevel) -> Menu: gameMan.add_menu_opts(menus['file'], callback=set_game) gameMan.game_menu = menus['file'] - pal_menu = menus['pal'] = Menu(bar) + pal_menu = menus['pal'] = tk.Menu(bar) # Menu name - bar.add_cascade(menu=pal_menu, label=_('Palette')) - pal_menu.add_command( - label=_('Clear'), - command=pal_clear, - ) - pal_menu.add_command( - # Placeholder.. - label=_('Delete Palette'), # This name is overwritten later - command=pal_remove, - ) - menus['pal_delete_ind'] = pal_menu.index('end') - pal_menu.add_command( - label=_('Fill Palette'), - command=pal_shuffle, - ) - - pal_menu.add_separator() - - pal_menu.add_checkbutton( - label=_('Save Settings in Palettes'), - variable=var_pal_save_settings, - ) - - pal_menu.add_separator() - - pal_menu.add_command( - label=_('Save Palette'), - command=pal_save, - accelerator=tk_tools.ACCEL_SAVE, - ) - menus['pal_save_ind'] = pal_menu.index('end') - pal_menu.add_command( - label=_('Save Palette As...'), - command=pal_save_as, - accelerator=tk_tools.ACCEL_SAVE_AS, - ) - - pal_menu.add_separator() + bar.add_cascade(menu=pal_menu, label=gettext('Palette')) - # refresh_pal_ui() adds the palette menu options here. - - view_menu = Menu(bar) - bar.add_cascade(menu=view_menu, label=_('View')) - - win.bind_all(tk_tools.KEY_SAVE, pal_save) - win.bind_all(tk_tools.KEY_SAVE_AS, pal_save_as) - win.bind_all(tk_tools.KEY_EXPORT, export_editoritems) + view_menu = tk.Menu(bar) + bar.add_cascade(menu=view_menu, label=gettext('View')) helpMenu.make_help_menu(bar) - return view_menu + return view_menu, pal_menu -def init_windows() -> None: +async def init_windows() -> None: """Initialise all windows and panes. """ - view_menu = init_menu_bar(TK_ROOT) + view_menu, pal_menu = init_menu_bar(TK_ROOT, export=lambda: export_editoritems(pal_ui)) TK_ROOT.maxsize( width=TK_ROOT.winfo_screenwidth(), height=TK_ROOT.winfo_screenheight(), @@ -1787,7 +1534,7 @@ def init_windows() -> None: # OS X has a special quit menu item. TK_ROOT.createcommand('tk::mac::Quit', quit_application) - ui_bg = Frame(TK_ROOT, bg=ItemsBG) + ui_bg = tk.Frame(TK_ROOT, bg=ItemsBG) ui_bg.grid(row=0, column=0, sticky='NSEW') TK_ROOT.columnconfigure(0, weight=1) TK_ROOT.rowconfigure(0, weight=1) @@ -1799,7 +1546,7 @@ def init_windows() -> None: style.configure('BG.TButton', background=ItemsBG) style.configure('Preview.TLabel', background='#F4F5F5') - frames['preview'] = Frame(ui_bg, bg=ItemsBG) + frames['preview'] = tk.Frame(ui_bg, bg=ItemsBG) frames['preview'].grid( row=0, column=3, @@ -1814,11 +1561,12 @@ def init_windows() -> None: height=frames['preview'].winfo_reqheight()+5, ) # Prevent making the window smaller than the preview pane - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'preview') ttk.Separator( ui_bg, - orient=VERTICAL, + orient='vertical', ).grid( row=0, column=4, @@ -1827,7 +1575,7 @@ def init_windows() -> None: pady=10, ) - picker_split_frame = Frame(ui_bg, bg=ItemsBG) + picker_split_frame = tk.Frame(ui_bg, bg=ItemsBG) picker_split_frame.grid(row=0, column=5, sticky="NSEW", padx=5, pady=5) ui_bg.columnconfigure(5, weight=1) @@ -1849,7 +1597,8 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: item_search.init(search_frame, update_filter) - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'filter') frames['picker'] = ttk.Frame( picker_split_frame, @@ -1862,9 +1611,10 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: picker_split_frame.columnconfigure(0, weight=1) init_picker(frames['picker']) - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'picker') - frames['toolMenu'] = Frame( + frames['toolMenu'] = tk.Frame( frames['preview'], bg=ItemsBG, width=192, @@ -1875,7 +1625,7 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: windows['pal'] = SubPane.SubPane( TK_ROOT, - title=_('Palettes'), + title=gettext('Palettes'), name='pal', menu_bar=view_menu, resize_x=True, @@ -1890,17 +1640,29 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: windows['pal'].columnconfigure(0, weight=1) windows['pal'].rowconfigure(0, weight=1) - init_palette(pal_frame) + pal_ui = paletteUI.PaletteUI( + pal_frame, pal_menu, + cmd_clear=pal_clear, + cmd_shuffle=pal_shuffle, + get_items=lambda: [(it.id, it.subKey) for it in pal_picked], + set_items=set_palette, + ) - loader.step('UI') + TK_ROOT.bind_all(tk_tools.KEY_SAVE, lambda e: pal_ui.event_save) + TK_ROOT.bind_all(tk_tools.KEY_SAVE_AS, lambda e: pal_ui.event_save_as) + TK_ROOT.bind_all(tk_tools.KEY_EXPORT, lambda e: export_editoritems(pal_ui)) + + await trio.sleep(0) + loader.step('UI', 'palette') packageMan.make_window() - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'packageman') windows['opt'] = SubPane.SubPane( TK_ROOT, - title=_('Export Options'), + title=gettext('Export Options'), name='opt', menu_bar=view_menu, resize_x=True, @@ -1908,17 +1670,17 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: tool_img='icons/win_options', tool_col=2, ) - init_option(windows['opt']) + init_option(windows['opt'], pal_ui) - loader.step('UI') + loader.step('UI', 'options') StyleVarPane.make_pane(frames['toolMenu'], view_menu, flow_picker) - loader.step('UI') + loader.step('UI', 'stylevar') CompilerPane.make_pane(frames['toolMenu'], view_menu) - loader.step('UI') + loader.step('UI', 'compiler') UI['shuffle_pal'] = SubPane.make_tool_button( frame=frames['toolMenu'], @@ -1932,7 +1694,7 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: ) tooltip.add_tooltip( UI['shuffle_pal'], - _('Fill empty spots in the palette with random items.'), + gettext('Fill empty spots in the palette with random items.'), ) # Make scrollbar work globally @@ -1945,16 +1707,20 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: tk_tools.bind_leftclick(windows['opt'], contextWin.hide_context) tk_tools.bind_leftclick(windows['pal'], contextWin.hide_context) + await trio.sleep(0) backup_win.init_toplevel() - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'backup') voiceEditor.init_widgets() - loader.step('UI') + await trio.sleep(0) + loader.step('UI', 'voiceline') contextWin.init_widgets() - loader.step('UI') + loader.step('UI', 'contextwin') optionWindow.init_widgets() - loader.step('UI') + loader.step('UI', 'optionwindow') init_drag_icon() - loader.step('UI') + loader.step('UI', 'drag_icon') + await trio.sleep(0) optionWindow.reset_all_win = reset_panes @@ -1972,14 +1738,7 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: if utils.MAC: TK_ROOT.lift() # Raise to the top of the stack - TK_ROOT.update_idletasks() - StyleVarPane.window.update_idletasks() - CompilerPane.window.update_idletasks() - windows['opt'].update_idletasks() - windows['pal'].update_idletasks() - - TK_ROOT.after(50, set_pal_listbox_selection) - # This needs some time for the listbox to appear first + await trio.sleep(0.1) # Position windows according to remembered settings: try: @@ -1994,17 +1753,14 @@ def update_filter(new_filter: Optional[Set[Tuple[str, int]]]) -> None: '+' + str(TK_ROOT.winfo_rooty()) ) else: - TK_ROOT.geometry( - '+' + str(TK_ROOT.winfo_rootx()) + - '+' + str(TK_ROOT.winfo_rooty()) - ) + TK_ROOT.geometry(f'{TK_ROOT.winfo_rootx()}+{TK_ROOT.winfo_rooty()}') else: start_x, start_y = utils.adjust_inside_screen( start_x, start_y, win=TK_ROOT, ) - TK_ROOT.geometry('+' + str(start_x) + '+' + str(start_y)) + TK_ROOT.geometry(f'+{start_x}+{start_y}') TK_ROOT.update_idletasks() # First move to default positions, then load the config. @@ -2051,8 +1807,7 @@ def style_select_callback(style_id: str) -> None: style_win.callback = style_select_callback style_select_callback(style_win.chosen_id) - img.start_loading() - set_palette() + set_palette(pal_ui.selected) # Set_palette needs to run first, so it can fix invalid palette indexes. BEE2_config.read_settings() - refresh_pal_ui() + pal_ui.update_state() diff --git a/src/app/__init__.py b/src/app/__init__.py index 23f17eb78..8139fdd29 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -2,8 +2,8 @@ import tkinter as tk from types import TracebackType from typing import Type -from utils import BEE_VERSION - +import utils +import trio # Import first, so it monkeypatch traceback before us. # We must always have one Tk object, and it needs to be constructed # before most of TKinter will function. So doing it here does it first. @@ -34,26 +34,26 @@ def tk_error( # The exception is caught inside the TK code. # We don't care about that, so try and move the traceback up # one level. - import sys import logging if exc_tb.tb_next: exc_tb = exc_tb.tb_next try: on_error(exc_type, exc_value, exc_tb) - except: + except Exception: pass logger = logging.getLogger('BEE2') logger.error( - msg='Uncaught Exception:', + msg='Uncaught Tk Exception:', exc_info=(exc_type, exc_value, exc_tb), ) - # Since this isn't caught normally, it won't quit the application. - # Quit ourselves manually. to prevent TK just freezing. - TK_ROOT.quit() - sys.exit() + try: + import BEE2 + BEE2.APP_NURSERY.cancel_scope.cancel() + except Exception: + pass TK_ROOT.report_callback_exception = tk_error @@ -68,7 +68,6 @@ def on_error( # We don't want this to fail, so import everything here, and wrap in # except Exception. import traceback - err = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb)) # Grab and release the grab so nothing else can block the error message. @@ -81,18 +80,6 @@ def on_error( except Exception: pass - # Try and terminate background operations. - try: - import loadScreen - loadScreen.BG_PROC.kill() - except Exception: - pass - try: - from . import sound - sound.sounds = sound.NullSound() - except Exception: - pass - if not issubclass(exc_type, Exception): # It's subclassing BaseException (KeyboardInterrupt, SystemExit), # so ignore the error. @@ -101,10 +88,13 @@ def on_error( # Put it onscreen. try: from tkinter import messagebox + from localisation import gettext messagebox.showinfo( - title='BEEMOD {} Error!'.format(BEE_VERSION), - message='An error occurred: \n{}\n\nThis has ' - 'been copied to the clipboard.'.format(err), + title=gettext('BEEMOD {} Error!').format(utils.BEE_VERSION), + message=gettext( + 'An error occurred: \n{}\n\n' + 'This has been copied to the clipboard.' + ).format(err), icon=messagebox.ERROR, ) except Exception: @@ -120,3 +110,12 @@ def on_error( except Exception: # Ignore failures... pass + +# Various configuration booleans. +PLAY_SOUND = tk.BooleanVar(value=True, name='OPT_play_sounds') +KEEP_WIN_INSIDE = tk.BooleanVar(value=True, name='OPT_keep_win_inside') +FORCE_LOAD_ONTOP = tk.BooleanVar(value=True, name='OPT_force_load_ontop') +SHOW_LOG_WIN = tk.BooleanVar(value=False, name='OPT_show_log_window') +LAUNCH_AFTER_EXPORT = tk.BooleanVar(value=True, name='OPT_launch_after_export') +PRESERVE_RESOURCES = tk.BooleanVar(value=False, name='OPT_preserve_bee2_resource_dir') +DEV_MODE = tk.BooleanVar(value=utils.DEV_MODE, name='OPT_development_mode') diff --git a/src/app/backup.py b/src/app/backup.py index 225ec381b..97705baaa 100644 --- a/src/app/backup.py +++ b/src/app/backup.py @@ -19,10 +19,9 @@ from app.CheckDetails import CheckDetails, Item as CheckItem from FakeZip import FakeZip, zip_names, zip_open_bin from srctools import Property, KeyValError -from tkinter import filedialog -from tkinter import messagebox -from tkinter import ttk +from tkinter import filedialog, messagebox, ttk from app.tooltip import add_tooltip +from localisation import gettext, ngettext if TYPE_CHECKING: from app import gameMan @@ -76,32 +75,32 @@ # Loadscreens used as basic progress bars copy_loader = loadScreen.LoadScreen( ('COPY', ''), - title_text=_('Copying maps'), + title_text=gettext('Copying maps'), ) reading_loader = loadScreen.LoadScreen( ('READ', ''), - title_text=_('Loading maps'), + title_text=gettext('Loading maps'), ) deleting_loader = loadScreen.LoadScreen( ('DELETE', ''), - title_text=_('Deleting maps'), + title_text=gettext('Deleting maps'), ) class P2C: """A PeTI map.""" def __init__( - self, - filename, - zip_file, - create_time, - mod_time, - title='', - desc='', - is_coop=False, - ): + self, + filename, + zip_file, + create_time, + mod_time, + title='', + desc='', + is_coop=False, + ) -> None: self.filename = filename self.zip_file = zip_file self.create_time = create_time @@ -334,7 +333,7 @@ def backup_maps(maps): ): if not messagebox.askyesno( title='Overwrite File?', - message=_('This filename is already in the backup.' + message=gettext('This filename is already in the backup.' 'Do you wish to overwrite it? ' '({})').format(p2c.title), parent=window, @@ -440,8 +439,8 @@ def save_backup(): if not maps: messagebox.showerror( - _('BEE2 Backup'), - _('No maps were chosen to backup!'), + gettext('BEE2 Backup'), + gettext('No maps were chosen to backup!'), ) return @@ -501,7 +500,7 @@ def restore_maps(maps: List[P2C]): ): if not messagebox.askyesno( title='Overwrite File?', - message=_('This map is already in the game directory.' + message=gettext('This map is already in the game directory.' 'Do you wish to overwrite it? ' '({})').format(p2c.title), parent=window, @@ -563,8 +562,8 @@ def show_window() -> None: def ui_load_backup() -> None: """Prompt and load in a backup file.""" file = filedialog.askopenfilename( - title=_('Load Backup'), - filetypes=[(_('Backup zip'), '.zip')], + title=gettext('Load Backup'), + filetypes=[(gettext('Backup zip'), '.zip')], ) if not file: return @@ -597,7 +596,7 @@ def ui_new_backup() -> None: BACKUPS['back'].clear() BACKUPS['backup_name'] = None BACKUPS['backup_path'] = None - backup_name.set(_('Unsaved Backup')) + backup_name.set(gettext('Unsaved Backup')) BACKUPS['unsaved_file'] = unsaved = BytesIO() BACKUPS['backup_zip'] = ZipFile( unsaved, @@ -622,8 +621,8 @@ def ui_save_backup() -> None: def ui_save_backup_as() -> None: """Prompt for a name, and then save a backup.""" path = filedialog.asksaveasfilename( - title=_('Save Backup As'), - filetypes=[(_('Backup zip'), '.zip')], + title=gettext('Save Backup As'), + filetypes=[(gettext('Backup zip'), '.zip')], ) if not path: return @@ -719,7 +718,7 @@ def ui_delete_game() -> None: return map_count = len(to_delete) if not messagebox.askyesno( - _('Confirm Deletion'), + gettext('Confirm Deletion'), ngettext( 'Do you wish to delete {} map?\n', 'Do you wish to delete {} maps?\n', @@ -757,8 +756,8 @@ def ui_delete_game() -> None: def init() -> None: """Initialise all widgets in the given window.""" for cat, btn_text in [ - ('back_', _('Restore:')), - ('game_', _('Backup:')), + ('back_', gettext('Restore:')), + ('game_', gettext('Backup:')), ]: UI[cat + 'frame'] = frame = ttk.Frame( window, @@ -795,7 +794,7 @@ def init() -> None: ) UI[cat + 'btn_sel'] = ttk.Button( button_frame, - text=_('Checked'), + text=gettext('Checked'), width=8, ) UI[cat + 'btn_all'].grid(row=0, column=1) @@ -803,7 +802,7 @@ def init() -> None: UI[cat + 'btn_del'] = ttk.Button( button_frame, - text=_('Delete Checked'), + text=gettext('Delete Checked'), width=14, ) UI[cat + 'btn_del'].grid(row=1, column=0, columnspan=3) @@ -853,7 +852,7 @@ def init_application() -> None: global window window = TK_ROOT TK_ROOT.title( - _('BEEMOD {} - Backup / Restore Puzzles').format(utils.BEE_VERSION) + gettext('BEEMOD {} - Backup / Restore Puzzles').format(utils.BEE_VERSION) ) init() @@ -866,20 +865,20 @@ def init_application() -> None: file_menu = menus['file'] = tk.Menu(bar, name='apple') else: file_menu = menus['file'] = tk.Menu(bar) - file_menu.add_command(label=_('New Backup'), command=ui_new_backup) - file_menu.add_command(label=_('Open Backup'), command=ui_load_backup) - file_menu.add_command(label=_('Save Backup'), command=ui_save_backup) - file_menu.add_command(label=_('Save Backup As'), command=ui_save_backup_as) + file_menu.add_command(label=gettext('New Backup'), command=ui_new_backup) + file_menu.add_command(label=gettext('Open Backup'), command=ui_load_backup) + file_menu.add_command(label=gettext('Save Backup'), command=ui_save_backup) + file_menu.add_command(label=gettext('Save Backup As'), command=ui_save_backup_as) - bar.add_cascade(menu=file_menu, label=_('File')) + bar.add_cascade(menu=file_menu, label=gettext('File')) game_menu = menus['game'] = tk.Menu(bar) - game_menu.add_command(label=_('Add Game'), command=gameMan.add_game) - game_menu.add_command(label=_('Remove Game'), command=gameMan.remove_game) + game_menu.add_command(label=gettext('Add Game'), command=gameMan.add_game) + game_menu.add_command(label=gettext('Remove Game'), command=gameMan.remove_game) game_menu.add_separator() - bar.add_cascade(menu=game_menu, label=_('Game')) + bar.add_cascade(menu=game_menu, label=gettext('Game')) gameMan.game_menu = game_menu from app import helpMenu @@ -925,7 +924,7 @@ def directory_callback(path): ) UI['auto_enable'] = enable_check = ttk.Checkbutton( frame, - text=_('Automatic Backup After Export'), + text=gettext('Automatic Backup After Export'), variable=check_var, command=check_callback, ) @@ -957,7 +956,7 @@ def directory_callback(path): count_frame.grid(row=0, column=1) ttk.Label( count_frame, - text=_('Keep (Per Game):'), + text=gettext('Keep (Per Game):'), ).grid(row=0, column=0) count = tk_tools.ttk_Spinbox( @@ -975,7 +974,7 @@ def init_toplevel() -> None: window = tk.Toplevel(TK_ROOT) window.transient(TK_ROOT) window.withdraw() - window.title(_('Backup/Restore Puzzles')) + window.title(gettext('Backup/Restore Puzzles')) def quit_command(): from BEE2_config import GEN_OPTS @@ -994,21 +993,21 @@ def quit_command(): ) ttk.Button( toolbar_frame, - text=_('New Backup'), + text=gettext('New Backup'), command=ui_new_backup, width=14, ).grid(row=0, column=0) ttk.Button( toolbar_frame, - text=_('Open Backup'), + text=gettext('Open Backup'), command=ui_load_backup, width=13, ).grid(row=0, column=1) ttk.Button( toolbar_frame, - text=_('Save Backup'), + text=gettext('Save Backup'), command=ui_save_backup, width=11, ).grid(row=0, column=2) @@ -1021,8 +1020,6 @@ def quit_command(): ).grid(row=0, column=3) toolbar_frame.grid(row=0, column=0, columnspan=3, sticky='W') - - TK_ROOT.update() ui_new_backup() @@ -1033,4 +1030,3 @@ def deinit() -> None: obj = BACKUPS[name] if obj is not None: obj.close() - diff --git a/src/app/contextWin.py b/src/app/contextWin.py index 6c9877a2c..5a6cbda70 100644 --- a/src/app/contextWin.py +++ b/src/app/contextWin.py @@ -23,22 +23,20 @@ sound, img, UI, - TK_ROOT, + TK_ROOT, DEV_MODE, ) import utils import srctools.logger from editoritems import Handle as RotHandle, Surface, ItemClass from editoritems_props import TimerDelay +from localisation import gettext LOGGER = srctools.logger.get_logger(__name__) -OPEN_IN_TAB = 2 - wid = {} selected_item: 'UI.Item' selected_sub_item: 'UI.PalItem' -is_open = False version_lookup = [] @@ -81,34 +79,34 @@ class SPR(Enum): SPRITE_TOOL = { # The tooltips associated with each sprite. - 'rot_0': _('This item may not be rotated.'), - 'rot_4': _('This item can be pointed in 4 directions.'), - 'rot_5': _('This item can be positioned on the sides and center.'), - 'rot_6': _('This item can be centered in two directions, plus on the sides.'), - 'rot_8': _('This item can be placed like light strips.'), - 'rot_36': _('This item can be rotated on the floor to face 360 degrees.'), - 'rot_catapult': _('This item is positioned using a catapult trajectory.'), - 'rot_paint': _('This item positions the dropper to hit target locations.'), - - 'in_none': _('This item does not accept any inputs.'), - 'in_norm': _('This item accepts inputs.'), - 'in_dual': _('This item has two input types (A and B), using the Input A and B items.'), - - 'out_none': _('This item does not output.'), - 'out_norm': _('This item has an output.'), - 'out_tim': _('This item has a timed output.'), - - 'space_none': _('This item does not take up any space inside walls.'), - 'space_embed': _('This item takes space inside the wall.'), - - 'surf_none': _('This item cannot be placed anywhere...'), - 'surf_ceil': _('This item can only be attached to ceilings.'), - 'surf_floor': _('This item can only be placed on the floor.'), - 'surf_floor_ceil': _('This item can be placed on floors and ceilings.'), - 'surf_wall': _('This item can be placed on walls only.'), - 'surf_wall_ceil': _('This item can be attached to walls and ceilings.'), - 'surf_wall_floor': _('This item can be placed on floors and walls.'), - 'surf_wall_floor_ceil': _('This item can be placed in any orientation.'), + 'rot_0': gettext('This item may not be rotated.'), + 'rot_4': gettext('This item can be pointed in 4 directions.'), + 'rot_5': gettext('This item can be positioned on the sides and center.'), + 'rot_6': gettext('This item can be centered in two directions, plus on the sides.'), + 'rot_8': gettext('This item can be placed like light strips.'), + 'rot_36': gettext('This item can be rotated on the floor to face 360 degrees.'), + 'rot_catapult': gettext('This item is positioned using a catapult trajectory.'), + 'rot_paint': gettext('This item positions the dropper to hit target locations.'), + + 'in_none': gettext('This item does not accept any inputs.'), + 'in_norm': gettext('This item accepts inputs.'), + 'in_dual': gettext('This item has two input types (A and B), using the Input A and B items.'), + + 'out_none': gettext('This item does not output.'), + 'out_norm': gettext('This item has an output.'), + 'out_tim': gettext('This item has a timed output.'), + + 'space_none': gettext('This item does not take up any space inside walls.'), + 'space_embed': gettext('This item takes space inside the wall.'), + + 'surf_none': gettext('This item cannot be placed anywhere...'), + 'surf_ceil': gettext('This item can only be attached to ceilings.'), + 'surf_floor': gettext('This item can only be placed on the floor.'), + 'surf_floor_ceil': gettext('This item can be placed on floors and ceilings.'), + 'surf_wall': gettext('This item can be placed on walls only.'), + 'surf_wall_ceil': gettext('This item can be attached to walls and ceilings.'), + 'surf_wall_floor': gettext('This item can be placed on floors and walls.'), + 'surf_wall_floor_ceil': gettext('This item can be placed in any orientation.'), } IMG_ALPHA = img.Handle.blank(64, 64) @@ -171,6 +169,10 @@ def func(e): show_prop(item) return func +def is_visible() -> bool: + """Checks if the window is visible.""" + return window.winfo_ismapped() + def show_prop(widget, warp_cursor=False): """Show the properties window for an item. @@ -179,18 +181,17 @@ def show_prop(widget, warp_cursor=False): If warp_cursor is true, the cursor will be moved relative to this window so it stays on top of the selected subitem. """ - global selected_item, selected_sub_item, is_open - if warp_cursor and is_open: + global selected_item, selected_sub_item + if warp_cursor and is_visible(): cursor_x, cursor_y = window.winfo_pointerxy() off_x = cursor_x - window.winfo_rootx() off_y = cursor_y - window.winfo_rooty() else: off_x, off_y = None, None window.deiconify() - window.lift(TK_ROOT) + window.lift() selected_item = widget.item selected_sub_item = widget - is_open = True adjust_position() @@ -223,7 +224,7 @@ def set_version_combobox(box: ttk.Combobox, item: 'UI.Item') -> list: if len(version_names) <= 1: # There aren't any alternates to choose from, disable the box box.state(['disabled']) - box['values'] = [_('No Alternate Versions')] + box['values'] = [gettext('No Alternate Versions')] box.current(0) else: box.state(['!disabled']) @@ -271,7 +272,7 @@ def load_item_data() -> None: style_desc=item_data.desc, ) # Dump out the instances used in this item. - if optionWindow.DEV_MODE.get(): + if DEV_MODE.get(): inst_desc = [] for editor in [selected_item.data.editor] + selected_item.data.editor_extra: if editor is selected_item.data.editor: @@ -289,7 +290,7 @@ def load_item_data() -> None: wid['desc'].set_text(desc) - if optionWindow.DEV_MODE.get(): + if DEV_MODE.get(): source = selected_item.data.source.replace("from", "\nfrom") wid['item_id']['text'] = f'{source}\n-> {selected_item.id}:{selected_sub_item.subKey}' wid['item_id'].grid() @@ -303,11 +304,11 @@ def load_item_data() -> None: version_lookup[:] = set_version_combobox(wid['variant'], selected_item) - if selected_item.url is None: + if selected_item.data.url is None: wid['moreinfo'].state(['disabled']) else: wid['moreinfo'].state(['!disabled']) - tooltip.set_tooltip(wid['moreinfo'], selected_item.url) + tooltip.set_tooltip(wid['moreinfo'], selected_item.data.url) editor = item_data.editor has_timer = any(isinstance(prop, TimerDelay) for prop in editor.properties) @@ -317,7 +318,7 @@ def load_item_data() -> None: set_sprite(SPR.INPUT, 'in_dual') # Real funnels work slightly differently. if selected_item.id.casefold() == 'item_tbeam': - tooltip.set_tooltip(wid['sprite', SPR.INPUT], _( + tooltip.set_tooltip(wid['sprite', SPR.INPUT], gettext( 'Excursion Funnels accept a on/off ' 'input and a directional input.' )) @@ -368,7 +369,7 @@ def load_item_data() -> None: set_sprite(SPR.ROTATION, 'rot_36') tooltip.set_tooltip( wid['sprite', SPR.ROTATION], - SPRITE_TOOL['rot_36'] + _( + SPRITE_TOOL['rot_36'] + gettext( 'This item can be rotated on the floor to face 360 ' 'degrees, for Reflection Cubes only.' ), @@ -386,13 +387,13 @@ def load_item_data() -> None: set_sprite(SPR.COLLISION, 'space_embed') -def adjust_position(e=None): +def adjust_position(e=None) -> None: """Move the properties window onto the selected item. We call this constantly, so the property window will not go outside the screen, and snap back to the item when the main window returns. """ - if not is_open or selected_sub_item is None: + if not is_visible() or selected_sub_item is None: return # Calculate the pixel offset between the window and the subitem in @@ -402,12 +403,12 @@ def adjust_position(e=None): loc_x, loc_y = utils.adjust_inside_screen( x=( - selected_sub_item.winfo_rootx() + selected_sub_item.label.winfo_rootx() + window.winfo_rootx() - icon_widget.winfo_rootx() ), y=( - selected_sub_item.winfo_rooty() + selected_sub_item.label.winfo_rooty() + window.winfo_rooty() - icon_widget.winfo_rooty() ), @@ -422,9 +423,8 @@ def adjust_position(e=None): def hide_context(e=None): """Hide the properties window, if it's open.""" - global is_open, selected_item, selected_sub_item - if is_open: - is_open = False + global selected_item, selected_sub_item + if is_visible(): window.withdraw() sound.fx('contract') selected_item = selected_sub_item = None @@ -437,7 +437,7 @@ def init_widgets() -> None: ttk.Label( f, - text=_("Properties:"), + text=gettext("Properties:"), anchor="center", ).grid( row=0, @@ -463,9 +463,11 @@ def init_widgets() -> None: wid['ent_count'].grid(row=0, column=2, rowspan=2, sticky='e') tooltip.add_tooltip( wid['ent_count'], - _('The number of entities used for this item. The Source engine ' - 'limits this to 2048 in total. This provides a guide to how many of ' - 'these items can be placed in a map at once.') + gettext( + 'The number of entities used for this item. The Source engine ' + 'limits this to 2048 in total. This provides a guide to how many of ' + 'these items can be placed in a map at once.' + ), ) wid['author'] = ttk.Label(f, text="", anchor="center", relief="sunken") @@ -486,7 +488,7 @@ def init_widgets() -> None: functools.partial(sub_open, i), ) - ttk.Label(f, text=_("Description:"), anchor="sw").grid( + ttk.Label(f, text=gettext("Description:"), anchor="sw").grid( row=5, column=0, sticky="SW", @@ -517,21 +519,23 @@ def init_widgets() -> None: wid['desc']['yscrollcommand'] = desc_scroll.set desc_scroll.grid(row=0, column=1, sticky="NS") - def show_more_info(): - url = selected_item.url + def show_more_info() -> None: + """Show the 'more info' URL.""" + url = selected_item.data.url if url is not None: try: - webbrowser.open(url, new=OPEN_IN_TAB, autoraise=True) + webbrowser.open_new_tab(url) except webbrowser.Error: if messagebox.askyesno( - icon="error", - title="BEE2 - Error", - message=_('Failed to open a web browser. Do you wish ' - 'for the URL to be copied to the clipboard ' - 'instead?'), - detail='"{!s}"'.format(url), - parent=window, - ): + icon="error", + title="BEE2 - Error", + message=gettext( + 'Failed to open a web browser. Do you wish for the URL ' + 'to be copied to the clipboard instead?' + ), + detail=f'"{url!s}"', + parent=window, + ): LOGGER.info("Saving {} to clipboard!", url) TK_ROOT.clipboard_clear() TK_ROOT.clipboard_append(url) @@ -540,7 +544,7 @@ def show_more_info(): # so it doesn't appear there. hide_context(None) - wid['moreinfo'] = ttk.Button(f, text=_("More Info>>"), command=show_more_info) + wid['moreinfo'] = ttk.Button(f, text=gettext("More Info>>"), command=show_more_info) wid['moreinfo'].grid(row=7, column=2, sticky='e') tooltip.add_tooltip(wid['moreinfo']) @@ -557,13 +561,13 @@ def show_item_props() -> None: wid['changedefaults'] = ttk.Button( f, - text=_("Change Defaults..."), + text=gettext("Change Defaults..."), command=show_item_props, ) wid['changedefaults'].grid(row=7, column=1) tooltip.add_tooltip( wid['changedefaults'], - _('Change the default settings for this item when placed.') + gettext('Change the default settings for this item when placed.') ) wid['variant'] = ttk.Combobox( diff --git a/src/app/dragdrop.py b/src/app/dragdrop.py index 3f15dcc62..6e2ce8617 100644 --- a/src/app/dragdrop.py +++ b/src/app/dragdrop.py @@ -315,7 +315,7 @@ def _start(self, slot: 'Slot[ItemT]', event: tkinter.Event) -> None: sound.fx('config') self._drag_win.deiconify() - self._drag_win.lift(slot._lbl.winfo_toplevel()) + self._drag_win.lift() # grab makes this window the only one to receive mouse events, so # it is guaranteed that it'll drop when the mouse is released. self._drag_win.grab_set_global() diff --git a/src/app/gameMan.py b/src/app/gameMan.py index 7016cef4a..d237e4744 100644 --- a/src/app/gameMan.py +++ b/src/app/gameMan.py @@ -32,12 +32,13 @@ FileSystem, FileSystemChain, ) import srctools.logger -from app import backup, optionWindow, tk_tools, TK_ROOT, resource_gen +import srctools.fgd +from app import backup, tk_tools, resource_gen, TK_ROOT, DEV_MODE +from localisation import gettext import loadScreen import packages.template_brush import editoritems import utils -import srctools from typing import Optional, Union, Any, Type, IO @@ -221,6 +222,13 @@ # still_alive_gutair_cover.wav # want_you_gone_guitar_cover.wav +# HammerAddons tags relevant to P2. +FGD_TAGS = frozenset({ + 'SINCE_HL2', 'SINCE_HLS', 'SINCE_EP1', 'SINCE_EP2', 'SINCE_TF2', + 'SINCE_P1', 'SINCE_L4D', 'SINCE_L4D2', 'SINCE_ASW', 'SINCE_P2', + 'P2', 'UNTIL_CSGO', 'VSCRIPT', 'INST_IO' +}) + def load_filesystems(package_sys: Iterable[FileSystem]) -> None: """Load package filesystems into a chain.""" @@ -490,6 +498,15 @@ def edit_fgd(self, add_lines: bool=False) -> None: del data[i:] break + engine_fgd = srctools.FGD.engine_dbase() + engine_fgd.collapse_bases() + fgd = srctools.FGD() + + for ent in engine_fgd: + if ent.classname.startswith('comp_'): + fgd.entities[ent.classname] = ent + ent.strip_tags(FGD_TAGS) + with atomic_write(fgd_path, overwrite=True, mode='wb') as file: for line in data: file.write(line) @@ -502,7 +519,9 @@ def edit_fgd(self, add_lines: bool=False) -> None: ) with utils.install_path('BEE2.fgd').open('rb') as bee2_fgd: shutil.copyfileobj(bee2_fgd, file) - file.write(imp_res_read_binary(srctools, 'srctools.fgd')) + file_str = io.TextIOWrapper(file, encoding='iso-8859-1') + fgd.export(file_str) + file_str.detach() # Ensure it doesn't close it itself. def cache_invalid(self) -> bool: """Check to see if the cache is valid.""" @@ -543,21 +562,22 @@ def refresh_cache(self, already_copied: set[str]) -> None: if start_folder == 'instances': dest = self.abs_path(INST_PATH + '/' + path) elif start_folder in ('bee2', 'music_samp'): - screen_func('RES') - continue # Skip app icons + screen_func('RES', start_folder) + continue # Skip app icons and music samples. else: - dest = self.abs_path(os.path.join('bee2', start_folder, path)) + # Preserve original casing. + dest = self.abs_path(os.path.join('bee2', file.path)) # Already copied from another package. if dest in already_copied: - screen_func('RES') + screen_func('RES', dest) continue - already_copied.add(dest.casefold()) + already_copied.add(dest) os.makedirs(os.path.dirname(dest), exist_ok=True) with file.open_bin() as fsrc, open(dest, 'wb') as fdest: shutil.copyfileobj(fsrc, fdest) - screen_func('RES') + screen_func('RES', file.path) LOGGER.info('Cache copied.') @@ -569,9 +589,9 @@ def refresh_cache(self, already_copied: set[str]) -> None: # gun instance. if file.endswith(('.vmx', '.mdl_dis', 'tag_coop_gun.vmf')): continue - path = os.path.join(dirpath, file).casefold() + path = os.path.join(dirpath, file) - if path not in already_copied: + if path.casefold() not in already_copied: LOGGER.info('Deleting: {}', path) os.remove(path) @@ -676,14 +696,14 @@ def export( os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) # Start off with the style's data. - vbsp_config = Property(None, []) - vbsp_config += style.config.copy() + vbsp_config = Property.root() + vbsp_config += style.config().copy() all_items = style.items.copy() renderables = style.renderables.copy() resources: dict[str, bytes] = {} - export_screen.step('EXP') + export_screen.step('EXP', 'style-conf') vpk_success = True @@ -708,13 +728,13 @@ def export( # Raised by StyleVPK to indicate it failed to copy. vpk_success = False - export_screen.step('EXP') + export_screen.step('EXP', obj_type.__name__) packages.template_brush.write_templates(self) - export_screen.step('EXP') + export_screen.step('EXP', 'template_brush') vbsp_config.set_key(('Options', 'Game_ID'), self.steamID) - vbsp_config.set_key(('Options', 'dev_mode'), srctools.bool_as_int(optionWindow.DEV_MODE.get())) + vbsp_config.set_key(('Options', 'dev_mode'), srctools.bool_as_int(DEV_MODE.get())) # If there are multiple of these blocks, merge them together. # They will end up in this order. @@ -765,8 +785,8 @@ def export( export_screen.reset() if messagebox.askokcancel( - title=_('BEE2 - Export Failed!'), - message=_( + title=gettext('BEE2 - Export Failed!'), + message=gettext( 'Compiler file {file} missing. ' 'Exit Steam applications, then press OK ' 'to verify your game cache. You can then ' @@ -782,7 +802,7 @@ def export( if should_backup: LOGGER.info('Backing up original {}!', name) shutil.copy(item_path, backup_path) - export_screen.step('BACK') + export_screen.step('BACK', name) # Backup puzzles, if desired backup.auto_backup(selected_game, export_screen) @@ -803,32 +823,32 @@ def export( LOGGER.info('Editing Gameinfo...') self.edit_gameinfo(True) - export_screen.step('EXP') + export_screen.step('EXP', 'gameinfo') if not GEN_OPTS.get_bool('General', 'preserve_bee2_resource_dir'): LOGGER.info('Adding ents to FGD.') self.edit_fgd(True) - export_screen.step('EXP') + export_screen.step('EXP', 'fgd') # atomicwrites writes to a temporary file, then renames in one step. # This ensures editoritems won't be half-written. LOGGER.info('Writing Editoritems script...') with atomic_write(self.abs_path('portal2_dlc2/scripts/editoritems.txt'), overwrite=True, encoding='utf8') as editor_file: editoritems.Item.export(editor_file, all_items, renderables) - export_screen.step('EXP') + export_screen.step('EXP', 'editoritems') LOGGER.info('Writing Editoritems database...') with open(self.abs_path('bin/bee2/editor.bin'), 'wb') as inst_file: pick = pickletools.optimize(pickle.dumps(all_items)) inst_file.write(pick) - export_screen.step('EXP') + export_screen.step('EXP', 'editoritems_db') LOGGER.info('Writing VBSP Config!') os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) with open(self.abs_path('bin/bee2/vbsp_config.cfg'), 'w', encoding='utf8') as vbsp_file: for line in vbsp_config.export(): vbsp_file.write(line) - export_screen.step('EXP') + export_screen.step('EXP', 'vbsp_config') if num_compiler_files > 0: LOGGER.info('Copying Custom Compiler!') @@ -857,8 +877,8 @@ def export( # running. export_screen.reset() messagebox.showerror( - title=_('BEE2 - Export Failed!'), - message=_('Copying compiler file {file} failed. ' + title=gettext('BEE2 - Export Failed!'), + message=gettext('Copying compiler file {file} failed. ' 'Ensure {game} is not running.').format( file=comp_file, game=self.name, @@ -866,7 +886,7 @@ def export( master=TK_ROOT, ) return False, vpk_success - export_screen.step('COMP') + export_screen.step('COMP', str(comp_file)) if should_refresh: LOGGER.info('Copying Resources!') @@ -875,12 +895,12 @@ def export( LOGGER.info('Optimizing editor models...') self.clean_editor_models(all_items) - export_screen.step('EXP') + export_screen.step('EXP', 'editor_models') LOGGER.info('Writing fizzler sides...') self.generate_fizzler_sides(vbsp_config) resource_gen.make_cube_colourizer_legend(Path(self.abs_path('bee2'))) - export_screen.step('EXP') + export_screen.step('EXP', 'fizzler_sides') # Write generated resources, after the regular ones have been copied. for filename, data in resources.items(): @@ -1078,7 +1098,7 @@ def init_trans(self): self.load_trans(lang) - def load_trans(self, lang): + def load_trans(self, lang) -> None: """Actually load the translation.""" # Already loaded if TRANS_DATA: @@ -1107,10 +1127,10 @@ def load_trans(self, lang): if key.startswith('PORTAL2_PuzzleEditor'): TRANS_DATA[key] = value.replace("\\'", "'") - if _('Quit') == '####': + if gettext('Quit') == '####': # Dummy translations installed, apply here too. for key in TRANS_DATA: - TRANS_DATA[key] = _(key) + TRANS_DATA[key] = gettext(key) def find_steam_info(game_dir): @@ -1164,11 +1184,11 @@ def scan_music_locs(): make_tag_coop_inst(loc) except FileNotFoundError: messagebox.showinfo( - message=_('Ap-Tag Coop gun instance not found!\n' + message=gettext('Ap-Tag Coop gun instance not found!\n' 'Coop guns will not work - verify cache to fix.'), parent=TK_ROOT, icon=messagebox.ERROR, - title=_('BEE2 - Aperture Tag Files Missing'), + title=gettext('BEE2 - Aperture Tag Files Missing'), ) MUSIC_TAG_LOC = None else: @@ -1265,7 +1285,6 @@ def load(): LOGGER.warning("Can't parse game: ", exc_info=True) continue all_games.append(new_game) - new_game.edit_gameinfo(True) if len(all_games) == 0: # Hide the loading screen, since it appears on top loadScreen.main_loader.suppress() @@ -1282,14 +1301,15 @@ def add_game(e=None, refresh_menu=True): """Ask for, and load in a game to export to.""" messagebox.showinfo( - message=_('Select the folder where the game executable is located ' - '({appname})...').format(appname='portal2' + EXE_SUFFIX), + message=gettext( + 'Select the folder where the game executable is located ({appname})...' + ).format(appname='portal2' + EXE_SUFFIX), parent=TK_ROOT, - title=_('BEE2 - Add Game'), + title=gettext('BEE2 - Add Game'), ) exe_loc = filedialog.askopenfilename( - title=_('Find Game Exe'), - filetypes=[(_('Executable'), '.exe')], + title=gettext('Find Game Exe'), + filetypes=[(gettext('Executable'), '.exe')], initialdir='C:', ) if exe_loc: @@ -1297,36 +1317,36 @@ def add_game(e=None, refresh_menu=True): gm_id, name = find_steam_info(folder) if name is None or gm_id is None: messagebox.showinfo( - message=_('This does not appear to be a valid game folder!'), + message=gettext('This does not appear to be a valid game folder!'), parent=TK_ROOT, icon=messagebox.ERROR, - title=_('BEE2 - Add Game'), + title=gettext('BEE2 - Add Game'), ) return False # Mel doesn't use PeTI, so that won't make much sense... if gm_id == utils.STEAM_IDS['MEL']: messagebox.showinfo( - message=_("Portal Stories: Mel doesn't have an editor!"), + message=gettext("Portal Stories: Mel doesn't have an editor!"), parent=TK_ROOT, icon=messagebox.ERROR, - title=_('BEE2 - Add Game'), + title=gettext('BEE2 - Add Game'), ) return False invalid_names = [gm.name for gm in all_games] while True: name = tk_tools.prompt( - _('BEE2 - Add Game'), - _("Enter the name of this game:"), + gettext('BEE2 - Add Game'), + gettext("Enter the name of this game:"), initialvalue=name, ) if name in invalid_names: messagebox.showinfo( icon=messagebox.ERROR, parent=TK_ROOT, - message=_('This name is already taken!'), - title=_('BEE2 - Add Game'), + message=gettext('This name is already taken!'), + title=gettext('BEE2 - Add Game'), ) elif name is None: return False @@ -1334,14 +1354,13 @@ def add_game(e=None, refresh_menu=True): messagebox.showinfo( icon=messagebox.ERROR, parent=TK_ROOT, - message=_('Please enter a name for this game!'), - title=_('BEE2 - Add Game'), + message=gettext('Please enter a name for this game!'), + title=gettext('BEE2 - Add Game'), ) else: break new_game = Game(name, gm_id, folder, {}) - new_game.edit_gameinfo(add_line=True) all_games.append(new_game) if refresh_menu: add_menu_opts(game_menu) @@ -1353,13 +1372,13 @@ def remove_game(e=None): """Remove the currently-chosen game from the game list.""" global selected_game lastgame_mess = ( - _("\n (BEE2 will quit, this is the last game set!)") + gettext("\n (BEE2 will quit, this is the last game set!)") if len(all_games) == 1 else "" ) confirm = messagebox.askyesno( title="BEE2", - message=_('Are you sure you want to delete "{}"?').format( + message=gettext('Are you sure you want to delete "{}"?').format( selected_game.name ) + lastgame_mess, ) diff --git a/src/app/helpMenu.py b/src/app/helpMenu.py index a5d3f6067..b57cf49f2 100644 --- a/src/app/helpMenu.py +++ b/src/app/helpMenu.py @@ -10,12 +10,15 @@ from app.richTextBox import tkRichText from app import tkMarkdown, tk_tools, sound, img, TK_ROOT +from localisation import gettext import utils +import srctools # For version info import PIL import platform import mistletoe +import pygtrie class ResIcon(Enum): @@ -54,26 +57,26 @@ def steam_url(name): Res = WebResource WEB_RESOURCES = [ - Res(_('Wiki...'), BEE2_ITEMS_REPO + 'wiki/', ResIcon.BEE2), + Res(gettext('Wiki...'), BEE2_ITEMS_REPO + 'wiki/', ResIcon.BEE2), Res( - _('Original Items...'), + gettext('Original Items...'), 'https://developer.valvesoftware.com/wiki/Category:Portal_2_Puzzle_Maker', ResIcon.PORTAL2, ), # i18n: The chat program. - Res(_('Discord Server...'), DISCORD_SERVER, ResIcon.DISCORD), - Res(_("aerond's Music Changer..."), MUSIC_CHANGER, ResIcon.MUSIC_CHANGER), + Res(gettext('Discord Server...'), DISCORD_SERVER, ResIcon.DISCORD), + Res(gettext("aerond's Music Changer..."), MUSIC_CHANGER, ResIcon.MUSIC_CHANGER), SEPERATOR, - Res(_('Application Repository...'), BEE2_REPO, ResIcon.GITHUB), - Res(_('Items Repository...'), BEE2_ITEMS_REPO, ResIcon.GITHUB), + Res(gettext('Application Repository...'), BEE2_REPO, ResIcon.GITHUB), + Res(gettext('Items Repository...'), BEE2_ITEMS_REPO, ResIcon.GITHUB), SEPERATOR, - Res(_('Submit Application Bugs...'), BEE2_REPO + 'issues/new', ResIcon.BUGS), - Res(_('Submit Item Bugs...'), BEE2_ITEMS_REPO + 'issues/new', ResIcon.BUGS), + Res(gettext('Submit Application Bugs...'), BEE2_REPO + 'issues/new', ResIcon.BUGS), + Res(gettext('Submit Item Bugs...'), BEE2_ITEMS_REPO + 'issues/new', ResIcon.BUGS), SEPERATOR, - Res(_('Portal 2'), steam_url('PORTAL2'), ResIcon.PORTAL2), - Res(_('Aperture Tag'), steam_url('TAG'), ResIcon.TAG), - Res(_('Portal Stories: Mel'), steam_url('MEL'), ResIcon.MEL), - Res(_('Thinking With Time Machine'), steam_url('TWTM'), ResIcon.TWTM), + Res(gettext('Portal 2'), steam_url('PORTAL2'), ResIcon.PORTAL2), + Res(gettext('Aperture Tag'), steam_url('TAG'), ResIcon.TAG), + Res(gettext('Portal Stories: Mel'), steam_url('MEL'), ResIcon.MEL), + Res(gettext('Thinking With Time Machine'), steam_url('TWTM'), ResIcon.TWTM), ] del Res, steam_url @@ -81,13 +84,15 @@ def steam_url(name): CREDITS_TEXT = '''\ Used software / libraries in the BEE2.4: -* [pyglet {pyglet_ver}][pyglet] by Alex Holkner and Contributors -* [Pillow {pil_ver}][pillow] by Alex Clark and Contributors -* [noise (2008-12-15)][perlin_noise] by Casey Duncan -* [mistletoe {mstle_ver}][mistletoe] by Mi Yu and Contributors -* [marisa-trie][marisa] by Susumu Yata and Contributors -* [TKinter {tk_ver}/TTK {ttk_ver}/Tcl {tcl_ver}][tcl] -* [Python {py_ver}][python] +* [srctools][srctools] `v{srctools_ver}` by TeamSpen210 +* [pyglet][pyglet] `{pyglet_ver}` by Alex Holkner and Contributors +* [Pillow][pillow] `{pil_ver}` by Alex Clark and Contributors +* [noise][perlin_noise] `(2008-12-15)` by Casey Duncan +* [mistletoe][mistletoe] `{mstle_ver}` by Mi Yu and Contributors +* [pygtrie][pygtrie] `{pygtrie_ver}` by Michal Nazarewicz +* [TKinter][tcl] `{tk_ver}`/[TTK][tcl] `{ttk_ver}`/[Tcl][tcl] `{tcl_ver}` +* [Python][python] `{py_ver}` +* [FFmpeg][ffmpeg] licensed under the [LGPLv2.1](http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html). Binaries are built via [sudo-nautilus][ffmpeg-bin]. [pyglet]: https://bitbucket.org/pyglet/pyglet/wiki/Home [avbin]: https://avbin.github.io/AVbin/Home/Home.html @@ -95,9 +100,12 @@ def steam_url(name): [perlin_noise]: https://github.com/caseman/noise [squish]: https://github.com/svn2github/libsquish [mistletoe]: https://github.com/miyuchina/mistletoe -[marisa]: https://github.com/s-yata/marisa-trie +[pygtrie]: https://github.com/mina86/pygtrie [tcl]: https://tcl.tk/ [python]: https://www.python.org/ +[FFmpeg]: https://ffmpeg.org/ +[ffmpeg-bin]: https://github.com/sudo-nautilus/FFmpeg-Builds-Win32 +[srctools]: https://github.com/TeamSpen210/srctools ----- @@ -182,44 +190,210 @@ def steam_url(name): ------- -# Marisa-trie - -Copyright (c) marisa-trie authors and contributors, 2012-2016 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR -A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF -CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ------- - -# Libmarisa - -libmarisa and its command line tools are dual-licensed under the BSD 2-clause license and the LGPL. - -#### The BSD 2-clause license - -Copyright (c) 2010-2016, Susumu Yata -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# pygtrie + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. -------- @@ -254,7 +428,9 @@ def steam_url(name): ttk_ver=ttk.__version__, pyglet_ver=sound.pyglet_version, mstle_ver=mistletoe.__version__, + pygtrie_ver=pygtrie.__version__, pil_ver=PIL.__version__, + srctools_ver=srctools.__version__, ).replace('\n', ' \n') # Add two spaces to keep line breaks @@ -295,7 +471,7 @@ def __init__(self, title: str, text: str): ttk.Button( frame, - text=_('Close'), + text=gettext('Close'), command=self.withdraw, ).grid( row=1, column=0, @@ -319,7 +495,7 @@ def make_help_menu(parent: tk.Menu): # Using this name displays this correctly in OS X help = tk.Menu(parent, name='help') - parent.add_cascade(menu=help, label=_('Help')) + parent.add_cascade(menu=help, label=gettext('Help')) icons: Dict[ResIcon, img.Handle] = { icon: img.Handle.sprite('icons/' + icon.value, 16, 16) @@ -329,7 +505,7 @@ def make_help_menu(parent: tk.Menu): icons[ResIcon.NONE] = img.Handle.blank(16, 16) credits = Dialog( - title=_('BEE2 Credits'), + title=gettext('BEE2 Credits'), text=CREDITS_TEXT, ) @@ -346,6 +522,6 @@ def make_help_menu(parent: tk.Menu): help.add_separator() help.add_command( - label=_('Credits...'), + label=gettext('Credits...'), command=credits.show, ) diff --git a/src/app/img.py b/src/app/img.py index 5c4aae3f8..564775252 100644 --- a/src/app/img.py +++ b/src/app/img.py @@ -4,45 +4,45 @@ caching images so repeated requests are cheap. """ from __future__ import annotations -import time -import threading + +from pathlib import Path +from typing import Generic, TypeVar, Union, Callable, Type, cast from collections.abc import Sequence, Mapping -from queue import Queue, Empty as EmptyQueue -from PIL import ImageTk, Image, ImageDraw -import os from weakref import ref as WeakRef -import tkinter as tk from tkinter import ttk -from typing import Generic, TypeVar, Union, Callable, Optional, Type -from app import TK_ROOT +import tkinter as tk +import itertools +import logging + +from PIL import ImageTk, Image, ImageDraw +import attr +import trio from srctools import Vec, Property from srctools.vtf import VTFFlags, VTF from srctools.filesys import FileSystem, RawFileSystem, FileSystemChain -from utils import PackagePath import srctools.logger -import logging + +from app import TK_ROOT import utils -# These are both valid TK image types. -tkImage = Union[ImageTk.PhotoImage, tk.PhotoImage] # Widgets with an image attribute that can be set. tkImgWidgets = Union[tk.Label, ttk.Label, tk.Button, ttk.Button] tkImgWidgetsT = TypeVar('tkImgWidgetsT', tk.Label, ttk.Label, tk.Button, ttk.Button) +WidgetWeakRef = Union['WeakRef[tk.Label], WeakRef[ttk.Label], WeakRef[tk.Button], WeakRef[ttk.Button]'] ArgT = TypeVar('ArgT') # Used to keep track of the used handles, so we can deduplicate them. _handles: dict[tuple, Handle] = {} # Matches widgets to the handle they use. -_wid_tk: dict[WeakRef[tkImgWidgets], Handle] = {} -# Records handles with a loaded image, but no labels using it. -# These will be cleaned up after some time passes. -_pending_cleanup: dict[int, tuple[Handle, float]] = {} +_wid_tk: dict[WidgetWeakRef, Handle] = {} + +# TK images have unique IDs, so preserve discarded image objects. +_unused_tk_img: dict[tuple[int, int], list[tk.PhotoImage]] = {} LOGGER = srctools.logger.get_logger('img') FSYS_BUILTIN = RawFileSystem(str(utils.install_path('images'))) -FSYS_BUILTIN.open_ref() PACK_SYSTEMS: dict[str, FileSystem] = {} # Silence DEBUG messages from Pillow, they don't help. @@ -52,9 +52,6 @@ PETI_ITEM_BG = (229, 232, 233) PETI_ITEM_BG_HEX = '#{:2X}{:2X}{:2X}'.format(*PETI_ITEM_BG) -_queue_load: Queue[Handle] = Queue() -_queue_ui: Queue[Handle] = Queue() - def _load_special(path: str) -> Image.Image: """Various special images we have to load.""" @@ -86,54 +83,70 @@ def _load_special(path: str) -> Image.Image: del _load_icon, _load_icon_flip # Loader handles, which we want to cycle animate. _load_handles: dict[tuple[int, int], Handle] = {} -_last_load_i = -1 # And the last index we used of those. - -def load_filesystems(systems: Mapping[str, FileSystem]) -> None: - """Load in the filesystems used in packages.""" - PACK_SYSTEMS.clear() - for pak_id, sys in systems.items(): - PACK_SYSTEMS[pak_id] = FileSystemChain( - (sys, 'resources/BEE2/'), - (sys, 'resources/materials/'), - (sys, 'resources/materials/models/props_map_editor/'), - ) +# Once initialised, schedule here. +_load_nursery: trio.Nursery | None = None +# Load calls occuring before init. This is done so apply() can be called during import etc, +# and it'll be deferred till later. +_early_loads: set[Handle] = set() -def tuple_size(size: Union[tuple[int, int], int]) -> tuple[int, int]: +def tuple_size(size: tuple[int, int] | int) -> tuple[int, int]: """Return an xy tuple given a size or tuple.""" if isinstance(size, tuple): return size return size, size +def _get_tk_img(width: int, height: int) -> ImageTk.PhotoImage: + """Recycle an old image, or construct a new one.""" + if not width: + width = 16 + if not height: + height = 16 + + # Use setdefault and pop so each step is atomic. + img_list = _unused_tk_img.setdefault((width, height), []) + try: + img = img_list.pop() + except IndexError: + img = ImageTk.PhotoImage('RGBA', (width, height)) + return img + + +def _discard_tk_img(img: ImageTk.PhotoImage | None) -> None: + """Store an unused image so it can be reused.""" + if img is not None: + # Use setdefault and append so each step is atomic. + img_list = _unused_tk_img.setdefault((img.width(), img.height()), []) + img_list.append(img) + + # Special paths which map to various images. -PATH_BLANK = PackagePath('', 'blank') -PATH_ERROR = PackagePath('', 'error') -PATH_LOAD = PackagePath('', 'load') -PATH_NONE = PackagePath('', 'none') -PATH_BG = PackagePath('color', PETI_ITEM_BG_HEX[1:]) -PATH_BLACK = PackagePath('', '000') -PATH_WHITE = PackagePath('', 'fff') +PATH_BLANK = utils.PackagePath('', 'blank') +PATH_ERROR = utils.PackagePath('', 'error') +PATH_LOAD = utils.PackagePath('', 'load') +PATH_NONE = utils.PackagePath('', 'none') +PATH_BG = utils.PackagePath('color', PETI_ITEM_BG_HEX[1:]) +PATH_BLACK = utils.PackagePath('', '000') +PATH_WHITE = utils.PackagePath('', 'fff') class ImageType(Generic[ArgT]): """Represents a kind of image that can be loaded or generated. - This contains callables for generating a PIL or TK image from a specified + This contains callables for generating a PIL image from a specified arg type, width and height. """ def __init__( self, name: str, pil_func: Callable[[ArgT, int, int], Image.Image], - tk_func: Optional[Callable[[ArgT, int, int], tkImage]]=None, allow_raw: bool=False, alpha_result: bool=False, ) -> None: self.name = name self.pil_func = pil_func - self.tk_func = tk_func self.allow_raw = allow_raw self.alpha_result = alpha_result @@ -146,30 +159,14 @@ def _pil_from_color(color: tuple[int, int, int], width: int, height: int) -> Ima return Image.new('RGBA', (width or 16, height or 16), color + (255, )) -def _tk_from_color(color: tuple[int, int, int], width: int, height: int) -> tkImage: - """Directly produce an image of this size with the specified color.""" - r, g, b = color - img = tk.PhotoImage(width=width or 16, height=height or 16) - # Make hex RGB, then set the full image to that. - img.put(f'{{#{r:02X}{g:02X}{b:02X}}}', to=(0, 0, width or 16, height or 16)) - return img - - def _pil_empty(arg: object, width: int, height: int) -> Image.Image: """Produce an image of this size with transparent pixels.""" return Image.new('RGBA', (width or 16, height or 16), (0, 0, 0, 0)) -def _tk_empty(arg: object, width: int, height: int) -> tkImage: - """Produce a TK image of this size which is entirely transparent.""" - img = tk.PhotoImage(width=width or 16, height=height or 16) - img.blank() - return img - - def _load_file( fsys: FileSystem, - uri: PackagePath, + uri: utils.PackagePath, width: int, height: int, resize_algo: int, check_other_packages: bool=False, @@ -180,33 +177,31 @@ def _load_file( path += ".png" image: Image.Image - with fsys: - try: - img_file = fsys[path] - except (KeyError, FileNotFoundError): - img_file = None + try: + img_file = fsys[path] + except (KeyError, FileNotFoundError): + img_file = None # Deprecated behaviour, check the other packages. if img_file is None and check_other_packages: for pak_id, other_fsys in PACK_SYSTEMS.items(): - with other_fsys: - try: - img_file = other_fsys[path] - LOGGER.warning( - 'Image "{}" was found in package "{}", ' - 'fix the reference.', - uri, pak_id, - ) - break - except (KeyError, FileNotFoundError): - pass + try: + img_file = other_fsys[path] + LOGGER.warning( + 'Image "{}" was found in package "{}", ' + 'fix the reference.', + uri, pak_id, + ) + break + except (KeyError, FileNotFoundError): + pass if img_file is None: LOGGER.error('"{}" does not exist!', uri) return Handle.error(width, height).get_pil() try: - with img_file.sys, img_file.open_bin() as file: + with img_file.open_bin() as file: if path.casefold().endswith('.vtf'): vtf = VTF.read(file) mipmap = 0 @@ -239,23 +234,23 @@ def _load_file( return image -def _pil_from_package(uri: PackagePath, width: int, height: int) -> Image.Image: +def _pil_from_package(uri: utils.PackagePath, width: int, height: int) -> Image.Image: """Load from a app package.""" try: fsys = PACK_SYSTEMS[uri.package] except KeyError: - LOGGER.warning('Unknown package "{}" for loading images!', uri.package) - return Handle.error(width, height).load_pil() + LOGGER.warning('Unknown package for loading images: "{}"!', uri) + return Handle.error(width, height).get_pil() return _load_file(fsys, uri, width, height, Image.ANTIALIAS, True) -def _pil_load_builtin(uri: PackagePath, width: int, height: int) -> Image.Image: +def _pil_load_builtin(uri: utils.PackagePath, width: int, height: int) -> Image.Image: """Load from the builtin UI resources.""" return _load_file(FSYS_BUILTIN, uri, width, height, Image.ANTIALIAS) -def _pil_load_builtin_sprite(uri: PackagePath, width: int, height: int) -> Image.Image: +def _pil_load_builtin_sprite(uri: utils.PackagePath, width: int, height: int) -> Image.Image: """Load from the builtin UI resources, but use nearest-neighbour resizing.""" return _load_file(FSYS_BUILTIN, uri, width, height, Image.NEAREST) @@ -279,6 +274,35 @@ def _pil_from_composite(components: Sequence[Handle], width: int, height: int) - return img +@attr.define +class CropInfo: + """Crop parameters.""" + source: Handle + bounds: tuple[int, int, int, int] # left, top, right, bottom coords. + transpose: Image.FLIP_TOP_BOTTOM | Image.FLIP_LEFT_RIGHT | Image.ROTATE_180 | None + + +def _pil_from_crop(info: CropInfo, width: int, height: int) -> Image.Image: + """Crop this image down to part of the source.""" + src_w = info.source.width + src_h = info.source.height + + # noinspection PyProtectedMember + image = info.source._load_pil() + # Shrink down the source to the final source so the bounds apply. + # TODO: Rescale bounds to actual source size to improve result? + if src_w > 0 and src_h > 0 and (src_w, src_h) != image.size: + image = image.resize((src_w, src_h), resample=Image.ANTIALIAS) + + image = image.crop(info.bounds) + if info.transpose is not None: + image = image.transpose(info.transpose) + + if width > 0 and height > 0 and (width, height) != image.size: + image = image.resize((width, height), resample=Image.ANTIALIAS) + return image + + def _pil_icon(arg: str, width: int, height: int) -> Image.Image: """Construct an image with an overlaid icon.""" ico = ICONS[arg] @@ -299,13 +323,14 @@ def _pil_icon(arg: str, width: int, height: int) -> Image.Image: return img -TYP_COLOR = ImageType('color', _pil_from_color, _tk_from_color) -TYP_ALPHA = ImageType('alpha', _pil_empty, _tk_empty, alpha_result=True) +TYP_COLOR = ImageType('color', _pil_from_color) +TYP_ALPHA = ImageType('alpha', _pil_empty, alpha_result=True) TYP_FILE = ImageType('file', _pil_from_package) TYP_BUILTIN_SPR = ImageType('sprite', _pil_load_builtin_sprite, allow_raw=True, alpha_result=True) TYP_BUILTIN = ImageType('builtin', _pil_load_builtin, allow_raw=True, alpha_result=True) TYP_ICON = ImageType('icon', _pil_icon, allow_raw=True) TYP_COMP = ImageType('composite', _pil_from_composite) +TYP_CROP = ImageType('crop', _pil_from_crop) class Handle(Generic[ArgT]): @@ -314,8 +339,8 @@ class Handle(Generic[ArgT]): The args are dependent on the type, and are used to create the image in a background thread. """ - _cached_pil: Optional[Image.Image] - _cached_tk: Optional[tkImage] + _cached_pil: Image.Image | None + _cached_tk: ImageTk.PhotoImage | None def __init__( self, typ: ImageType[ArgT], @@ -332,15 +357,15 @@ def __init__( self._cached_pil = None self._cached_tk = None self._force_loaded = False - self._users: set[Union[WeakRef[tkImgWidgets], Handle]] = set() + self._users: set[WidgetWeakRef | Handle] = set() # If None, get_tk()/get_pil() was used. - # If true, this is in the queue to load. Setting this requires - # the loading lock. + # If true, this is in the queue to load. self._loading = False - self.lock = threading.Lock() + # When no users are present, schedule cleaning up the handle's data to reuse. + self._cancel_cleanup: trio.CancelScope = trio.CancelScope() @classmethod - def _get(cls, typ: ImageType[ArgT], arg: ArgT, width: Union[int, tuple[int, int]], height: int) -> Handle[ArgT]: + def _get(cls, typ: ImageType[ArgT], arg: ArgT, width: int | tuple[int, int], height: int) -> Handle[ArgT]: if isinstance(width, tuple): width, height = width try: @@ -388,12 +413,12 @@ def parse( )) return cls.composite(children, width, height) - return cls.parse_uri(PackagePath.parse(prop.value, pack), width, height, subfolder=subfolder) + return cls.parse_uri(utils.PackagePath.parse(prop.value, pack), width, height, subfolder=subfolder) @classmethod def parse_uri( cls, - uri: PackagePath, + uri: utils.PackagePath, width: int = 0, height: int = 0, *, subfolder: str='', @@ -438,19 +463,19 @@ def parse_uri( if ',' in color: r, g, b = map(int, color.split(',')) elif len(color) == 3: - r = int(uri.path[0] * 2, 16) - g = int(uri.path[1] * 2, 16) - b = int(uri.path[2] * 2, 16) + r = int(color[0] * 2, 16) + g = int(color[1] * 2, 16) + b = int(color[2] * 2, 16) elif len(color) == 6: - r = int(uri.path[0:2], 16) - g = int(uri.path[2:4], 16) - b = int(uri.path[4:6], 16) + r = int(color[0:2], 16) + g = int(color[2:4], 16) + b = int(color[4:6], 16) else: raise ValueError except (ValueError, TypeError, OverflowError): # Try to grab from TK's colour list. try: - r, g, b = TK_ROOT.winfo_rgb(uri.path) + r, g, b = TK_ROOT.winfo_rgb(color) # They're full 16-bit colors, we don't want that. r >>= 8 g >>= 8 @@ -470,17 +495,17 @@ def parse_uri( return cls._get(typ, args, width, height) @classmethod - def builtin(cls, path: str, width: int = 0, height: int = 0) -> Handle: + def builtin(cls, path: str, width: int = 0, height: int = 0) -> Handle[utils.PackagePath]: """Shortcut for getting a handle to a builtin UI image.""" - return cls._get(TYP_BUILTIN, PackagePath('', path + '.png'), width, height) + return cls._get(TYP_BUILTIN, utils.PackagePath('', path + '.png'), width, height) @classmethod - def sprite(cls, path: str, width: int = 0, height: int = 0) -> Handle: + def sprite(cls, path: str, width: int = 0, height: int = 0) -> Handle[utils.PackagePath]: """Shortcut for getting a handle to a builtin UI image, but with nearest-neighbour rescaling.""" - return cls._get(TYP_BUILTIN_SPR, PackagePath('', path + '.png'), width, height) + return cls._get(TYP_BUILTIN_SPR, utils.PackagePath('', path + '.png'), width, height) @classmethod - def composite(cls, children: Sequence[Handle], width: int = 0, height: int = 0) -> Handle: + def composite(cls, children: Sequence[Handle], width: int = 0, height: int = 0) -> Handle[Sequence[Handle]]: """Return a handle composed of several images layered on top of each other.""" if not children: return cls.error(width, height) @@ -497,23 +522,32 @@ def composite(cls, children: Sequence[Handle], width: int = 0, height: int = 0) handle = _handles[TYP_COMP, key, width, height] = Handle(TYP_COMP, children, width, height) return handle + def crop( + self, + bounds: tuple[int, int, int, int], + transpose: int | None = None, + width: int = 0, height: int = 0, + ) -> Handle[Sequence[Handle]]: + """Wrap a handle to crop it into a smaller size.""" + return Handle(TYP_CROP, CropInfo(self, bounds, transpose), width, height) + @classmethod - def file(cls, path: PackagePath, width: int, height: int) -> Handle: + def file(cls, path: utils.PackagePath, width: int, height: int) -> Handle[utils.PackagePath]: """Shortcut for getting a handle to file path.""" return cls._get(TYP_FILE, path, width, height) @classmethod - def error(cls, width: int, height: int) -> Handle: + def error(cls, width: int, height: int) -> Handle[str]: """Shortcut for getting a handle to an error icon.""" return cls._get(TYP_ICON, 'error', width, height) @classmethod - def ico_none(cls, width: int, height: int) -> Handle: + def ico_none(cls, width: int, height: int) -> Handle[str]: """Shortcut for getting a handle to a 'none' icon.""" return cls._get(TYP_ICON, 'none', width, height) @classmethod - def ico_loading(cls, width: int, height: int) -> Handle: + def ico_loading(cls, width: int, height: int) -> Handle[str]: """Shortcut for getting a handle to a 'loading' icon.""" try: return _load_handles[width, height] @@ -528,7 +562,7 @@ def blank(cls, width: int, height: int) -> Handle: return cls._get(TYP_ALPHA, None, width, height) @classmethod - def color(cls, color: Union[tuple[int, int, int], Vec], width: int, height: int) -> Handle: + def color(cls, color: tuple[int, int, int] | Vec, width: int, height: int) -> Handle[tuple[int, int, int]]: """Shortcut for getting a handle to a solid color.""" if isinstance(color, Vec): # Convert. @@ -537,16 +571,17 @@ def color(cls, color: Union[tuple[int, int, int], Vec], width: int, height: int) def get_pil(self) -> Image.Image: """Load the PIL image if required, then return it.""" - with self.lock: - if self.type.allow_raw: - # Force load, so it's always ready. - self._force_loaded = True - elif not self._users: - # Loading something unused, schedule it to be cleaned. - _pending_cleanup[id(self)] = (self, time.monotonic()) - return self._load_pil() - - def get_tk(self) -> tkImage: + if self.type.allow_raw: + # Force load, so it's always ready. + self._force_loaded = True + elif not self._users and _load_nursery is not None: + # Loading something unused, schedule it to be cleaned soon. + self._cancel_cleanup.cancel() + self._cancel_cleanup = trio.CancelScope() + _load_nursery.start_soon(self._cleanup_task, self._cancel_cleanup) + return self._load_pil() + + def get_tk(self) -> ImageTk.PhotoImage: """Load the TK image if required, then return it. Only available on BUILTIN type images since they cannot then be @@ -558,50 +593,53 @@ def get_tk(self) -> tkImage: return self._load_tk() def _load_pil(self) -> Image.Image: + """Load the PIL image if required, then return it.""" if self._cached_pil is None: self._cached_pil = self.type.pil_func(self.arg, self.width, self.height) return self._cached_pil - def _load_tk(self) -> tkImage: - """Load the TK image if required, then return it. - - Should not be used if possible, to allow deferring loads to the - background. - """ + def _load_tk(self) -> ImageTk.PhotoImage: + """Load the TK image if required, then return it.""" if self._cached_tk is None: # LOGGER.debug('Loading {}', self) - if self.type.tk_func is None: - res = self._load_pil() - # Except for builtin types (icons), strip alpha. - if not self.type.alpha_result: - res = res.convert('RGB') - self._cached_tk = ImageTk.PhotoImage(image=res) - else: - self._cached_tk = self.type.tk_func(self.arg, self.width, self.height) + res = self._load_pil() + # Except for builtin types (icons), strip alpha. + if not self.type.alpha_result: + res = res.convert('RGB') + self._cached_tk = _get_tk_img(res.width, res.height) + self._cached_tk.paste(res) return self._cached_tk - def _decref(self, ref: 'Union[WeakRef[tkImgWidgets], Handle]') -> None: + def _decref(self, ref: 'WidgetWeakRef | Handle') -> None: """A label was no longer set to this handle.""" if self._force_loaded or (self._cached_tk is None and self._cached_pil is None): return self._users.discard(ref) if self.type is TYP_COMP: - for child in self.arg: # type: Handle + for child in cast('Sequence[Handle]', self.arg): child._decref(self) - if not self._users: - _pending_cleanup[id(self)] = (self, time.monotonic()) - - def _incref(self, ref: 'Union[WeakRef[tkImgWidgets], Handle]') -> None: + elif self.type is TYP_CROP: + cast(CropInfo, self.arg).source._decref(self) + if not self._users and _load_nursery is not None: + # Schedule this handle to be cleaned up, and store a cancel scope so that + # can be aborted. + self._cancel_cleanup = trio.CancelScope() + _load_nursery.start_soon(self._cleanup_task, self._cancel_cleanup) + + def _incref(self, ref: 'WidgetWeakRef | Handle') -> None: """Add a label to the list of those controlled by us.""" if self._force_loaded: return self._users.add(ref) - _pending_cleanup.pop(id(self), None) + # Abort cleaning up if we were planning to. + self._cancel_cleanup.cancel() if self.type is TYP_COMP: - for child in self.arg: # type: Handle + for child in cast('Sequence[Handle]', self.arg): child._incref(self) + elif self.type is TYP_CROP: + cast(CropInfo, self.arg).source._incref(self) - def _request_load(self) -> tkImage: + def _request_load(self) -> ImageTk.PhotoImage: """Request a reload of this image. If this can be done synchronously, the result is returned. @@ -609,95 +647,121 @@ def _request_load(self) -> tkImage: """ if self._loading is True: return Handle.ico_loading(self.width, self.height).get_tk() - with self.lock: - if self._cached_tk is not None: - return self._cached_tk - if self._loading is False: - self._loading = True - _queue_load.put(self) + if self._cached_tk is not None: + return self._cached_tk + if self._loading is False: + self._loading = True + if _load_nursery is None: + _early_loads.add(self) + else: + _load_nursery.start_soon(self._load_task) return Handle.ico_loading(self.width, self.height).get_tk() - -def _label_destroyed(ref: WeakRef[tkImgWidgets]) -> None: + async def _load_task(self) -> None: + """Scheduled to load images then apply to the labels.""" + await trio.to_thread.run_sync(self._load_pil) + self._loading = False + tk_ico = self._load_tk() + for label_ref in self._users: + if isinstance(label_ref, WeakRef): + label: tkImgWidgets | None = label_ref() + if label is not None: + try: + label['image'] = tk_ico + except tk.TclError: + # Can occur if the image has been removed/destroyed, but + # the Python object still exists. Ignore, should be + # cleaned up shortly. + pass + + async def _cleanup_task(self, scope: trio.CancelScope) -> None: + """Wait for the time to elapse, then clear the contents.""" + with scope: + await trio.sleep(5) + # We weren't cancelled and are empty, cleanup. + if not scope.cancel_called and self._loading is not None and not self._users: + _discard_tk_img(self._cached_tk) + self._cached_tk = self._cached_pil = None + + +def _label_destroyed(ref: WeakRef[tkImgWidgetsT]) -> None: """Finaliser for _wid_tk keys. Removes them from the dict, and decreases the usage count on the handle. """ try: handle = _wid_tk.pop(ref) - except (KeyError, TypeError): + except (KeyError, TypeError, NameError): + # Interpreter could be shutting down and deleted globals, or we were + # called twice, etc. Just ignore. pass else: handle._decref(ref) # noinspection PyProtectedMember -def _background_task() -> None: - """Background task doing the actual loading.""" - while True: - handle = _queue_load.get() - with handle.lock: - if handle._loading is True: - handle._load_pil() - handle._loading = False - _queue_ui.put(handle) +async def _spin_load_icons() -> None: + """Cycle loading icons.""" + fnames = [ + f'load_{i}' + for i in range(8) + ] + for load_name in itertools.cycle(fnames): + await trio.sleep(0.125) + for handle in _load_handles.values(): + handle.arg = load_name + handle._cached_pil = None + if handle._cached_tk is not None: + # This updates the TK widget directly. + handle._cached_tk.paste(handle._load_pil()) # noinspection PyProtectedMember -def _ui_task() -> None: - """Background task which does TK calls. - - TK must run in the main thread, so we do UI loads here. - """ - global _last_load_i - # Use the current time to set the frame also. - load_i = int((time.monotonic() % 1.0) * 8) - if load_i != _last_load_i: - _last_load_i = load_i - arg = f'load_{load_i}' - for handle in _load_handles.values(): - handle.arg = arg - with handle.lock: - handle._cached_pil = None - if handle._cached_tk is not None: - # This updates the TK widget directly. - handle._cached_tk.paste(handle._load_pil()) - - timeout = time.monotonic() - # Run, but if we go over 100ms, abort so the rest of the UI loop can run. - while time.monotonic() - timeout < 0.1: - try: - handle = _queue_ui.get_nowait() - except EmptyQueue: - break - tk_ico = handle._load_tk() - for label_ref in handle._users: - if isinstance(label_ref, WeakRef): - label: Optional[tkImgWidgets] = label_ref() - if label is not None: - label['image'] = tk_ico +async def init(filesystems: Mapping[str, FileSystem]) -> None: + """Load in the filesystems used in package and start the background loading.""" + global _load_nursery - for handle, use_time in list(_pending_cleanup.values()): - with handle.lock: - if use_time < timeout - 5.0 and handle._loading is not None and not handle._users: - del _pending_cleanup[id(handle)] - handle._cached_tk = handle._cached_pil = None - TK_ROOT.tk.call(_ui_task_cmd) + PACK_SYSTEMS.clear() + for pak_id, sys in filesystems.items(): + PACK_SYSTEMS[pak_id] = FileSystemChain( + (sys, 'resources/BEE2/'), + (sys, 'resources/materials/'), + (sys, 'resources/materials/models/props_map_editor/'), + ) -# Cache the registered ID, so we don't have to re-register. -_ui_task_cmd = ('after', 100, TK_ROOT.register(_ui_task)) -_bg_thread = threading.Thread(name='imghandle_load', target=_background_task) -_bg_thread.daemon = True + async with trio.open_nursery() as _load_nursery: + LOGGER.debug('Early loads: {}', _early_loads) + while _early_loads: + handle = _early_loads.pop() + if handle._users: + _load_nursery.start_soon(Handle._load_task, handle) + _load_nursery.start_soon(_spin_load_icons) + await trio.sleep_forever() -def start_loading() -> None: - """Start the background loading threads.""" - _bg_thread.start() - TK_ROOT.tk.call(_ui_task_cmd) +# noinspection PyProtectedMember +def refresh_all() -> None: + """Force all images to reload.""" + LOGGER.info('Forcing all images to reload!') + done = 0 + for handle in list(_handles.values()): + # If force-loaded it's builtin UI etc we shouldn't reload. + # If already loading, no point. + if not handle._force_loaded and not handle._loading: + _discard_tk_img(handle._cached_tk) + handle._cached_tk = handle._cached_pil = None + loading = handle._request_load() + done += 1 + for label_ref in handle._users: + if isinstance(label_ref, WeakRef): + label: tkImgWidgets | None = label_ref() + if label is not None: + label['image'] = loading + LOGGER.info('Queued {} images to reload.', done) # noinspection PyProtectedMember -def apply(widget: tkImgWidgetsT, img: Optional[Handle]) -> tkImgWidgetsT: +def apply(widget: tkImgWidgetsT, img: Handle | None) -> tkImgWidgetsT: """Set the image in a widget. This tracks the widget, so later reloads will affect the widget. @@ -718,6 +782,9 @@ def apply(widget: tkImgWidgetsT, img: Optional[Handle]) -> tkImgWidgetsT: except KeyError: pass else: + if old is img: + # Unchanged. + return widget old._decref(ref) img._incref(ref) _wid_tk[ref] = img @@ -748,11 +815,14 @@ def make_splash_screen( It then adds the gradients on top. """ import random - folder = str(utils.install_path('images/splash_screen')) - path = '' + folder = utils.install_path('images/splash_screen') + user_folder = folder / 'user' + path = Path('') + if user_folder.exists(): + folder = user_folder try: - path = random.choice(os.listdir(folder)) - with open(os.path.join(folder, path), 'rb') as img_file: + path = random.choice(list(folder.iterdir())) + with path.open('rb') as img_file: image = Image.open(img_file) image.load() except (FileNotFoundError, IndexError, IOError): diff --git a/src/app/itemPropWin.py b/src/app/itemPropWin.py index faaca0dba..8f4823fbf 100644 --- a/src/app/itemPropWin.py +++ b/src/app/itemPropWin.py @@ -1,16 +1,18 @@ -from tkinter import * # ui library - -from tkinter import ttk # themed ui components that match the OS +"""Window for adjusting the default values of item properties.""" +from __future__ import annotations +import tkinter as tk +from tkinter import ttk from functools import partial as func_partial from enum import Enum +from typing import Callable, Any import random import utils import srctools from app import contextWin, gameMan, tk_tools, sound, TK_ROOT +from localisation import gettext import srctools.logger -from typing import Dict, List, Union, Any LOGGER = srctools.logger.get_logger(__name__) @@ -27,7 +29,7 @@ class PropTypes(Enum): PANEL = 'panel' GELS = 'gelType' OSCILLATE = 'track' - + @property def is_editable(self) -> bool: """Check if the user can change this property type.""" @@ -36,9 +38,9 @@ def is_editable(self) -> bool: # All properties in editoritems, Valve probably isn't going to # release a major update so it's fine to hardcode this. PROP_TYPES = { - 'toplevel': (PropTypes.PISTON, _('Start Position')), - 'bottomlevel': (PropTypes.PISTON, _('End Position')), - 'timerdelay': (PropTypes.TIMER, _('Delay \n(0=infinite)')), + 'toplevel': (PropTypes.PISTON, gettext('Start Position')), + 'bottomlevel': (PropTypes.PISTON, gettext('End Position')), + 'timerdelay': (PropTypes.TIMER, gettext('Delay \n(0=infinite)')), 'angledpanelanimation': (PropTypes.PANEL, 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type'), 'paintflowtype': (PropTypes.GELS, 'PORTAL2_PuzzleEditor_ContextMenu_paint_flow_type'), @@ -95,7 +97,7 @@ def is_editable(self) -> bool: 'angledpanelanimation', 'paintflowtype', 'timerdelay', - ] +] PROP_POS = [ 'allowstreak', 'startenabled', @@ -108,19 +110,19 @@ def is_editable(self) -> bool: 'dropperenabled', 'autodrop', 'autorespawn', - ] +] # holds the checkbox or other item used to manipulate the box -widgets = {} # type: Dict[str, Any] +widgets: dict[str, Any] = {} # holds the descriptive labels for each property -labels = {} # type: Dict[str, ttk.Label] +labels: dict[str, ttk.Label] = {} # The properties we currently have displayed. -propList = [] # type: List[str] +propList: list[str] = [] # selected values for this items -values = {} # type: Dict[str, Union[Variable, str, float]] -out_values = {} # type: Dict[str, Union[Variable, str, float]] +values: dict[str, tk.Variable | str | float] = {} +out_values: dict[str, tk.Variable | str | float] = {} PAINT_OPTS = [ 'PORTAL2_PuzzleEditor_ContextMenu_paint_flow_type_light', @@ -128,13 +130,13 @@ def is_editable(self) -> bool: 'PORTAL2_PuzzleEditor_ContextMenu_paint_flow_type_heavy', 'PORTAL2_PuzzleEditor_ContextMenu_paint_flow_type_drip', 'PORTAL2_PuzzleEditor_ContextMenu_paint_flow_type_bomb', - ] +] PANEL_ANGLES = [ - ('30', 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_30'), - ('45', 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_45'), - ('60', 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_60'), - ('90', 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_90'), + (30, 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_30'), + (45, 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_45'), + (60, 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_60'), + (90, 'PORTAL2_PuzzleEditor_ContextMenu_angled_panel_type_90'), ] DEFAULTS = { # default values for this item @@ -156,35 +158,47 @@ def is_editable(self) -> bool: 'paintflowtype': 1, 'allowstreak': True } +# Used for us to produce the appropriate changing sound. +last_angle: int = 0 -last_angle = '0' - -is_open = False # ttk.Scale works on floating point values, # so it can be put partway. We need to suppress calling our callbacks # whilst we fix it. enable_tim_callback = True enable_pist_callback = True +win = tk.Toplevel(TK_ROOT) +win.transient(TK_ROOT) +win.wm_attributes('-topmost', True) +win.withdraw() + -def callback(name): - """Do nothing by default!""" +def callback(props: dict[str, str]) -> None: + """Called when the window is closed, to apply properties.""" pass -def scroll_angle(key, e): +def is_visible() -> bool: + """Check if the window is visible.""" + return win.winfo_ismapped() + + +def scroll_angle(key: str, e: tk.Event) -> None: + """Change callback for panel angles.""" if e.delta > 0 and widgets[key].get() != '90': e.widget.invoke('buttonup') elif e.delta < 0 and widgets[key].get() != '0': e.widget.invoke('buttondown') -def save_paint(key, val): +def save_paint(key: str, val: str) -> None: + """Save callback for paint options.""" sound.fx_blockable('config') out_values[key] = val -def save_angle(key, new_angle): +def save_angle(key: str, new_angle: int) -> None: + """Change callback for angle properties.""" global last_angle if new_angle > last_angle: sound.fx_blockable('raise_' + random.choice('123')) @@ -194,7 +208,8 @@ def save_angle(key, new_angle): out_values[key] = 'ramp_' + str(new_angle) + '_deg_open' -def save_tim(key, val): +def save_tim(key: str, val: str) -> None: + """Change callback for TimerDelay.""" global enable_tim_callback if enable_tim_callback: new_val = round(float(val)) @@ -217,7 +232,7 @@ def save_tim(key, val): out_values[key] = str(new_val) -def save_pist(key, val): +def save_pist(key: str, val: str) -> None: """The top and bottom positions are closely interrelated.""" global enable_pist_callback if not enable_pist_callback: @@ -264,7 +279,7 @@ def save_rail(key) -> None: widgets['startactive'].state(['!disabled']) -def toggleCheck(key, var, e=None): +def toggle_check(key: str, var: tk.IntVar, _: tk.Event=None) -> None: """Toggle a checkbox.""" if var.get(): var.set(0) @@ -273,35 +288,34 @@ def toggleCheck(key, var, e=None): set_check(key) -def set_check(key): +def set_check(key: str) -> None: + """Generic change callback for checkboxes.""" sound.fx_blockable('config') out_values[key] = str(values[key].get()) -def exit_win(e=None) -> None: +def exit_win(_: tk.Event=None) -> None: """Quit and save the new settings.""" - global is_open win.grab_release() win.withdraw() - is_open = False out = {} for key in propList: if key in PROP_TYPES: # Use out_values if it has a matching key, # or use values by default. out_val = out_values.get(key, values[key]) - if isinstance(out_val, Variable): + if isinstance(out_val, tk.Variable): out[key] = str(out_val.get()) else: out[key] = out_val callback(out) - if contextWin.is_open: + if contextWin.is_visible(): # Restore the context window if we hid it earlier. contextWin.window.deiconify() -def can_edit(prop_list): +def can_edit(prop_list: dict[str, str]) -> bool: """Determine if any of these properties are changeable.""" for prop in prop_list: prop_type, prop_name = PROP_TYPES.get(prop, (PropTypes.NONE, '')) @@ -310,17 +324,15 @@ def can_edit(prop_list): return False -def init(cback): - global callback, labels, win, is_open +def init(cback: Callable[[dict[str, str]], None]) -> None: + """Build the properties window widgets.""" + global callback callback = cback - is_open = False - win = Toplevel(TK_ROOT) + win.title("BEE2") win.resizable(False, False) tk_tools.set_window_icon(win) win.protocol("WM_DELETE_WINDOW", exit_win) - win.transient(TK_ROOT) - win.withdraw() if utils.MAC: # Switch to use the 'modal' window style on Mac. @@ -339,8 +351,8 @@ def init(cback): frame.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) - labels['noOptions'] = ttk.Label(frame, text=_('No Properties available!')) - widgets['saveButton'] = ttk.Button(frame, text=_('Close'), command=exit_win) + labels['noOptions'] = ttk.Label(frame, text=gettext('No Properties available!')) + widgets['saveButton'] = ttk.Button(frame, text=gettext('Close'), command=exit_win) widgets['titleLabel'] = ttk.Label(frame, text='') widgets['titleLabel'].grid(columnspan=9) @@ -355,7 +367,7 @@ def init(cback): labels[key] = ttk.Label(frame, text=prop_name) if prop_type is PropTypes.CHECKBOX: - values[key] = IntVar(value=DEFAULTS[key]) + values[key] = tk.IntVar(value=DEFAULTS[key]) out_values[key] = srctools.bool_as_int(DEFAULTS[key]) widgets[key] = ttk.Checkbutton( frame, @@ -365,39 +377,39 @@ def init(cback): widgets[key].bind( '', func_partial( - toggleCheck, + toggle_check, key, values[key], ) ) elif prop_type is PropTypes.OSCILLATE: - values[key] = IntVar(value=DEFAULTS[key]) + values[key] = tk.IntVar(value=DEFAULTS[key]) out_values[key] = srctools.bool_as_int(DEFAULTS[key]) widgets[key] = ttk.Checkbutton( frame, variable=values[key], command=func_partial(save_rail, key), - ) + ) elif prop_type is PropTypes.PANEL: frm = ttk.Frame(frame) widgets[key] = frm - values[key] = StringVar(value=DEFAULTS[key]) + values[key] = tk.StringVar(value=DEFAULTS[key]) for pos, (angle, disp_angle) in enumerate(PANEL_ANGLES): ttk.Radiobutton( frm, variable=values[key], - value=angle, + value=str(angle), text=gameMan.translate(disp_angle), command=func_partial(save_angle, key, angle), - ).grid(row=0, column=pos) + ).grid(row=0, column=pos) frm.columnconfigure(pos, weight=1) elif prop_type is PropTypes.GELS: frm = ttk.Frame(frame) widgets[key] = frm - values[key] = IntVar(value=DEFAULTS[key]) + values[key] = tk.IntVar(value=DEFAULTS[key]) for pos, text in enumerate(PAINT_OPTS): ttk.Radiobutton( frm, @@ -405,7 +417,7 @@ def init(cback): value=pos, text=gameMan.translate(text), command=func_partial(save_paint, key, pos), - ).grid(row=0, column=pos) + ).grid(row=0, column=pos) frm.columnconfigure(pos, weight=1) out_values[key] = str(DEFAULTS[key]) @@ -416,7 +428,7 @@ def init(cback): to=4, orient="horizontal", command=func_partial(save_pist, key), - ) + ) values[key] = DEFAULTS[key] out_values[key] = str(DEFAULTS[key]) if ((key == 'toplevel' and DEFAULTS['startup']) or @@ -444,10 +456,10 @@ def init(cback): values['startup'] = DEFAULTS['startup'] -def show_window(used_props, parent, item_name): - global is_open, last_angle +def show_window(used_props: dict[str, str], parent: tk.Toplevel, item_name: str) -> None: + """Show the item property changing window.""" + global last_angle propList[:] = [key.casefold() for key in used_props] - is_open = True spec_row = 1 start_up = srctools.conv_bool(used_props.get('startup', '0')) @@ -468,7 +480,7 @@ def show_window(used_props, parent, item_name): elif prop_type is PropTypes.GELS: values[prop].set(value) elif prop_type is PropTypes.PANEL: - last_angle = value[5:7] + last_angle = int(value[5:7]) values[prop].set(last_angle) out_values[prop] = value elif prop_type is PropTypes.PISTON: @@ -481,20 +493,10 @@ def show_window(used_props, parent, item_name): else: if ((prop == 'toplevel' and start_up) or (prop == 'bottomlevel' and not start_up)): - widgets[prop].set( - max( - top_level, - bot_level, - ) - ) + widgets[prop].set(max(top_level, bot_level)) if ((prop == 'toplevel' and not start_up) or (prop == 'bottomlevel' and start_up)): - widgets[prop].set( - min( - top_level, - bot_level, - ) - ) + widgets[prop].set(min(top_level, bot_level)) elif prop_type is PropTypes.TIMER: try: values[prop] = int(value) @@ -512,7 +514,7 @@ def show_window(used_props, parent, item_name): labels[key].grid( row=spec_row, column=0, - sticky=E, + sticky='e', padx=2, pady=5, ) @@ -528,7 +530,7 @@ def show_window(used_props, parent, item_name): else: labels[key].grid_remove() widgets[key].grid_remove() -# if we have a 'special' prop, add the divider between the types + # if we have a 'special' prop, add the divider between the types if spec_row > 1: widgets['div_h'].grid( row=spec_row + 1, @@ -546,14 +548,14 @@ def show_window(used_props, parent, item_name): labels[key].grid( row=(ind // 3) + spec_row, column=(ind % 3) * 3, - sticky=E, + sticky=tk.E, padx=2, pady=5, ) widgets[key].grid( row=(ind // 3) + spec_row, column=(ind % 3)*3 + 1, - sticky="EW", + sticky="ew", padx=2, pady=5, ) @@ -566,7 +568,7 @@ def show_window(used_props, parent, item_name): widgets['div_1'].grid( row=spec_row, column=2, - sticky="NS", + sticky="ns", rowspan=(ind//3) + 1 ) else: @@ -599,8 +601,8 @@ def show_window(used_props, parent, item_name): # playing sound.block_fx() - widgets['titleLabel'].configure(text='Settings for "' + item_name + '"') - win.title('BEE2 - ' + item_name) + widgets['titleLabel'].configure(text=gettext('Settings for "{}"').format(item_name)) + win.title(gettext('BEE2 - {}').format(item_name)) win.deiconify() win.lift(parent) win.grab_set() @@ -610,7 +612,7 @@ def show_window(used_props, parent, item_name): '+' + str(parent.winfo_rooty() - win.winfo_reqheight() - 30) ) - if contextWin.is_open: + if contextWin.is_visible(): # Temporarily hide the context window while we're open. contextWin.window.withdraw() diff --git a/src/app/item_search.py b/src/app/item_search.py index 23e8352e3..4ca422974 100644 --- a/src/app/item_search.py +++ b/src/app/item_search.py @@ -8,6 +8,7 @@ from pygtrie import CharTrie from app import UI, TK_ROOT +from localisation import gettext LOGGER = srctools.logger.get_logger(__name__) word_to_ids: 'CharTrie[Set[Tuple[str, int]]]' = CharTrie() @@ -63,10 +64,7 @@ def trigger_cback() -> None: frm.columnconfigure(1, weight=1) - ttk.Label( - frm, - text=_('Search:'), - ).grid(row=0, column=0) + ttk.Label(frm, text=gettext('Search:')).grid(row=0, column=0) search_var = tk.StringVar() search_var.trace_add('write', on_type) diff --git a/src/app/itemconfig.py b/src/app/itemconfig.py index cc23b8a77..ff4b5d960 100644 --- a/src/app/itemconfig.py +++ b/src/app/itemconfig.py @@ -2,6 +2,7 @@ from __future__ import annotations import tkinter as tk +import trio from tkinter import ttk from tkinter.colorchooser import askcolor from functools import lru_cache @@ -15,6 +16,7 @@ import utils import srctools.logger from app import signage_ui, UI, tkMarkdown, sound, img, tk_tools +from localisation import gettext from typing import Union, Callable, List, Tuple, Optional @@ -56,43 +58,45 @@ def parse_color(color: str) -> Tuple[int, int, int]: return r, g, b -@BEE2_config.option_handler('ItemVar') -def save_load_itemvar(prop: Property=None) -> Optional[Property]: - """Save or load item variables into the palette.""" - if prop is None: - prop = Property('', []) - for group in CONFIG_ORDER: - conf = Property(group.id, []) - for widget in group.widgets: - if widget.has_values: - conf.append(Property(widget.id, widget.values.get())) - for widget in group.multi_widgets: - conf.append(Property(widget.id, [ - Property(str(tim_val), var.get()) - for tim_val, var in - widget.values - ])) - prop.append(conf) - return prop - else: - # Loading. - for group in CONFIG_ORDER: - conf = prop.find_key(group.id, []) - for widget in group.widgets: - if widget.has_values: - try: - widget.values.set(conf[widget.id]) - except LookupError: - pass - - for widget in group.multi_widgets: - time_conf = conf.find_key(widget.id, []) - for tim_val, var in widget.values: - try: - var.set(time_conf[str(tim_val)]) - except LookupError: - pass - return None +@BEE2_config.OPTION_SAVE('ItemVar') +def save_itemvar() -> Property: + """Save item variables into the palette.""" + prop = Property('', []) + for group in CONFIG_ORDER: + conf = Property(group.id, []) + for widget in group.widgets: + if widget.has_values: + conf.append(Property(widget.id, widget.values.get())) + for widget in group.multi_widgets: + conf.append(Property(widget.id, [ + Property(str(tim_val), var.get()) + for tim_val, var in + widget.values + ])) + prop.append(conf) + return prop + + +@BEE2_config.OPTION_LOAD('ItemVar') +def load_itemvar(prop: Property) -> None: + """Load item variables into the palette.""" + for group in CONFIG_ORDER: + conf = prop.find_block(group.id, or_blank=True) + for widget in group.widgets: + if widget.has_values: + try: + widget.values.set(conf[widget.id]) + except LookupError: + pass + + for widget in group.multi_widgets: + time_conf = conf.find_block(widget.id, or_blank=True) + for tim_val, var in widget.values: + try: + var.set(time_conf[str(tim_val)]) + except LookupError: + pass + return None @attr.define @@ -131,8 +135,8 @@ def __init__( self.multi_widgets = multi_widgets @classmethod - def parse(cls, data: ParseData) -> 'PakObject': - props = data.info # type: Property + async def parse(cls, data: ParseData) -> 'PakObject': + props = data.info if data.is_override: # Override doesn't have a name @@ -146,6 +150,7 @@ def parse(cls, data: ParseData) -> 'PakObject': multi_widgets = [] # type: List[Widget] for wid in props.find_all('Widget'): + await trio.sleep(0) try: create_func = WidgetLookup[wid['type']] except KeyError: @@ -322,9 +327,14 @@ def make_pane(parent: ttk.Frame): wid_frame.grid(row=row, column=0, sticky='ew') wid_frame.columnconfigure(1, weight=1) + try: + widget = wid.create_func(wid_frame, wid.values, wid.config) + except Exception: + LOGGER.exception('Could not construct widget {}.{}', config.id, wid.id) + continue + label = ttk.Label(wid_frame, text=wid.name + ': ') label.grid(row=0, column=0) - widget = wid.create_func(wid_frame, wid.values, wid.config) widget.grid(row=0, column=1, sticky='e') if wid.tooltip: @@ -609,7 +619,7 @@ def open_win(e) -> None: new_color, tk_color = askcolor( color=(r, g, b), parent=parent.winfo_toplevel(), - title=_('Choose a Color'), + title=gettext('Choose a Color'), ) if new_color is not None: r, g, b = map(int, new_color) # Returned as floats, which is wrong. diff --git a/src/app/lazy_conf.py b/src/app/lazy_conf.py new file mode 100644 index 000000000..13b69a5bf --- /dev/null +++ b/src/app/lazy_conf.py @@ -0,0 +1,113 @@ +"""Implements callables which lazily parses and combines config files.""" +from __future__ import annotations +from typing import Callable, Pattern +import functools + +from srctools import Property, logger, KeyValError +from app import DEV_MODE +import packages +import utils + + +LOGGER = logger.get_logger(__name__) +LazyConf = Callable[[], Property] +# Empty property. +BLANK: LazyConf = lambda: Property.root() + + +def raw_prop(block: Property, source: str= '') -> LazyConf: + """Make an existing property conform to the interface.""" + if block or block.name is not None: + if source: + def copier() -> Property: + """Copy the config, then apply the source.""" + copy = block.copy() + packages.set_cond_source(copy, source) + return copy + return copier + else: # We can just use the bound method. + return block.copy + else: # If empty, source is irrelevant, and we can use the constant. + return BLANK + + +def from_file(path: utils.PackagePath, missing_ok: bool=False, source: str= '') -> LazyConf: + """Lazily load the specified config.""" + try: + fsys = packages.PACKAGE_SYS[path.package] + except KeyError: + if not missing_ok: + LOGGER.warning('Package does not exist: "{}"', path) + return BLANK + try: + file = fsys[path.path] + except FileNotFoundError: + if not missing_ok: + LOGGER.warning('File does not exist: "{}"', path) + return BLANK + + def loader() -> Property: + """Load and parse the specified file when called.""" + try: + with file.open_str() as f: + props = Property.parse(f) + except (KeyValError, FileNotFoundError, UnicodeDecodeError): + LOGGER.exception('Unable to read "{}"', path) + raise + if source: + packages.set_cond_source(props, source) + return props + + if DEV_MODE.get(): + # Parse immediately, to check syntax. + try: + with file.open_str() as f: + Property.parse(f) + except (KeyValError, FileNotFoundError, UnicodeDecodeError): + LOGGER.exception('Unable to read "{}"', path) + + return loader + + +def concat(a: LazyConf, b: LazyConf) -> LazyConf: + """Concatenate the two configs together.""" + # Catch a raw property being passed in. + assert callable(a) and callable(b), (a, b) + # If either is blank, this is a no-op, so avoid a pointless layer. + if a is BLANK: + return b + if b is BLANK: + return a + + def concat_inner() -> Property: + """Resolve then merge the configs.""" + prop = Property.root() + prop.extend(a()) + prop.extend(b()) + return prop + return concat_inner + + +def replace(base: LazyConf, replacements: list[tuple[Pattern[str], str]]) -> LazyConf: + """Replace occurances of values in the base config.""" + rep_funcs = [ + functools.partial(pattern.sub, repl) + for pattern, repl in replacements + ] + + def replacer() -> Property: + """Replace values.""" + copy = base() + for prop in copy.iter_tree(): + name = prop.real_name + if name is not None: + for func in rep_funcs: + name = func(name) + prop.name = name + if not prop.has_children(): + value = prop.value + for func in rep_funcs: + value = func(value) + prop.value = value + return copy + return replacer diff --git a/src/app/music_conf.py b/src/app/music_conf.py index 6ab89820b..0224cc5ce 100644 --- a/src/app/music_conf.py +++ b/src/app/music_conf.py @@ -6,12 +6,13 @@ from srctools import FileSystemChain, FileSystem import srctools.logger -from app.selector_win import Item as SelItem, selWin as SelectorWin, AttrDef as SelAttr +from app.selector_win import Item as SelItem, SelectorWin, AttrDef as SelAttr from app.SubPane import SubPane from app import TK_ROOT from BEE2_config import GEN_OPTS from consts import MusicChannel from packages import Music +from localisation import gettext BTN_EXPAND = '▽' BTN_EXPAND_HOVER = '▼' @@ -44,8 +45,6 @@ def set_suggested(music_id: str, *, sel_item: bool=False) -> None: for channel in MusicChannel: if channel is MusicChannel.BASE: continue - if sel_item: - WINDOWS[channel].sel_item_id('') WINDOWS[channel].set_suggested() else: music = Music.by_id(music_id) @@ -54,20 +53,35 @@ def set_suggested(music_id: str, *, sel_item: bool=False) -> None: continue sugg = music.get_suggestion(channel) - if sel_item: - WINDOWS[channel].sel_item_id(sugg) - WINDOWS[channel].set_suggested(sugg) + WINDOWS[channel].set_suggested({sugg} if sugg else set()) def export_data() -> Dict[MusicChannel, Optional[Music]]: """Return the data used to export this.""" - return { - channel: - None if - win.chosen_id is None - else Music.by_id(win.chosen_id) - for channel, win in WINDOWS.items() + base_id = WINDOWS[MusicChannel.BASE].chosen_id + if base_id is not None: + base_track = Music.by_id(base_id) + else: + base_track = None + data: dict[MusicChannel, Optional[Music]] = { + MusicChannel.BASE: base_track, } + for channel, win in WINDOWS.items(): + if channel is MusicChannel.BASE: + continue + # If collapsed, use the suggested track. Otherwise use the chosen one. + if is_collapsed: + if base_track is not None: + mus_id = base_track.get_suggestion(channel) + else: + mus_id = None + else: + mus_id = win.chosen_id + if mus_id is not None: + data[channel] = Music.by_id(mus_id) + else: + data[channel] = None + return data def selwin_callback(music_id: Optional[str], channel: MusicChannel) -> None: @@ -129,46 +143,49 @@ def for_channel(channel: MusicChannel) -> List[SelItem]: base_win = WINDOWS[MusicChannel.BASE] = SelectorWin( TK_ROOT, for_channel(MusicChannel.BASE), - title=_('Select Background Music - Base'), - desc=_('This controls the background music used for a map. Expand ' - 'the dropdown to set tracks for specific test elements.'), + save_id='music_base', + title=gettext('Select Background Music - Base'), + desc=gettext('This controls the background music used for a map. Expand the dropdown to set ' + 'tracks for specific test elements.'), has_none=True, sound_sys=filesystem, - none_desc=_('Add no music to the map at all. Testing Element-specific ' - 'music may still be added.'), + none_desc=gettext('Add no music to the map at all. Testing Element-specific music may still ' + 'be added.'), callback=selwin_callback, callback_params=[MusicChannel.BASE], attributes=[ - SelAttr.bool('SPEED', _('Propulsion Gel SFX')), - SelAttr.bool('BOUNCE', _('Repulsion Gel SFX')), - SelAttr.bool('TBEAM', _('Excursion Funnel Music')), - SelAttr.bool('TBEAM_SYNC', _('Synced Funnel Music')), + SelAttr.bool('SPEED', gettext('Propulsion Gel SFX')), + SelAttr.bool('BOUNCE', gettext('Repulsion Gel SFX')), + SelAttr.bool('TBEAM', gettext('Excursion Funnel Music')), + SelAttr.bool('TBEAM_SYNC', gettext('Synced Funnel Music')), ], ) WINDOWS[MusicChannel.TBEAM] = SelectorWin( TK_ROOT, for_channel(MusicChannel.TBEAM), - title=_('Select Excursion Funnel Music'), - desc=_('Set the music used while inside Excursion Funnels.'), + save_id='music_tbeam', + title=gettext('Select Excursion Funnel Music'), + desc=gettext('Set the music used while inside Excursion Funnels.'), has_none=True, sound_sys=filesystem, - none_desc=_('Have no music playing when inside funnels.'), + none_desc=gettext('Have no music playing when inside funnels.'), callback=selwin_callback, callback_params=[MusicChannel.TBEAM], attributes=[ - SelAttr.bool('TBEAM_SYNC', _('Synced Funnel Music')), + SelAttr.bool('TBEAM_SYNC', gettext('Synced Funnel Music')), ], ) WINDOWS[MusicChannel.BOUNCE] = SelectorWin( TK_ROOT, for_channel(MusicChannel.BOUNCE), - title=_('Select Repulsion Gel Music'), - desc=_('Select the music played when players jump on Repulsion Gel.'), + save_id='music_bounce', + title=gettext('Select Repulsion Gel Music'), + desc=gettext('Select the music played when players jump on Repulsion Gel.'), has_none=True, sound_sys=filesystem, - none_desc=_('Add no music when jumping on Repulsion Gel.'), + none_desc=gettext('Add no music when jumping on Repulsion Gel.'), callback=selwin_callback, callback_params=[MusicChannel.BOUNCE], ) @@ -176,11 +193,12 @@ def for_channel(channel: MusicChannel) -> List[SelItem]: WINDOWS[MusicChannel.SPEED] = SelectorWin( TK_ROOT, for_channel(MusicChannel.SPEED), - title=_('Select Propulsion Gel Music'), - desc=_('Select music played when players have large amounts of horizontal velocity.'), + save_id='music_speed', + title=gettext('Select Propulsion Gel Music'), + desc=gettext('Select music played when players have large amounts of horizontal velocity.'), has_none=True, sound_sys=filesystem, - none_desc=_('Add no music while running fast.'), + none_desc=gettext('Add no music while running fast.'), callback=selwin_callback, callback_params=[MusicChannel.SPEED], ) @@ -201,11 +219,11 @@ def set_collapsed() -> None: global is_collapsed is_collapsed = True GEN_OPTS['Last_Selected']['music_collapsed'] = '1' - base_lbl['text'] = _('Music: ') + base_lbl['text'] = gettext('Music: ') toggle_btn_exit() # Set all music to the children - so those are used. - set_suggested(WINDOWS[MusicChannel.BASE].chosen_id, sel_item=True) + set_suggested(WINDOWS[MusicChannel.BASE].chosen_id) for wid in exp_widgets: wid.grid_remove() @@ -215,7 +233,7 @@ def set_expanded() -> None: global is_collapsed is_collapsed = False GEN_OPTS['Last_Selected']['music_collapsed'] = '0' - base_lbl['text'] = _('Base: ') + base_lbl['text'] = gettext('Base: ') toggle_btn_exit() for wid in exp_widgets: wid.grid() @@ -248,9 +266,9 @@ def toggle(event: tkinter.Event) -> None: btn.grid(row=row, column=2, sticky='EW') for row, text in enumerate([ - _('Funnel:'), - _('Bounce:'), - _('Speed:'), + gettext('Funnel:'), + gettext('Bounce:'), + gettext('Speed:'), ], start=1): label = ttk.Label(frame, text=text) exp_widgets.append(label) diff --git a/src/app/optionWindow.py b/src/app/optionWindow.py index d4fafcdc6..a673fcf51 100644 --- a/src/app/optionWindow.py +++ b/src/app/optionWindow.py @@ -14,7 +14,12 @@ import utils import srctools.logger -from app import contextWin, gameMan, tk_tools, sound, logWindow, TK_ROOT +from app import ( + contextWin, gameMan, tk_tools, sound, logWindow, img, TK_ROOT, + PLAY_SOUND, KEEP_WIN_INSIDE, FORCE_LOAD_ONTOP, SHOW_LOG_WIN, + LAUNCH_AFTER_EXPORT, PRESERVE_RESOURCES, DEV_MODE, +) +from localisation import gettext import loadScreen @@ -28,13 +33,6 @@ class AfterExport(Enum): QUIT = 2 # Quit the app. UI = {} -PLAY_SOUND = BooleanVar(value=True, name='OPT_play_sounds') -KEEP_WIN_INSIDE = BooleanVar(value=True, name='OPT_keep_win_inside') -FORCE_LOAD_ONTOP = BooleanVar(value=True, name='OPT_force_load_ontop') -SHOW_LOG_WIN = BooleanVar(value=False, name='OPT_show_log_window') -LAUNCH_AFTER_EXPORT = BooleanVar(value=True, name='OPT_launch_after_export') -PRESERVE_RESOURCES = BooleanVar(value=False, name='OPT_preserve_bee2_resource_dir') -DEV_MODE = BooleanVar(value=False, name='OPT_development_mode') AFTER_EXPORT_ACTION = IntVar( value=AfterExport.MINIMISE.value, name='OPT_after_export_action', @@ -43,19 +41,19 @@ class AfterExport(Enum): # action, launching_game -> suffix on the message box. AFTER_EXPORT_TEXT: Dict[Tuple[AfterExport, bool], str] = { (AfterExport.NORMAL, False): '', - (AfterExport.NORMAL, True): _('\nLaunch Game?'), + (AfterExport.NORMAL, True): gettext('\nLaunch Game?'), - (AfterExport.MINIMISE, False): _('\nMinimise BEE2?'), - (AfterExport.MINIMISE, True): _('\nLaunch Game and minimise BEE2?'), + (AfterExport.MINIMISE, False): gettext('\nMinimise BEE2?'), + (AfterExport.MINIMISE, True): gettext('\nLaunch Game and minimise BEE2?'), - (AfterExport.QUIT, False): _('\nQuit BEE2?'), - (AfterExport.QUIT, True): _('\nLaunch Game and quit BEE2?'), + (AfterExport.QUIT, False): gettext('\nQuit BEE2?'), + (AfterExport.QUIT, True): gettext('\nLaunch Game and quit BEE2?'), } refresh_callbacks: List[Callable[[], None]] = [] # functions called to apply settings. # All the auto-created checkbox variables -VARS: Dict[Tuple[str, str], BooleanVar] = {} +VARS: Dict[Tuple[str, str], Variable] = {} def reset_all_win() -> None: @@ -68,7 +66,7 @@ def reset_all_win() -> None: win = Toplevel(TK_ROOT) win.transient(master=TK_ROOT) tk_tools.set_window_icon(win) -win.title(_('BEE2 Options')) +win.title(gettext('BEE2 Options')) win.withdraw() @@ -106,7 +104,7 @@ def clear_caches() -> None: """ import packages - message = _( + message = gettext( 'Package cache times have been reset. ' 'These will now be extracted during the next export.' ) @@ -123,7 +121,7 @@ def clear_caches() -> None: # anything... if PRESERVE_RESOURCES.get(): PRESERVE_RESOURCES.set(False) - message += '\n\n' + _('"Preserve Game Resources" has been disabled.') + message += '\n\n' + gettext('"Preserve Game Resources" has been disabled.') save() # Save any option changes.. @@ -133,9 +131,9 @@ def clear_caches() -> None: # Since we've saved, dismiss this window. win.withdraw() - + messagebox.showinfo( - title=_('Packages Reset'), + title=gettext('Packages Reset'), message=message, ) @@ -216,19 +214,19 @@ def init_widgets() -> None: UI['fr_general'] = fr_general = ttk.Frame( nbook, ) - nbook.add(fr_general, text=_('General')) + nbook.add(fr_general, text=gettext('General')) init_gen_tab(fr_general) UI['fr_win'] = fr_win = ttk.Frame( nbook, ) - nbook.add(fr_win, text=_('Windows')) + nbook.add(fr_win, text=gettext('Windows')) init_win_tab(fr_win) UI['fr_dev'] = fr_dev = ttk.Frame( nbook, ) - nbook.add(fr_dev, text=_('Development')) + nbook.add(fr_dev, text=gettext('Development')) init_dev_tab(fr_dev) ok_cancel = ttk.Frame( @@ -252,12 +250,12 @@ def cancel() -> None: UI['ok_btn'] = ok_btn = ttk.Button( ok_cancel, - text=_('OK'), + text=gettext('OK'), command=ok, ) UI['cancel_btn'] = cancel_btn = ttk.Button( ok_cancel, - text=_('Cancel'), + text=gettext('Cancel'), command=cancel, ) ok_btn.grid(row=0, column=0) @@ -283,7 +281,7 @@ def save_after_export(): after_export_frame = ttk.LabelFrame( f, - text=_('After Export:'), + text=gettext('After Export:'), ) after_export_frame.grid( row=0, @@ -300,19 +298,19 @@ def save_after_export(): exp_nothing = ttk.Radiobutton( after_export_frame, - text=_('Do Nothing'), + text=gettext('Do Nothing'), variable=AFTER_EXPORT_ACTION, value=AfterExport.NORMAL.value, ) exp_minimise = ttk.Radiobutton( after_export_frame, - text=_('Minimise BEE2'), + text=gettext('Minimise BEE2'), variable=AFTER_EXPORT_ACTION, value=AfterExport.MINIMISE.value, ) exp_quit = ttk.Radiobutton( after_export_frame, - text=_('Quit BEE2'), + text=gettext('Quit BEE2'), variable=AFTER_EXPORT_ACTION, value=AfterExport.QUIT.value, ) @@ -320,18 +318,18 @@ def save_after_export(): exp_minimise.grid(row=1, column=0, sticky='w') exp_quit.grid(row=2, column=0, sticky='w') - add_tooltip(exp_nothing, _('After exports, do nothing and ' + add_tooltip(exp_nothing, gettext('After exports, do nothing and ' 'keep the BEE2 in focus.')) - add_tooltip(exp_minimise, _('After exports, minimise to the taskbar/dock.')) - add_tooltip(exp_quit, _('After exports, quit the BEE2.')) + add_tooltip(exp_minimise, gettext('After exports, minimise to the taskbar/dock.')) + add_tooltip(exp_quit, gettext('After exports, quit the BEE2.')) make_checkbox( after_export_frame, section='General', item='launch_Game', var=LAUNCH_AFTER_EXPORT, - desc=_('Launch Game'), - tooltip=_('After exporting, launch the selected game automatically.'), + desc=gettext('Launch Game'), + tooltip=gettext('After exporting, launch the selected game automatically.'), ).grid(row=3, column=0, sticky='W', pady=(10, 0)) if sound.has_sound(): @@ -339,31 +337,31 @@ def save_after_export(): f, section='General', item='play_sounds', - desc=_('Play Sounds'), + desc=gettext('Play Sounds'), var=PLAY_SOUND, ) else: mute = ttk.Checkbutton( f, - text=_('Play Sounds'), + text=gettext('Play Sounds'), state='disabled', ) add_tooltip( mute, - _('Pyglet is either not installed or broken.\n' + gettext('Pyglet is either not installed or broken.\n' 'Sound effects have been disabled.') ) mute.grid(row=0, column=1, sticky='E') UI['reset_cache'] = reset_cache = ttk.Button( f, - text=_('Reset Package Caches'), + text=gettext('Reset Package Caches'), command=clear_caches, ) reset_cache.grid(row=1, column=1, sticky='EW') add_tooltip( reset_cache, - _('Force re-extracting all package resources.'), + gettext('Force re-extracting all package resources.'), ) @@ -372,8 +370,8 @@ def init_win_tab(f: ttk.Frame) -> None: f, section='General', item='keep_win_inside', - desc=_('Keep windows inside screen'), - tooltip=_('Prevent sub-windows from moving outside the screen borders. ' + desc=gettext('Keep windows inside screen'), + tooltip=gettext('Prevent sub-windows from moving outside the screen borders. ' 'If you have multiple monitors, disable this.'), var=KEEP_WIN_INSIDE, ) @@ -383,9 +381,9 @@ def init_win_tab(f: ttk.Frame) -> None: f, section='General', item='splash_stay_ontop', - desc=_('Keep loading screens on top'), + desc=gettext('Keep loading screens on top'), var=FORCE_LOAD_ONTOP, - tooltip=_( + tooltip=gettext( "Force loading screens to be on top of other windows. " "Since they don't appear on the taskbar/dock, they can't be " "brought to the top easily again." @@ -394,111 +392,120 @@ def init_win_tab(f: ttk.Frame) -> None: ttk.Button( f, - text=_('Reset All Window Positions'), + text=gettext('Reset All Window Positions'), # Indirect reference to allow UI to set this later command=lambda: reset_all_win(), ).grid(row=1, column=0, sticky=EW) def init_dev_tab(f: ttk.Frame) -> None: - f.columnconfigure(1, weight=1) + f.columnconfigure(0, weight=1) f.columnconfigure(2, weight=1) make_checkbox( f, section='Debug', item='log_missing_ent_count', - desc=_('Log missing entity counts'), - tooltip=_('When loading items, log items with missing entity counts ' + desc=gettext('Log missing entity counts'), + tooltip=gettext('When loading items, log items with missing entity counts ' 'in their properties.txt file.'), - ).grid(row=0, column=0, sticky=W) + ).grid(row=0, column=0, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='log_missing_styles', - desc=_("Log when item doesn't have a style"), - tooltip=_('Log items have no applicable version for a particular style.' + desc=gettext("Log when item doesn't have a style"), + tooltip=gettext('Log items have no applicable version for a particular style.' 'This usually means it will look very bad.'), - ).grid(row=1, column=0, sticky=W) + ).grid(row=1, column=0, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='log_item_fallbacks', - desc=_("Log when item uses parent's style"), - tooltip=_('Log when an item reuses a variant from a parent style ' + desc=gettext("Log when item uses parent's style"), + tooltip=gettext('Log when an item reuses a variant from a parent style ' '(1970s using 1950s items, for example). This is usually ' 'fine, but may need to be fixed.'), - ).grid(row=2, column=0, sticky=W) + ).grid(row=2, column=0, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='log_incorrect_packfile', - desc=_("Log missing packfile resources"), - tooltip=_('Log when the resources a "PackList" refers to are not ' + desc=gettext("Log missing packfile resources"), + tooltip=gettext('Log when the resources a "PackList" refers to are not ' 'present in the zip. This may be fine (in a prerequisite zip),' ' but it often indicates an error.'), - ).grid(row=3, column=0, sticky=W) + ).grid(row=3, column=0, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='development_mode', var=DEV_MODE, - desc=_("Development Mode"), - tooltip=_('Enables displaying additional UI specific for ' + desc=gettext("Development Mode"), + tooltip=gettext('Enables displaying additional UI specific for ' 'development purposes. Requires restart to have an effect.'), - ).grid(row=0, column=1, sticky=W) + ).grid(row=0, column=2, columnspan=2, sticky=W) make_checkbox( f, section='General', item='preserve_bee2_resource_dir', - desc=_('Preserve Game Directories'), + desc=gettext('Preserve Game Directories'), var=PRESERVE_RESOURCES, - tooltip=_('When exporting, do not copy resources to \n"bee2/" and' + tooltip=gettext('When exporting, do not copy resources to \n"bee2/" and' ' "sdk_content/maps/bee2/".\n' "Only enable if you're" ' developing new content, to ensure it is not ' 'overwritten.'), - ).grid(row=1, column=1, sticky=W) + ).grid(row=1, column=2, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='show_log_win', - desc=_('Show Log Window'), + desc=gettext('Show Log Window'), var=SHOW_LOG_WIN, - tooltip=_('Show the log file in real-time.'), - ).grid(row=2, column=1, sticky=W) + tooltip=gettext('Show the log file in real-time.'), + ).grid(row=2, column=2, columnspan=2, sticky=W) make_checkbox( f, section='Debug', item='force_all_editor_models', - desc=_("Force Editor Models"), - tooltip=_('Make all props_map_editor models available for use. ' + desc=gettext("Force Editor Models"), + tooltip=gettext('Make all props_map_editor models available for use. ' 'Portal 2 has a limit of 1024 models loaded in memory at ' 'once, so we need to disable unused ones to free this up.'), - ).grid(row=3, column=1, sticky='w') + ).grid(row=3, column=2, columnspan=2, sticky='w') ttk.Separator(orient='horizontal').grid( - row=9, column=0, columnspan=2, sticky='ew' + row=9, column=0, columnspan=3, sticky='ew' ) ttk.Button( f, - text=_('Dump All objects'), + text=gettext('Dump All objects'), command=report_all_obj, ).grid(row=10, column=0) ttk.Button( f, - text=_('Dump Items list'), + text=gettext('Dump Items list'), command=report_items, ).grid(row=10, column=1) + reload_img = ttk.Button( + f, + text=gettext('Reload Images'), + command=img.refresh_all, + ) + add_tooltip(reload_img, gettext( + 'Reload all images in the app. Expect the app to freeze momentarily.' + )) + reload_img.grid(row=10, column=2) # Various "reports" that can be produced. diff --git a/src/app/packageMan.py b/src/app/packageMan.py index 938b88fde..129f01413 100644 --- a/src/app/packageMan.py +++ b/src/app/packageMan.py @@ -41,7 +41,7 @@ def make_packitems() -> Iterable[CheckItem]: pack.disp_name, hover_text=pack.desc or 'No description!', # The clean package can't be disabled! - lock_check=(pack.id == packages.CLEAN_PACKAGE), + lock_check=(pack.id.casefold() == packages.CLEAN_PACKAGE), state=pack.enabled ) item.package = pack diff --git a/src/app/paletteLoader.py b/src/app/paletteLoader.py index 238d61575..d03fd96ac 100644 --- a/src/app/paletteLoader.py +++ b/src/app/paletteLoader.py @@ -1,44 +1,49 @@ +"""Defines the palette data structure and file saving/loading logic.""" +from __future__ import annotations +from typing import IO, Iterator import os import shutil import zipfile import random +import io +from uuid import UUID, uuid4, uuid5 import utils import srctools.logger -import BEE2_config from srctools import Property, NoKeyError, KeyValError -from typing import List, Tuple, Optional, Dict +from localisation import gettext LOGGER = srctools.logger.get_logger(__name__) - PAL_DIR = utils.conf_location('palettes/') - +GROUP_BUILTIN = '' PAL_EXT = '.bee2_palette' -pal_list: List['Palette'] = [] - # Allow translating the names of the built-in palettes -TRANS_NAMES: Dict[str, str] = { +TRANS_NAMES: dict[str, str] = { # i18n: Last exported items - 'LAST_EXPORT': _(''), + 'LAST_EXPORT': gettext(''), # i18n: Empty palette name - 'EMPTY': _('Blank'), + 'EMPTY': gettext('Blank'), # i18n: BEEmod 1 palette. - 'BEEMOD': _('BEEMod'), + 'BEEMOD': gettext('BEEMod'), # i18n: Default items merged together - 'P2_COLLAPSED': _('Portal 2 Collapsed'), + 'P2_COLLAPSED': gettext('Portal 2 Collapsed'), # i18n: Original Palette - 'PORTAL2': _('Portal 2'), + 'PORTAL2': gettext('Portal 2'), # i18n: Aperture Tag's palette - 'APTAG': _('Aperture Tag'), + 'APTAG': gettext('Aperture Tag'), } +DEFAULT_NS = UUID('91001b81-60ee-494d-9d2a-6371397b2240') +UUID_PORTAL2 = uuid5(DEFAULT_NS, 'PORTAL2') +UUID_EXPORT = uuid5(DEFAULT_NS, 'LAST_EXPORT') +UUID_BLANK = uuid5(DEFAULT_NS, 'EMPTY') # The original palette, plus BEEmod 1 and Aperture Tag's palettes. -DEFAULT_PALETTES: Dict[str, List[Tuple[str, int]]] = { +DEFAULT_PALETTES: dict[str, list[tuple[str, int]]] = { 'EMPTY': [], 'PORTAL2': [ ("ITEM_BUTTON_PEDESTAL", 0), @@ -198,13 +203,15 @@ class Palette: """A palette, saving an arrangement of items for editoritems.txt""" def __init__( self, - name, - pos: List[Tuple[str, int]], - trans_name='', - prevent_overwrite=False, - filename: str=None, - settings: Optional[Property]=None, - ): + name: str, + pos: list[tuple[str, int]], + trans_name: str = '', + readonly: bool = False, + group: str = '', + filename: str = None, + settings: Property | None = None, + uuid: UUID = None, + ) -> None: # Name of the palette self.name = name self.trans_name = trans_name @@ -214,6 +221,15 @@ def __init__( except KeyError: LOGGER.warning('Unknown translated palette "{}', trans_name) + # Group to show the palette in. + self.group = group + + # ID unique to this palette. + if uuid is not None: + self.uuid = uuid + else: + self.uuid = uuid4() + # If loaded from a file, the path to use. # None determines a filename automatically. self.filename = filename @@ -221,17 +237,20 @@ def __init__( self.pos = pos # If true, prevent overwriting the original file # (premade palettes or ) - self.prevent_overwrite = prevent_overwrite + self.readonly = readonly + if readonly: + self.group = GROUP_BUILTIN # If not None, settings associated with the palette. self.settings = settings - def __str__(self): - return self.name - + def __repr__(self) -> str: + return f'' @classmethod - def parse(cls, path: str): + def parse(cls, path: str) -> Palette: + """Parse a palette from a file.""" + needs_save = False with open(path, encoding='utf8') as f: props = Property.parse(f, path) name = props['Name', '??'] @@ -240,38 +259,56 @@ def parse(cls, path: str): items.append((item.real_name, int(item.value))) trans_name = props['TransName', ''] + if trans_name: + # Builtin, force a fixed uuid. This is mainly for LAST_EXPORT. + uuid = uuid5(DEFAULT_NS, trans_name) + else: + try: + uuid = UUID(hex=props['UUID']) + except (ValueError, LookupError): + uuid = uuid4() + needs_save = True + settings: Property | None try: settings = props.find_key('Settings') except NoKeyError: settings = None - return Palette( + pal = Palette( name, items, trans_name=trans_name, - prevent_overwrite=props.bool('readonly'), + group=props['group', ''], + readonly=props.bool('readonly'), filename=os.path.basename(path), + uuid=uuid, settings=settings, ) + if needs_save: + LOGGER.info('Resaving older palette file {}', pal.filename) + pal.save() + return pal - def save(self, ignore_readonly=False): + def save(self, ignore_readonly: bool = False) -> None: """Save the palette file into the specified location. - If ignore_readonly is true, this will ignore the `prevent_overwrite` + If ignore_readonly is true, this will ignore the `readonly` property of the palette (allowing resaving those properties over old versions). Otherwise those palettes always create a new file. """ LOGGER.info('Saving "{}"!', self.name) - props = Property(None, [ + props = Property.root( Property('Name', self.name), Property('TransName', self.trans_name), - Property('ReadOnly', srctools.bool_as_int(self.prevent_overwrite)), + Property('Group', self.group), + Property('ReadOnly', srctools.bool_as_int(self.readonly)), + Property('UUID', self.uuid.hex), Property('Items', [ Property(item_id, str(subitem)) for item_id, subitem in self.pos ]) - ]) + ) # If default, don't include in the palette file. # Remove the translated name, in case it's not going to write # properly to the file. @@ -280,9 +317,6 @@ def save(self, ignore_readonly=False): else: del props['TransName'] - if not self.prevent_overwrite: - del props['ReadOnly'] - if self.settings is not None: self.settings.name = 'Settings' props.append(self.settings.copy()) @@ -291,7 +325,7 @@ def save(self, ignore_readonly=False): # Use a hash to ensure it's a valid path (without '-' if negative) # If a conflict occurs, add ' ' and hash again to get a different # value. - if self.filename is None or (self.prevent_overwrite and not ignore_readonly): + if self.filename is None or (self.readonly and not ignore_readonly): hash_src = self.name while True: hash_filename = str(abs(hash(hash_src))) + PAL_EXT @@ -308,33 +342,37 @@ def save(self, ignore_readonly=False): for line in props.export(): file.write(line) - def delete_from_disk(self): + def delete_from_disk(self) -> None: """Delete this palette from disk.""" if self.filename is not None: os.remove(os.path.join(PAL_DIR, self.filename)) -def load_palettes(): - """Scan and read in all palettes in the specified directory.""" +def load_palettes() -> Iterator[Palette]: + """Scan and read in all palettes. Legacy files will be converted in the process.""" # Load our builtin palettes. for name, items in DEFAULT_PALETTES.items(): LOGGER.info('Loading builtin "{}"', name) - pal_list.append(Palette( + yield Palette( name, items, name, - prevent_overwrite=True, - )) + readonly=True, + group=GROUP_BUILTIN, + uuid=uuid5(DEFAULT_NS, name), + ) for name in os.listdir(PAL_DIR): # this is both files and dirs LOGGER.info('Loading "{}"', name) path = os.path.join(PAL_DIR, name) - pos_file, prop_file = None, None + + pos_file: IO[str] | None = None + prop_file: IO[str] | None = None try: if name.endswith(PAL_EXT): try: - pal_list.append(Palette.parse(path)) + yield Palette.parse(path) except KeyValError as exc: # We don't need the traceback, this isn't an error in the app # itself. @@ -343,8 +381,8 @@ def load_palettes(): elif name.endswith('.zip'): # Extract from a zip with zipfile.ZipFile(path) as zip_file: - pos_file = zip_file.open('positions.txt') - prop_file = zip_file.open('properties.txt') + pos_file = io.TextIOWrapper(zip_file.open('positions.txt'), encoding='ascii', errors='ignore') + prop_file = io.TextIOWrapper(zip_file.open('properties.txt'), encoding='ascii', errors='ignore') elif os.path.isdir(path): # Open from the subfolder pos_file = open(os.path.join(path, 'positions.txt')) @@ -360,7 +398,9 @@ def load_palettes(): # Legacy parsing of BEE2.2 files.. pal = parse_legacy(pos_file, prop_file, name) if pal is not None: - pal_list.append(pal) + yield pal + else: + continue finally: if pos_file: pos_file.close() @@ -374,16 +414,12 @@ def load_palettes(): os.remove(path) else: # Folders can't be overwritten... - pal.prevent_overwrite = True + pal.readonly = True pal.save() shutil.rmtree(path) - # Ensure the list has a defined order.. - pal_list.sort(key=str) - return pal_list - -def parse_legacy(posfile, propfile, path): +def parse_legacy(posfile, propfile, path) -> Palette | None: """Parse the original BEE2.2 palette format.""" props = Property.parse(propfile, path + ':properties.txt') name = props['name', 'Unnamed'] @@ -398,42 +434,14 @@ def parse_legacy(posfile, propfile, path): val = line.split('",') if len(val) == 2: pos.append(( - val[0][1:], # Item ID - int(val[1].strip()), # Item subtype + val[0][1:], # Item ID + int(val[1].strip()), # Item subtype )) else: LOGGER.warning('Malformed row "{}"!', line) return None return Palette(name, pos) - -def save_pal(items, name: str, include_settings: bool): - """Save a palette under the specified name.""" - for pal in pal_list: - if pal.name == name and not pal.prevent_overwrite: - pal.pos = list(items) - break - else: - pal = Palette(name, list(items)) - pal_list.append(pal) - - if include_settings: - pal.settings = BEE2_config.get_curr_settings() - else: - pal.settings = None - - pal.save() - return pal - - -def check_exists(name): - """Check if a palette with the given name exists.""" - for pal in pal_list: - if pal.name == name: - return True - return False - - if __name__ == '__main__': results = load_palettes() for palette in results: diff --git a/src/app/paletteUI.py b/src/app/paletteUI.py new file mode 100644 index 000000000..7cbf003b0 --- /dev/null +++ b/src/app/paletteUI.py @@ -0,0 +1,367 @@ +"""Handles the UI required for saving and loading palettes.""" +from __future__ import annotations +from typing import Callable +from uuid import UUID + +import tkinter as tk +from tkinter import ttk, messagebox + +import BEE2_config +from app.paletteLoader import Palette, UUID_PORTAL2, UUID_EXPORT, UUID_BLANK +from app import tk_tools, paletteLoader, TK_ROOT, img +from localisation import gettext + +import srctools.logger + +LOGGER = srctools.logger.get_logger(__name__) +TREE_TAG_GROUPS = 'pal_group' +TREE_TAG_PALETTES = 'palette' +ICO_GEAR = img.Handle.sprite('icons/gear', 10, 10) + +# Re-export paletteLoader values for convenience. +__all__ = [ + 'PaletteUI', 'Palette', 'UUID', 'UUID_EXPORT', 'UUID_PORTAL2', 'UUID_BLANK', +] + + +class PaletteUI: + """UI for selecting palettes.""" + def __init__( + self, f: tk.Frame, menu: tk.Menu, + *, + cmd_clear: Callable[[], None], + cmd_shuffle: Callable[[], None], + get_items: Callable[[], list[tuple[str, int]]], + set_items: Callable[[Palette], None], + ) -> None: + """Initialises the palette pane. + + The parameters are used to communicate with the item list: + - cmd_clear and cmd_shuffle are called to do those actions to the list. + - pal_get_items is called to retrieve the current list of selected items. + - cmd_save_btn_state is the .state() method on the save button. + - cmd_set_items is called to apply a palette to the list of items. + """ + self.palettes: dict[UUID, Palette] = { + pal.uuid: pal + for pal in paletteLoader.load_palettes() + } + + try: + self.selected_uuid = UUID(hex=BEE2_config.GEN_OPTS.get_val('Last_Selected', 'palette_uuid', '')) + except ValueError: + self.selected_uuid = UUID_PORTAL2 + + f.rowconfigure(1, weight=1) + f.columnconfigure(0, weight=1) + self.var_save_settings = tk.BooleanVar(value=BEE2_config.GEN_OPTS.get_bool('General', 'palette_save_settings')) + self.var_pal_select = tk.StringVar(value=self.selected_uuid.hex) + self.get_items = get_items + self.set_items = set_items + # Overwritten to configure the save state button. + self.save_btn_state = lambda s: None + + ttk.Button( + f, + text=gettext('Clear Palette'), + command=cmd_clear, + ).grid(row=0, sticky="EW") + + self.ui_treeview = treeview = ttk.Treeview(f, show='tree', selectmode='browse') + self.ui_treeview.grid(row=1, sticky="NSEW") + self.ui_treeview.tag_bind(TREE_TAG_PALETTES, '', self.event_select_tree) + + # Avoid re-registering the double-lambda, just do it here. + # This makes clicking the groups return selection to the palette. + evtid_reselect = self.ui_treeview.register(self.treeview_reselect) + self.ui_treeview.tag_bind(TREE_TAG_GROUPS, '', lambda e: treeview.tk.call('after', 'idle', evtid_reselect)) + + # And ensure when focus returns we reselect, in case it deselects. + f.winfo_toplevel().bind('', lambda e: self.treeview_reselect(), add=True) + + scrollbar = tk_tools.HidingScroll( + f, + orient='vertical', + command=self.ui_treeview.yview, + ) + scrollbar.grid(row=1, column=1, sticky="NS") + self.ui_treeview['yscrollcommand'] = scrollbar.set + + self.ui_remove = ttk.Button( + f, + text=gettext('Delete Palette'), + command=self.event_remove, + ) + self.ui_remove.grid(row=2, sticky="EW") + + if tk_tools.USE_SIZEGRIP: + ttk.Sizegrip(f).grid(row=2, column=1) + + self.ui_menu = menu + self.ui_group_menus: dict[str, tk.Menu] = {} + self.ui_group_treeids: dict[str, str] = {} + menu.add_command( + label=gettext('Clear'), + command=cmd_clear, + ) + menu.add_command( + # Placeholder.. + label=gettext('Delete Palette'), # This name is overwritten later + command=self.event_remove, + ) + self.ui_menu_delete_index = menu.index('end') + + menu.add_command( + label=gettext('Change Palette Group...'), + command=self.event_change_group, + ) + self.ui_menu_regroup_index = menu.index('end') + + menu.add_command( + label=gettext('Rename Palette...'), + command=self.event_rename, + ) + self.ui_menu_rename_index = menu.index('end') + + menu.add_command( + label=gettext('Fill Palette'), + command=cmd_shuffle, + ) + + menu.add_separator() + + menu.add_checkbutton( + label=gettext('Save Settings in Palettes'), + variable=self.var_save_settings, + ) + + menu.add_separator() + + menu.add_command( + label=gettext('Save Palette'), + command=self.event_save, + accelerator=tk_tools.ACCEL_SAVE, + ) + self.ui_menu_save_ind = menu.index('end') + menu.add_command( + label=gettext('Save Palette As...'), + command=self.event_save_as, + accelerator=tk_tools.ACCEL_SAVE_AS, + ) + + menu.add_separator() + self.ui_menu_palettes_index = menu.index('end') + 1 + self.update_state() + + @property + def selected(self) -> Palette: + """Retrieve the currently selected palette.""" + try: + return self.palettes[self.selected_uuid] + except KeyError: + LOGGER.warning('No such palette with ID {}', self.selected_uuid) + return self.palettes[UUID_PORTAL2] + + def update_state(self) -> None: + """Update the UI to show correct state.""" + # Clear out all the current data. + for grp_menu in self.ui_group_menus.values(): + grp_menu.delete(0, 'end') + self.ui_menu.delete(self.ui_menu_palettes_index, 'end') + + # Detach all groups + children, and get a list of existing ones. + existing: set[str] = set() + for group_id in self.ui_group_treeids.values(): + existing.update(self.ui_treeview.get_children(group_id)) + self.ui_treeview.detach(group_id) + for pal_id in self.ui_treeview.get_children(''): + if pal_id.startswith('pal_'): + self.ui_treeview.delete(pal_id) + + groups: dict[str, list[Palette]] = {} + for pal in self.palettes.values(): + groups.setdefault(pal.group, []).append(pal) + + for group, palettes in sorted(groups.items(), key=lambda t: (t[0] != paletteLoader.GROUP_BUILTIN, t[0])): + if group == paletteLoader.GROUP_BUILTIN: + group = gettext('Builtin / Readonly') # i18n: Palette group title. + if group: + try: + grp_menu = self.ui_group_menus[group] + except KeyError: + grp_menu = self.ui_group_menus[group] = tk.Menu(self.ui_menu) + self.ui_menu.add_cascade(label=group, menu=grp_menu) + + try: + grp_tree = self.ui_group_treeids[group] + except KeyError: + grp_tree = self.ui_group_treeids[group] = self.ui_treeview.insert( + '', 'end', + text=group, + open=True, + tags=TREE_TAG_GROUPS, + ) + else: + self.ui_treeview.move(grp_tree, '', 9999) + else: # '', directly add. + grp_menu = self.ui_menu + grp_tree = '' # Root. + for pal in sorted(palettes, key=lambda p: p.name): + gear_img = ICO_GEAR.get_tk() if pal.settings is not None else '' + grp_menu.add_radiobutton( + label=pal.name, + value=pal.uuid.hex, + command=self.event_select_menu, + variable=self.var_pal_select, + image=gear_img, + compound='left', + ) + pal_id = 'pal_' + pal.uuid.hex + if pal_id in existing: + existing.remove(pal_id) + self.ui_treeview.move(pal_id, grp_tree, 99999) + self.ui_treeview.item( + pal_id, + text=pal.name, + image=gear_img, + ) + else: # New + self.ui_treeview.insert( + grp_tree, 'end', + text=pal.name, + iid='pal_' + pal.uuid.hex, + image=gear_img, + tags=TREE_TAG_PALETTES, + ) + # Finally strip any ones which were removed. + if existing: + self.ui_treeview.delete(*existing) + + # Select the currently selected UUID. + self.ui_treeview.selection_set('pal_' + self.selected.uuid.hex) + self.ui_treeview.see('pal_' + self.selected.uuid.hex) + + self.ui_menu.entryconfigure( + self.ui_menu_delete_index, + label=gettext('Delete Palette "{}"').format(self.selected.name), + ) + if self.selected.readonly: + self.ui_remove.state(('disabled',)) + self.save_btn_state(('disabled',)) + self.ui_menu.entryconfigure(self.ui_menu_delete_index, state='disabled') + self.ui_menu.entryconfigure(self.ui_menu_regroup_index, state='disabled') + self.ui_menu.entryconfigure(self.ui_menu_save_ind, state='disabled') + self.ui_menu.entryconfigure(self.ui_menu_rename_index, state='disabled') + else: + self.ui_remove.state(('!disabled',)) + self.save_btn_state(('!disabled',)) + self.ui_menu.entryconfigure(self.ui_menu_delete_index, state='normal') + self.ui_menu.entryconfigure(self.ui_menu_regroup_index, state='normal') + self.ui_menu.entryconfigure(self.ui_menu_save_ind, state='normal') + self.ui_menu.entryconfigure(self.ui_menu_rename_index, state='normal') + + def event_save_settings_changed(self) -> None: + """Save the state of this button.""" + BEE2_config.GEN_OPTS['General']['palette_save_settings'] = srctools.bool_as_int(self.var_save_settings.get()) + + def event_remove(self) -> None: + """Remove the currently selected palette.""" + pal = self.selected + if not pal.readonly and messagebox.askyesno( + title='BEE2', + message=gettext('Are you sure you want to delete "{}"?').format(pal.name), + parent=TK_ROOT, + ): + pal.delete_from_disk() + del self.palettes[pal.uuid] + self.select_palette(paletteLoader.UUID_PORTAL2) + self.set_items(self.selected) + + def event_save(self) -> None: + """Save the current palette over the original name.""" + if self.selected.readonly: + self.event_save_as() + return + else: + self.selected.pos = self.get_items() + if self.var_save_settings.get(): + self.selected.settings = BEE2_config.get_curr_settings(is_palette=True) + else: + self.selected.settings = None + self.selected.save(ignore_readonly=True) + self.update_state() + + def event_save_as(self) -> None: + """Save the palette with a new name.""" + name = tk_tools.prompt(gettext("BEE2 - Save Palette"), gettext("Enter a name:")) + if name is None: + # Cancelled... + return + pal = Palette(name, self.get_items()) + while pal.uuid in self.palettes: # Should be impossible. + pal.uuid = paletteLoader.uuid4() + + if self.var_save_settings.get(): + pal.settings = BEE2_config.get_curr_settings(is_palette=True) + + pal.save() + self.palettes[pal.uuid] = pal + self.select_palette(pal.uuid) + self.update_state() + + def event_rename(self) -> None: + """Rename an existing palette.""" + if self.selected.readonly: + return + name = tk_tools.prompt(gettext("BEE2 - Save Palette"), gettext("Enter a name:")) + if name is None: + # Cancelled... + return + self.selected.name = name + self.update_state() + + def select_palette(self, uuid: UUID) -> None: + """Select a new palette, and update state. This does not update items/settings!""" + pal = self.palettes[uuid] + self.selected_uuid = uuid + BEE2_config.GEN_OPTS['Last_Selected']['palette_uuid'] = uuid.hex + + def event_change_group(self) -> None: + """Change the group of a palette.""" + if self.selected.readonly: + return + res = tk_tools.prompt( + gettext("BEE2 - Change Palette Group"), + gettext('Enter the name of the group for this palette, or "" to ungroup.'), + validator=lambda x: x, + ) + if res is not None: + self.selected.group = res.strip('<>') + self.selected.save() + self.update_state() + + def event_select_menu(self) -> None: + """Called when the menu buttons are clicked.""" + uuid_hex = self.var_pal_select.get() + self.select_palette(UUID(hex=uuid_hex)) + self.set_items(self.selected) + # If we remake the palette menus inside this event handler, it tries + # to select the old menu item (likely), so a crash occurs. Delay until + # another frame. + self.ui_treeview.after_idle(self.update_state) + + def event_select_tree(self, evt: tk.Event) -> None: + """Called when palettes are selected on the treeview.""" + # We're called just before it actually changes, so look up by the cursor pos. + uuid_hex = self.ui_treeview.identify('item', evt.x, evt.y)[4:] + self.var_pal_select.set(uuid_hex) + self.select_palette(UUID(hex=uuid_hex)) + self.set_items(self.selected) + self.update_state() + + def treeview_reselect(self) -> None: + """When a group item is selected on the tree, reselect the palette.""" + # This could be called before all the items are added to the UI. + uuid_hex = 'pal_' + self.selected.uuid.hex + if self.ui_treeview.exists(uuid_hex): + self.ui_treeview.selection_set(uuid_hex) diff --git a/src/app/resource_gen.py b/src/app/resource_gen.py index a91aafa43..af16d2e23 100644 --- a/src/app/resource_gen.py +++ b/src/app/resource_gen.py @@ -104,4 +104,12 @@ def make_cube_colourizer_legend(bee2_loc: Path) -> None: vtf_loc.parent.mkdir(parents=True, exist_ok=True) with vtf_loc.open('wb') as f: LOGGER.info('Exporting "{}"...', f.name) - vtf.save(f) + try: + vtf.save(f) + except NotImplementedError: + LOGGER.warning('No DXT compressor, using RGB888.') + # No libsquish, so DXT compression doesn't work. + vtf.format = vtf.low_format = ImageFormats.RGB888 + f.truncate(0) + f.seek(0) + vtf.save(f) diff --git a/src/app/richTextBox.py b/src/app/richTextBox.py index 7826634dc..9900890e5 100644 --- a/src/app/richTextBox.py +++ b/src/app/richTextBox.py @@ -8,7 +8,7 @@ from app import tkMarkdown from app.tk_tools import Cursors -import utils +from localisation import gettext import srctools.logger LOGGER = srctools.logger.get_logger(__name__) @@ -16,7 +16,7 @@ class tkRichText(tkinter.Text): """A version of the TK Text widget which allows using special formatting.""" - def __init__(self, parent, width=10, height=4, font="TkDefaultFont"): + def __init__(self, parent, width=10, height=4, font="TkDefaultFont", **kargs): # Setup all our configuration for inserting text. self.font = nametofont(font) self.bold_font = self.font.copy() @@ -26,7 +26,7 @@ def __init__(self, parent, width=10, height=4, font="TkDefaultFont"): self.italic_font['slant'] = 'italic' # URL -> tag name and callback ID. - self._link_commands: Dict[str, Tuple[str, int]] = {} + self._link_commands: Dict[str, Tuple[str, str]] = {} super().__init__( parent, @@ -36,10 +36,11 @@ def __init__(self, parent, width=10, height=4, font="TkDefaultFont"): font=self.font, # We only want the I-beam cursor over text. cursor=Cursors.REGULAR, + **kargs, ) self.heading_font = {} - cur_size = self.font['size'] + cur_size: float = self.font['size'] for size in range(6, 0, -1): self.heading_font[size] = font = self.font.copy() cur_size /= 0.8735 @@ -111,12 +112,12 @@ def __init__(self, parent, width=10, height=4, font="TkDefaultFont"): self.tag_bind( "link", "", - lambda e: self.configure(cursor=Cursors.LINK), + lambda e: self.__setitem__('cursor', Cursors.LINK), ) self.tag_bind( "link", "", - lambda e: self.configure(cursor=Cursors.REGULAR), + lambda e: self.__setitem__('cursor', Cursors.REGULAR), ) self['state'] = "disabled" @@ -125,6 +126,8 @@ def insert(*args, **kwargs) -> None: """Inserting directly is disallowed.""" raise TypeError('richTextBox should not have text inserted directly.') + # noinspection PyUnresolvedReferences + # noinspection PyProtectedMember def set_text(self, text_data: Union[str, tkMarkdown.MarkdownData]) -> None: """Write the rich-text into the textbox. @@ -145,8 +148,19 @@ def set_text(self, text_data: Union[str, tkMarkdown.MarkdownData]) -> None: super().insert("end", text_data) return + # Strip newlines from the start and end of text. + if text_data._unstripped and len(text_data.blocks) > 1: + first = text_data.blocks[0] + if isinstance(first, tkMarkdown.TextSegment) and first.text.startswith('\n'): + text_data.blocks[0] = tkMarkdown.TextSegment(first.text.lstrip('\n'), first.tags, first.url) + + last = text_data.blocks[-1] + if isinstance(last, tkMarkdown.TextSegment) and last.text.endswith('\n'): + text_data.blocks[-1] = tkMarkdown.TextSegment(last.text.rstrip('\n'), last.tags, last.url) + text_data._unstripped = True + segment: tkMarkdown.TextSegment - for block in text_data.blocks: + for i, block in enumerate(text_data.blocks): if isinstance(block, tkMarkdown.TextSegment): if block.url: try: @@ -180,7 +194,7 @@ def make_link_callback(self, url: str) -> Callable[[tkinter.Event], None]: def callback(e): if askokcancel( title='BEE2 - Open URL?', - message=_('Open "{}" in the default browser?').format(url), + message=gettext('Open "{}" in the default browser?').format(url), parent=self, ): webbrowser.open(url) diff --git a/src/app/selector_win.py b/src/app/selector_win.py index 74caa50e9..b71ea10f0 100644 --- a/src/app/selector_win.py +++ b/src/app/selector_win.py @@ -4,27 +4,38 @@ It appears as a textbox-like widget with a ... button to open the selection window. Each item has a description, author, and icon. """ +from __future__ import annotations + from tkinter import * # ui library from tkinter import font as tk_font from tkinter import ttk # themed ui components that match the OS from collections import defaultdict -from operator import itemgetter from enum import Enum import functools +import operator import math -from typing import NamedTuple, Optional, List, Dict, Union, Iterable, Mapping +import random +from typing import Optional, Union, Iterable, Mapping, Callable, Any, AbstractSet + +import attr from app.richTextBox import tkRichText from app.tkMarkdown import MarkdownData from app.tooltip import add_tooltip, set_tooltip from packages import SelitemData -from srctools import Vec, EmptyMapping +from srctools import Vec, Property, EmptyMapping import srctools.logger from srctools.filesys import FileSystemChain -from app import tkMarkdown, tk_tools, sound, img, TK_ROOT -from consts import SEL_ICON_SIZE as ICON_SIZE, SEL_ICON_SIZE_LRG as ICON_SIZE_LRG +from app import tkMarkdown, tk_tools, sound, img, TK_ROOT, DEV_MODE +from consts import ( + SEL_ICON_SIZE as ICON_SIZE, + SEL_ICON_SIZE_LRG as ICON_SIZE_LRG, + SEL_ICON_CROP_SHRINK as ICON_CROP_SHRINK +) +from localisation import gettext, ngettext import utils +import BEE2_config LOGGER = srctools.logger.get_logger(__name__) @@ -43,10 +54,8 @@ BTN_PLAY = '▶' BTN_STOP = '■' - -if __name__ == '__main__': - import gettext - gettext.NullTranslations().install(['ngettext']) +BTN_PREV = '⟨' +BTN_NEXT = '⟩' class NAV_KEYS(Enum): @@ -75,6 +84,7 @@ class NAV_KEYS(Enum): PLAY_SOUND = 'space' +@utils.freeze_enum_props class AttrTypes(Enum): """The type of labels used for selectoritem attributes.""" STR = STRING = 'string' # Normal text @@ -82,57 +92,121 @@ class AttrTypes(Enum): BOOL = 'bool' # A yes/no checkmark COLOR = COLOUR = 'color' # A Vec 0-255 RGB colour + @property + def is_wide(self) -> bool: + """Determine if this should be placed on its own row, or paired with another.""" + return self.value in ('string', 'list') + + AttrValues = Union[str, list, bool, Vec] -class AttrDef(NamedTuple): + +@attr.define +class WindowState: + """The window state stored in config files for restoration next launch.""" + open_groups: dict[str, bool] + width: int + height: int + + @classmethod + def parse(cls, props: Property) -> WindowState: + """Parse from keyvalues.""" + open_groups = { + prop.name: srctools.conv_bool(prop.value) + for prop in props.find_children('Groups') + } + return WindowState( + open_groups, + props.int('width', -1), props.int('height', -1), + ) + + def export(self) -> Property: + """Generate keyvalues.""" + props = Property('', [ + Property('width', str(self.width)), + Property('height', str(self.height)), + ]) + with props.build() as builder: + with builder.Groups: + for name, is_open in self.open_groups.items(): + builder[name](srctools.bool_as_int(is_open)) + return props + + +# The saved window states. When windows open they read from here, then write +# when closing. +SAVED_STATE: dict[str, WindowState] = {} + + +@BEE2_config.OPTION_SAVE('SelectorWindow', to_palette=False) +def save_handler() -> Property: + """Save properties to the config for next launch.""" + props = Property('', []) + for save_id, state in SAVED_STATE.items(): + prop = state.export() + prop.name = save_id + props.append(prop) + return props + + +@BEE2_config.OPTION_LOAD('SelectorWindow', from_palette=False) +def load_handler(props: Property) -> None: + """Load properties to the config from last launch.""" + for prop in props: + SAVED_STATE[prop.name] = WindowState.parse(prop) + + +@attr.define +class AttrDef: + """Configuration for attributes shown on selector labels.""" id: str desc: str default: AttrValues type: AttrTypes @classmethod - def string(cls, id: str, desc='', default: str='') -> 'AttrDef': + def string(cls, attr_id: str, desc='', default: str='') -> AttrDef: """Alternative constructor for string-type attrs.""" if desc != '' and not desc.endswith(': '): desc += ': ' - return cls(id, desc, default, AttrTypes.STRING) + return AttrDef(attr_id, desc, default, AttrTypes.STRING) @classmethod - def list(cls, id: str, desc='', default: list=None) -> 'AttrDef': + def list(cls, attr_id: str, desc='', default: list=None) -> AttrDef: """Alternative constructor for list-type attrs.""" if default is None: default = [] if desc != '' and not desc.endswith(': '): desc += ': ' - return cls(id, desc, default, AttrTypes.LIST) + return AttrDef(attr_id, desc, default, AttrTypes.LIST) @classmethod - def bool(cls, id: str, desc='', default: bool=False) -> 'AttrDef': + def bool(cls, attr_id: str, desc='', default: bool=False) -> AttrDef: """Alternative constructor for bool-type attrs.""" if desc != '' and not desc.endswith(': '): desc += ': ' - return cls(id, desc, default, AttrTypes.BOOL) + return AttrDef(attr_id, desc, default, AttrTypes.BOOL) @classmethod - def color(cls, id: str, desc='', default: Vec=None) -> 'AttrDef': - """Alternative constructor for String-type attrs.""" + def color(cls, attr_id: str, desc='', default: Vec=None) -> AttrDef: + """Alternative constructor for color-type attrs.""" if default is None: default = Vec(255, 255, 255) if desc != '' and not desc.endswith(': '): desc += ': ' - return cls(id, desc, default, AttrTypes.COLOR) + return AttrDef(attr_id, desc, default, AttrTypes.COLOR) class GroupHeader(ttk.Frame): """The widget used for group headers.""" - def __init__(self, win: 'selWin', title): + def __init__(self, win: SelectorWin, title: str) -> None: self.parent = win super().__init__( win.pal_frame, ) - self.sep_left = ttk.Separator(self) - self.sep_left.grid(row=0, column=0, sticky=EW) + sep_left = ttk.Separator(self) + sep_left.grid(row=0, column=0, sticky=EW) self.columnconfigure(0, weight=1) self.title = ttk.Label( @@ -144,8 +218,8 @@ def __init__(self, win: 'selWin', title): ) self.title.grid(row=0, column=1) - self.sep_right = ttk.Separator(self) - self.sep_right.grid(row=0, column=2, sticky=EW) + sep_right = ttk.Separator(self) + sep_right.grid(row=0, column=2, sticky=EW) self.columnconfigure(2, weight=1) self.arrow = ttk.Label( @@ -160,7 +234,7 @@ def __init__(self, win: 'selWin', title): # For the mouse events to work, we need to bind on all the children too. widgets = self.winfo_children() widgets.append(self) - for wid in widgets: # type: Widget + for wid in widgets: tk_tools.bind_leftclick(wid, self.toggle) wid['cursor'] = tk_tools.Cursors.LINK self.bind('', self.hover_start) @@ -168,10 +242,12 @@ def __init__(self, win: 'selWin', title): @property def visible(self) -> bool: + """Check if the contents are visible.""" return self._visible @visible.setter def visible(self, value: bool) -> None: + """Set if the contents are visible.""" value = bool(value) if value == self._visible: return # Don't do anything.. @@ -180,11 +256,11 @@ def visible(self, value: bool) -> None: self.hover_start() # Update arrow icon self.parent.flow_items() - def toggle(self, e: Event = None) -> None: + def toggle(self, _: Event = None) -> None: """Toggle the header on or off.""" self.visible = not self._visible - def hover_start(self, e: Event = None) -> None: + def hover_start(self, _: Event = None) -> None: """When hovered over, fill in the triangle.""" self.arrow['text'] = ( GRP_EXP_HOVER @@ -192,7 +268,7 @@ def hover_start(self, e: Event = None) -> None: GRP_COLL_HOVER ) - def hover_end(self, e: Event = None) -> None: + def hover_end(self, _: Event = None) -> None: """When leaving, hollow the triangle.""" self.arrow['text'] = ( GRP_EXP @@ -224,8 +300,9 @@ class Item: 'name', 'shortName', 'longName', - 'icon', + '_icon', 'large_icon', + 'previews', 'desc', 'authors', 'group', @@ -233,6 +310,7 @@ class Item: 'button', 'snd_sample', 'attrs', + 'package', '_selector', '_context_lbl', '_context_ind', @@ -246,28 +324,29 @@ def __init__( long_name: Optional[str] = None, icon: Optional[img.Handle]=None, large_icon: Optional[img.Handle] = None, + previews: list[img.Handle] = (), authors: Iterable[str]=(), desc: Union[MarkdownData, str] = MarkdownData(), group: str = '', sort_key: Optional[str] = None, attributes: Mapping[str, AttrValues] = EmptyMapping, snd_sample: Optional[str] = None, + package: str = '', ): self.name = name self.shortName = short_name self.group = group or '' self.longName = long_name or short_name self.sort_key = sort_key + self.package = package if len(self.longName) > 20: self._context_lbl = self.shortName else: self._context_lbl = self.longName - if icon is not None: - self.icon = icon - else: - self.icon = img.Handle.color(img.PETI_ITEM_BG, ICON_SIZE, ICON_SIZE) + self._icon = icon self.large_icon = large_icon + self.previews = list(previews) if isinstance(desc, str): self.desc = tkMarkdown.convert(desc, None) @@ -275,18 +354,36 @@ def __init__( self.desc = desc self.snd_sample = snd_sample - self.authors: List[str] = list(authors) - self.attrs: Dict[str, AttrValues] = dict(attributes) + self.authors: list[str] = list(authors) + self.attrs: dict[str, AttrValues] = dict(attributes) # The button widget for this item. self.button: Optional[ttk.Button] = None # The selector window we belong to. - self._selector: Optional['selWin'] = None + self._selector: Optional[SelectorWin] = None # The position on the menu this item is located at. # This is needed to change the font. self._context_ind: Optional[int] = None - def __repr__(self): - return '' + @property + def icon(self) -> img.Handle: + """If the small image is missing, replace it with the cropped large one.""" + if self._icon is None: + if self.large_icon is not None: + self._icon = self.large_icon.crop( + ICON_CROP_SHRINK, + width=ICON_SIZE, height=ICON_SIZE, + ) + else: + self._icon = img.Handle.color(img.PETI_ITEM_BG, ICON_SIZE, ICON_SIZE) + return self._icon + + @icon.setter + def icon(self, image: img.Handle | None) -> None: + """Alter the icon used.""" + self._icon = image + + def __repr__(self) -> str: + return f'' @property def context_lbl(self) -> str: @@ -294,7 +391,7 @@ def context_lbl(self) -> str: return self._context_lbl @context_lbl.setter - def context_lbl(self, value): + def context_lbl(self, value: str) -> None: """Update the context menu whenver this is set.""" self._context_lbl = value if self._selector and self._context_ind: @@ -304,7 +401,7 @@ def context_lbl(self, value): ) @classmethod - def from_data(cls, obj_id, data: SelitemData, attrs=None): + def from_data(cls, obj_id, data: SelitemData, attrs: Mapping[str, AttrValues] = None) -> Item: """Create a selector Item from a SelitemData tuple.""" return Item( name=obj_id, @@ -312,14 +409,26 @@ def from_data(cls, obj_id, data: SelitemData, attrs=None): long_name=data.name, icon=data.icon, large_icon=data.large_icon, + previews=data.previews, authors=data.auth, desc=data.desc, group=data.group, sort_key=data.sort_key, attributes=attrs, + package=', '.join(sorted(data.packages)), ) - def set_pos(self, x=None, y=None): + def _on_click(self, _: Event = None) -> None: + """Handle clicking on the item. + + If it's already selected, save and close the window. + """ + if self._selector.selected is self: + self._selector.save() + else: + self._selector.sel_item(self) + + def set_pos(self, x: int = None, y: int = None) -> None: """Place the item on the palette.""" if x is None or y is None: # Remove from the window. @@ -327,8 +436,8 @@ def set_pos(self, x=None, y=None): else: self.button.place(x=x, y=y) self.button.lift() # Force a particular stacking order for widgets - - def copy(self) -> 'Item': + + def copy(self) -> Item: """Duplicate an item.""" item = Item.__new__(Item) item.name = self.name @@ -336,6 +445,7 @@ def copy(self) -> 'Item': item.longName = self.longName item.icon = self.icon item.large_icon = self.large_icon + item.previews = self.previews.copy() item.desc = self.desc.copy() item.authors = self.authors.copy() item.group = self.group @@ -343,12 +453,79 @@ def copy(self) -> 'Item': item.snd_sample = self.snd_sample item._context_lbl = self._context_lbl item.attrs = self.attrs + item.package = self.package item._selector = item.button = None return item -class selWin: +class PreviewWindow: + """Displays images previewing the selected item.""" + def __init__(self) -> None: + self.win = Toplevel(TK_ROOT) + self.win.withdraw() + self.win.resizable(False, False) + + # Don't destroy the window when closed. + self.win.protocol("WM_DELETE_WINDOW", self.hide) + self.win.bind("", self.hide) + + self.display = ttk.Label(self.win) + self.display.grid(row=0, column=1, sticky='nsew') + self.win.columnconfigure(1, weight=1) + self.win.rowconfigure(0, weight=1) + + self.parent: Optional[SelectorWin] = None + + self.prev_btn = ttk.Button( + self.win, text=BTN_PREV, command=functools.partial(self.cycle, -1)) + self.next_btn = ttk.Button( + self.win, text=BTN_NEXT, command=functools.partial(self.cycle, +1)) + + self.img: list[img.Handle] = [] + self.index = 0 + + def show(self, parent: SelectorWin, item: Item) -> None: + """Show the window.""" + self.win.transient(parent.win) + self.win.title(gettext('{} Preview').format(item.longName)) + + self.parent = parent + self.index = 0 + self.img = item.previews + img.apply(self.display, self.img[0]) + + if len(self.img) > 1: + self.prev_btn.grid(row=0, column=0, sticky='ns') + self.next_btn.grid(row=0, column=2, sticky='ns') + else: + self.prev_btn.grid_remove() + self.next_btn.grid_remove() + + self.win.deiconify() + self.win.lift() + utils.center_win(self.win, parent.win) + if parent.modal: + parent.win.grab_release() + self.win.grab_set() + + def hide(self) -> None: + """Swap grabs if the parent is modal.""" + if self.parent.modal: + self.win.grab_release() + self.parent.win.grab_set() + self.win.withdraw() + + def cycle(self, off: int) -> None: + """Switch to a new image.""" + self.index = (self.index + off) % len(self.img) + img.apply(self.display, self.img[self.index]) + + +_PREVIEW = PreviewWindow() + + +class SelectorWin: """The selection window for skyboxes, music, goo and voice packs. Optionally an aditional 'None' item can be added, which indicates @@ -368,23 +545,24 @@ class selWin: def __init__( self, tk, - lst: List[Item], + lst: list[Item], *, # Make all keyword-only for readability + save_id: str, # Required! has_none=True, has_def=True, sound_sys: FileSystemChain=None, modal=False, # i18n: 'None' item description - none_desc=_('Do not add anything.'), + none_desc=gettext('Do not add anything.'), none_attrs=EmptyMapping, - none_icon: img.Handle=img.Handle.parse_uri(img.PATH_NONE, ICON_SIZE, ICON_SIZE), + none_icon: img.Handle = img.Handle.parse_uri(img.PATH_NONE, ICON_SIZE, ICON_SIZE), # i18n: 'None' item name. - none_name=_(""), - title='BEE2', - desc='', - readonly_desc='', - callback=None, - callback_params=(), + none_name: str = gettext(""), + title: str = 'BEE2', + desc: str = '', + readonly_desc: str = '', + callback: Callable[..., None]=None, + callback_params: Iterable[Any]=(), attributes: Iterable[AttrDef]=(), ): """Create a window object. @@ -394,6 +572,7 @@ def __init__( Args: - tk: Must be a Toplevel window, either the tk() root or another window if needed. + - save_id: The ID used to save/load the window state. - lst: A list of Item objects, defining the visible items. - If has_none is True, a item will be added to the beginning of the list. @@ -444,7 +623,7 @@ def __init__( # ID of the currently chosen item self.chosen_id = None - # Callback function, and positional arugments to pass + # Callback function, and positional arguments to pass if callback is not None: self.callback = callback self.callback_params = list(callback_params) @@ -452,8 +631,12 @@ def __init__( self.callback = None self.callback_params = () - # Item object for the currently suggested item. - self.suggested = None + # Currently suggested item objects. This would be a set, but we want to randomly pick. + self.suggested: list[Item] = [] + # While the user hovers over the "suggested" button, cycle through random items. But we + # want to apply that specific item when clicked. + self._suggested_rollover: Item | None = None + self._suggest_lbl: list[Label] = [] # Should we have the 'reset to default' button? self.has_def = has_def @@ -465,20 +648,20 @@ def __init__( else: self.item_list = lst try: - self.selected = self.item_list[0] # type: Item + self.selected = self.item_list[0] except IndexError: LOGGER.error('No items for window "{}"!', title) # We crash without items, forcefully add the None item in so at # least this works. self.item_list = [self.noneItem] self.selected = self.noneItem - + self.orig_selected = self.selected self.parent = tk self._readonly = False self.modal = modal - self.win = Toplevel(tk) + self.win = Toplevel(tk, name='selwin_' + save_id) self.win.withdraw() self.win.title("BEE2 - " + title) self.win.transient(master=tk) @@ -497,19 +680,25 @@ def __init__( self.win.bind("", self.key_navigate) # A map from group name -> header widget - self.group_widgets = {} + self.group_widgets: dict[str, GroupHeader] = {} # A map from folded name -> display name self.group_names = {} - self.grouped_items: Dict[str, List[Item]] = {} - # A list of folded group names in the display order. - self.group_order = [] + self.grouped_items: dict[str, list[Item]] = {} + # A list of casefolded group names in the display order. + self.group_order: list[str] = [] # The maximum number of items that fits per row (set in flow_items) self.item_width = 1 + # The ID used to persist our window state across sessions. + self.save_id = save_id.casefold() + # Indicate that flow_items() should restore state. + self.first_open = True + if desc: self.desc_label = ttk.Label( self.win, + name='desc_label', text=desc, justify=LEFT, anchor=W, @@ -523,6 +712,7 @@ def __init__( # PanedWindow allows resizing the two areas independently. self.pane_win = PanedWindow( self.win, + name='area_panes', orient=HORIZONTAL, sashpad=2, # Padding above/below panes sashwidth=3, # Width of border @@ -537,7 +727,7 @@ def __init__( shim.columnconfigure(0, weight=1) # We need to use a canvas to allow scrolling. - self.wid_canvas = Canvas(shim, highlightthickness=0) + self.wid_canvas = Canvas(shim, highlightthickness=0, name='pal_canvas') self.wid_canvas.grid(row=0, column=0, sticky="NSEW") # Add another frame inside to place labels on. @@ -546,6 +736,7 @@ def __init__( self.wid_scroll = tk_tools.HidingScroll( shim, + name='scrollbar', orient=VERTICAL, command=self.wid_canvas.yview, ) @@ -554,29 +745,15 @@ def __init__( tk_tools.add_mousewheel(self.wid_canvas, self.win) - if utils.MAC: - # Labelframe doesn't look good here on OSX - self.sugg_lbl = ttk.Label( - self.pal_frame, - # Draw lines with box drawing characters - text="\u250E\u2500" + _("Suggested") + "\u2500\u2512", - ) - else: - self.sugg_lbl = ttk.LabelFrame( - self.pal_frame, - text=_("Suggested"), - labelanchor=N, - height=50, - ) - # Holds all the widgets which provide info for the current item. - self.prop_frm = ttk.Frame(self.pane_win, borderwidth=4, relief='raised') + self.prop_frm = ttk.Frame(self.pane_win, name='prop_frame', borderwidth=4, relief='raised') self.prop_frm.columnconfigure(1, weight=1) # Border around the selected item icon. width, height = ICON_SIZE_LRG self.prop_icon_frm = ttk.Frame( self.prop_frm, + name='prop_icon_frame', borderwidth=4, relief='raised', width=width, @@ -584,16 +761,18 @@ def __init__( ) self.prop_icon_frm.grid(row=0, column=0, columnspan=4) - self.prop_icon = ttk.Label(self.prop_icon_frm) + self.prop_icon = ttk.Label(self.prop_icon_frm, name='prop_icon') img.apply(self.prop_icon, img.Handle.color(img.PETI_ITEM_BG, *ICON_SIZE_LRG)), self.prop_icon.grid(row=0, column=0) self.prop_icon_frm.configure(dict(zip(('width', 'height'), ICON_SIZE_LRG))) + tk_tools.bind_leftclick(self.prop_icon, self._icon_clicked) name_frame = ttk.Frame(self.prop_frm) self.prop_name = ttk.Label( name_frame, - text="Item", + name='prop_name', + text="", justify=CENTER, font=("Helvetica", 12, "bold"), ) @@ -605,36 +784,27 @@ def __init__( if sound_sys is not None and sound.has_sound(): self.samp_button = samp_button = ttk.Button( name_frame, + name='sample_button', text=BTN_PLAY, width=2, ) samp_button.grid(row=0, column=1) add_tooltip( samp_button, - _("Play a sample of this item."), + gettext("Play a sample of this item."), ) - def set_samp_play(): - samp_button['text'] = BTN_PLAY - - def set_samp_stop(): - samp_button['text'] = BTN_STOP - + # On start/stop, update the button label. self.sampler = sound.SamplePlayer( - stop_callback=set_samp_play, - start_callback=set_samp_stop, + stop_callback=functools.partial(operator.setitem, samp_button, 'text', BTN_PLAY), + start_callback=functools.partial(operator.setitem, samp_button, 'text', BTN_STOP), system=sound_sys, ) samp_button['command'] = self.sampler.play_sample - tk_tools.bind_leftclick(self.prop_icon, self.sampler.play_sample) samp_button.state(('disabled',)) else: self.sampler = None - # If we have a sound sampler, hold the system open while the window - # is so it doesn't snap open/closed while finding files. - self.sampler_held_open = False - self.prop_author = ttk.Label(self.prop_frm, text="Author") self.prop_author.grid(row=2, column=0, columnspan=4) @@ -646,6 +816,7 @@ def set_samp_stop(): self.prop_desc = tkRichText( self.prop_desc_frm, + name='prop_desc', width=40, height=4, font="TkSmallCaptionFont", @@ -660,6 +831,7 @@ def set_samp_stop(): self.prop_scroll = tk_tools.HidingScroll( self.prop_desc_frm, + name='desc_scroll', orient=VERTICAL, command=self.prop_desc.yview, ) @@ -674,18 +846,20 @@ def set_samp_stop(): ttk.Button( self.prop_frm, - text=_("OK"), + name='btn_ok', + text=gettext("OK"), command=self.save, ).grid( row=6, column=0, padx=(8, 8), - ) + ) if self.has_def: self.prop_reset = ttk.Button( self.prop_frm, - text=_("Reset to Default"), + name='btn_suggest', + text=gettext("Select Suggested"), command=self.sel_suggested, ) self.prop_reset.grid( @@ -696,7 +870,8 @@ def set_samp_stop(): ttk.Button( self.prop_frm, - text=_("Cancel"), + name='btn_cancel', + text=gettext("Cancel"), command=self.exit, ).grid( row=6, @@ -707,18 +882,18 @@ def set_samp_stop(): self.win.option_add('*tearOff', False) self.context_menu = Menu(self.win) - self.norm_font = tk_font.nametofont('TkMenuFont') + self.norm_font: tk_font.Font = tk_font.nametofont('TkMenuFont') # Make a font for showing suggested items in the context menu - self.sugg_font = self.norm_font.copy() + self.sugg_font: tk_font.Font = self.norm_font.copy() self.sugg_font['weight'] = tk_font.BOLD - # Make a font for previewing the suggested item - self.mouseover_font = self.norm_font.copy() + # Make a font for previewing the suggested items + self.mouseover_font: tk_font.Font = self.norm_font.copy() self.mouseover_font['slant'] = tk_font.ITALIC # The headers for the context menu - self.context_menus: Dict[str, Menu] = {} + self.context_menus: dict[str, Menu] = {} # The widget used to control which menu option is selected. self.context_var = StringVar() @@ -735,49 +910,73 @@ def set_samp_stop(): ) if attributes: - attr_frame = ttk.Frame(self.prop_frm) - attr_frame.grid( + attrs_frame = ttk.Frame(self.prop_frm) + attrs_frame.grid( row=5, column=0, columnspan=3, sticky=EW, + padx=5, ) + attrs_frame.columnconfigure(0, weight=1) + attrs_frame.columnconfigure(1, weight=1) self.attr = {} # Add in all the attribute labels - for index, attr in enumerate(attributes): + index = 0 + # Wide before short. + attr_order = sorted(attributes, key=lambda at: 0 if at.type.is_wide else 1) + for attrib in attr_order: + attr_frame = ttk.Frame(attrs_frame) desc_label = ttk.Label( attr_frame, - text=attr.desc, + text=attrib.desc, ) - self.attr[attr.id] = val_label = ttk.Label( + self.attr[attrib.id] = val_label = ttk.Label( attr_frame, ) - val_label.default = attr.default - val_label.type = attr.type - if attr.type is AttrTypes.BOOL: + + val_label.default = attrib.default + val_label.type = attrib.type + if attrib.type is AttrTypes.BOOL: # It's a tick/cross label - if attr.default: + if attrib.default: img.apply(val_label, ICON_CHECK) else: img.apply(val_label, ICON_CROSS) - elif attr.type is AttrTypes.COLOR: + elif attrib.type is AttrTypes.COLOR: # A small colour swatch. val_label.configure(relief=RAISED) # Show the color value when hovered. add_tooltip(val_label) - # Position in a 2-wide grid - desc_label.grid( - row=index // 2, - column=(index % 2) * 2, - sticky=E, - ) - val_label.grid( - row=index // 2, - column=(index % 2) * 2 + 1, - sticky=W, - ) + desc_label.grid(row=0, column=0, sticky=E) + val_label.grid(row=0, column=1, sticky=W) + # Wide ones have their own row, narrow ones are two to a row + if attrib.type.is_wide: + if index % 2: # Row has a single narrow, skip the empty space. + index += 1 + attr_frame.grid( + row=index // 2, + column=0, columnspan=2, + sticky=W, + ) + index += 2 + else: + if index % 2: # Right. + ttk.Separator(orient=VERTICAL).grid(row=index//2, column=1, sticky=NS) + attr_frame.grid( + row=index // 2, + column=2, + sticky=E, + ) + else: + attr_frame.grid( + row=index // 2, + column=0, + sticky=W, + ) + index += 1 else: self.attr = None @@ -856,41 +1055,33 @@ def refresh(self) -> None: self.item_list.sort(key=lambda it: (it is not self.noneItem, it.sort_key or it.longName)) grouped_items = defaultdict(list) # If the item is groupless, use 'Other' for the header. - self.group_names = {'': _('Other')} + self.group_names = {'': gettext('Other')} # Ungrouped items appear directly in the menu. self.context_menus = {'': self.context_menu} # First clear off the menu. self.context_menu.delete(0, 'end') - for ind, item in enumerate(self.item_list): + for item in self.item_list: + # noinspection PyProtectedMember if item._selector is not None and item._selector is not self: raise ValueError(f'Item {item} reused on a different selector!') item._selector = self if item.button is None: # New, create the button widget. if item is self.noneItem: - item.button = ttk.Button(self.pal_frame) + item.button = ttk.Button(self.pal_frame, name='item_none') item.context_lbl = item.context_lbl else: item.button = ttk.Button( self.pal_frame, + name='item_' + item.name, text=item.shortName, compound='top', ) - @tk_tools.bind_leftclick(item.button) - def click_item(event=None, *, _self=self, _item=item): - """Handle clicking on the item. - - If it's already selected, save and close the window. - """ - # We need to capture the item in a default, since it's - # the same variable in different iterations - if _item is self.selected: - _self.save() - else: - _self.sel_item(_item) + # noinspection PyProtectedMember + tk_tools.bind_leftclick(item.button, item._on_click) group_key = item.group.strip().casefold() grouped_items[group_key].append(item) @@ -910,7 +1101,7 @@ def click_item(event=None, *, _self=self, _item=item): menu.add_radiobutton( label=item.context_lbl, command=functools.partial(self.sel_item_id, item.name), - var=self.context_var, + variable=self.context_var, value=item.name, ) item._context_ind = len(grouped_items[group_key]) - 1 @@ -922,17 +1113,7 @@ def click_item(event=None, *, _self=self, _item=item): # Note - empty string should sort to the beginning! self.group_order[:] = sorted(self.grouped_items.keys()) - # We start with the ungrouped items, so increase the index - # appropriately. - if '' in grouped_items: - start = len(self.grouped_items['']) - else: - start = 0 - - for index, (key, menu) in enumerate( - sorted(self.context_menus.items(), key=itemgetter(0)), - start=start, - ): + for (key, menu) in sorted(self.context_menus.items(), key=operator.itemgetter(0)): if key == '': # Don't add the ungrouped menu to itself! continue @@ -941,39 +1122,49 @@ def click_item(event=None, *, _self=self, _item=item): label=self.group_names[key], ) # Set a custom attribute to keep track of the menu's index. - menu._context_index = index - self.flow_items() + # The one at the end is the one we just added. + menu._context_index = self.context_menu.index('end') + if self.win.winfo_ismapped(): + self.flow_items() - def exit(self, event: Event = None) -> None: + def exit(self, _: Event = None) -> None: """Quit and cancel, choosing the originally-selected item.""" self.sel_item(self.orig_selected) self.save() - def save(self, event: Event = None) -> None: + def save(self, _: Event = None) -> None: """Save the selected item into the textbox.""" # Stop sample sounds if they're playing if self.sampler is not None: self.sampler.stop() - # And close the reference we opened in open_win(). - if self.sampler_held_open is True: - self.sampler_held_open = False - self.sampler.system.close_ref() - for item in self.item_list: if item.button is not None: + # Unpress everything. + item.button.state(('!alternate', '!pressed', '!active')) img.apply(item.button, None) + if not self.first_open: # We've got state to store. + SAVED_STATE[self.save_id] = state = WindowState( + open_groups={ + grp_id: grp.visible + for grp_id, grp in self.group_widgets.items() + }, + width=self.win.winfo_width(), + height=self.win.winfo_height(), + ) + LOGGER.debug('Storing window state "{}" = {}', self.save_id, state) + if self.modal: self.win.grab_release() self.win.withdraw() self.set_disp() self.do_callback() - def set_disp(self, event: Event = None) -> str: + def set_disp(self, _: Event = None) -> str: """Set the display textbox.""" # Bold the text if the suggested item is selected (like the - # context menu). We check for truthness to ensure it's actually + # context menu). We check for truthiness to ensure it's actually # initialised. if self.display: if self.is_suggested(): @@ -986,21 +1177,34 @@ def set_disp(self, event: Event = None) -> str: else: self.chosen_id = self.selected.name + self._suggested_rollover = None # Discard the rolled over item. self.disp_label.set(self.selected.context_lbl) self.orig_selected = self.selected self.context_var.set(self.selected.name) return "break" # stop the entry widget from continuing with this event def rollover_suggest(self) -> None: - """Show the suggested item when the button is moused over.""" - if self.is_suggested() or self.suggested is None: - # the suggested item is aready the suggested item - # or no suggested item - return - self.display['font'] = self.mouseover_font - self.disp_label.set(self.suggested.context_lbl) + """Pick a suggested item when the button is moused over, and keep cycling.""" + if self.can_suggest(): + self.display['font'] = self.mouseover_font + self._pick_suggested(force=True) + + def _pick_suggested(self, force=False) -> None: + """Randomly select a suggested item.""" + if self.suggested and (force or self._suggested_rollover is not None): + self._suggested_rollover = random.choice(self.suggested) + self.disp_label.set(self._suggested_rollover.context_lbl) + self.display.after(1000, self._pick_suggested) + + def _icon_clicked(self, _: Event) -> None: + """When the large image is clicked, either show the previews or play sounds.""" + if self.sampler: + self.sampler.play_sample() + elif self.selected.previews: + _PREVIEW.show(self, self.selected) - def open_win(self, e: Event = None, *, force_open=False) -> object: + def open_win(self, _: Event = None, *, force_open=False) -> object: + """Display the window.""" if self._readonly and not force_open: TK_ROOT.bell() return 'break' # Tell tk to stop processing this event @@ -1009,24 +1213,44 @@ def open_win(self, e: Event = None, *, force_open=False) -> object: if item.button is not None: img.apply(item.button, item.icon) + # Restore configured states. + if self.first_open: + self.first_open = False + try: + state = SAVED_STATE[self.save_id] + except KeyError: + pass + else: + LOGGER.debug( + 'Restoring saved selectorwin state "{}" = {}', + self.save_id, state, + ) + for grp_id, is_open in state.open_groups.items(): + try: + self.group_widgets[grp_id].visible = is_open + except KeyError: # Stale config, ignore. + LOGGER.warning( + '({}): invalid selectorwin group: "{}"', + self.save_id, grp_id, + ) + if state.width > 0 or state.height > 0: + width = state.width if state.width > 0 else self.win.winfo_reqwidth() + height = state.height if state.height > 0 else self.win.winfo_reqheight() + self.win.geometry(f'{width}x{height}') + self.win.deiconify() - self.win.lift(self.parent) + self.win.lift() + if self.modal: self.win.grab_set() self.win.focus_force() # Focus here to deselect the textbox - # If we have a sound sampler, hold the system open while the window - # is so it doesn't snap open/closed while finding files. - if self.sampler is not None and self.sampler_held_open is False: - self.sampler_held_open = True - self.sampler.system.open_ref() - utils.center_win(self.win, parent=self.parent) self.sel_item(self.selected) self.win.after(2, self.flow_items) - def open_context(self, e: Event = None) -> None: + def open_context(self, _: Event = None) -> None: """Dislay the context window at the text widget.""" if not self._readonly: self.context_menu.post( @@ -1035,8 +1259,20 @@ def open_context(self, e: Event = None) -> None: def sel_suggested(self) -> None: """Select the suggested item.""" - if self.suggested is not None: - self.sel_item(self.suggested) + # Pick the hovered item. + if self._suggested_rollover is not None: + self.sel_item(self._suggested_rollover) + # Not hovering, but we have some, randomly pick. + elif self.suggested: + # Do not re-pick the same item if we can avoid it. + if self.selected in self.suggested and len(self.suggested) > 1: + pool = self.suggested.copy() + pool.remove(self.selected) + else: + pool = self.suggested + self.sel_item(random.choice(pool)) + self.set_disp() + self.do_callback() def do_callback(self) -> None: """Call the callback function.""" @@ -1067,7 +1303,6 @@ def sel_item_id(self, it_id: str) -> bool: def sel_item(self, item: Item, event: Event = None) -> None: """Select the specified item.""" - from app.optionWindow import DEV_MODE self.prop_name['text'] = item.longName if len(item.authors) == 0: self.prop_author['text'] = '' @@ -1083,12 +1318,17 @@ def sel_item(self, item: Item, event: Event = None) -> None: img.apply(self.prop_icon, icon) self.prop_icon_frm.configure(width=icon.width, height=icon.height) + if item.previews and not self.sampler: + self.prop_icon['cursor'] = tk_tools.Cursors.ZOOM_IN + else: + self.prop_icon['cursor'] = tk_tools.Cursors.REGULAR + if DEV_MODE.get(): # Show the ID of the item in the description if item is self.noneItem: text = tkMarkdown.convert('**ID:** *NONE*', None) else: - text = tkMarkdown.convert(f'**ID:** {item.name}', None) + text = tkMarkdown.convert(f'**ID:** `{item.package}`{":" if item.package else ""}`{item.name}`', None) self.prop_desc.set_text(tkMarkdown.join( text, tkMarkdown.MarkdownData.text('\n'), @@ -1117,10 +1357,10 @@ def sel_item(self, item: Item, event: Event = None) -> None: self.samp_button.state(('disabled',)) if self.has_def: - if self.suggested is None or self.selected == self.suggested: - self.prop_reset.state(('disabled',)) - else: + if self.can_suggest(): self.prop_reset.state(('!disabled',)) + else: + self.prop_reset.state(('disabled',)) if self.attr: # Set the attribute items. @@ -1136,7 +1376,7 @@ def sel_item(self, item: Item, event: Event = None) -> None: img.apply(label, img.Handle.color(val, 16, 16)) # Display the full color when hovering.. # i18n: Tooltip for colour swatch. - set_tooltip(label, _('Color: R={r}, G={g}, B={b}').format( + set_tooltip(label, gettext('Color: R={r}, G={g}, B={b}').format( r=int(val.x), g=int(val.y), b=int(val.z), )) elif label.type is AttrTypes.LIST: @@ -1172,8 +1412,14 @@ def key_navigate(self, event: Event) -> None: self.save() return + cur_group_name = self.selected.group.casefold() + cur_group = self.grouped_items[cur_group_name] + # Force the current group to be visible, so you can see what's + # happening. + self.group_widgets[cur_group_name].visible = True + # A list of groups names, in the order that they're visible onscreen - # (skipping hidden ones). + # (skipping hidden ones). Force-include ordered_groups = [ group_name for group_name in self.group_order @@ -1198,9 +1444,6 @@ def key_navigate(self, event: Event) -> None: ) return - cur_group_name = self.selected.group.casefold() - cur_group = self.grouped_items[cur_group_name] - # The index in the current group for an item item_ind = cur_group.index(self.selected) # The index in the visible groups @@ -1222,7 +1465,7 @@ def key_navigate(self, event: Event) -> None: key is NAV_KEYS.UP or key is NAV_KEYS.DN, ) - def _offset_select(self, group_list: List[str], group_ind: int, item_ind: int, is_vert: bool=False) -> None: + def _offset_select(self, group_list: list[str], group_ind: int, item_ind: int, is_vert: bool=False) -> None: """Helper for key_navigate(), jump to the given index in a group. group_list is sorted list of group names. @@ -1286,16 +1529,13 @@ def _offset_select(self, group_list: List[str], group_ind: int, item_ind: int, i else: # Within this group self.sel_item(cur_group[item_ind]) - def flow_items(self, e: Event = None) -> None: + def flow_items(self, _: Event = None) -> None: """Reposition all the items to fit in the current geometry. Called on the event. """ self.pal_frame.update_idletasks() self.pal_frame['width'] = self.wid_canvas.winfo_width() - self.prop_name['wraplength'] = self.prop_desc.winfo_width() - if self.desc_label is not None: - self.desc_label['wraplength'] = self.win.winfo_width() width = (self.wid_canvas.winfo_width() - 10) // ITEM_WIDTH if width < 1: @@ -1305,34 +1545,67 @@ def flow_items(self, e: Event = None) -> None: # The offset for the current group y_off = 0 - # Hide suggestion indicator if the item's not visible. - self.sugg_lbl.place_forget() + # Hide suggestion indicators if they end up unused. + for lbl in self._suggest_lbl: + lbl.place_forget() + suggest_ind = 0 + + # If only the '' group is present, force it to be visible, and hide + # the header. + no_groups = self.group_order == [''] for group_key in self.group_order: items = self.grouped_items[group_key] - group_wid = self.group_widgets[group_key] # type: GroupHeader - group_wid.place( - x=0, - y=y_off, - width=width * ITEM_WIDTH, - ) - group_wid.update_idletasks() - y_off += group_wid.winfo_reqheight() + group_wid = self.group_widgets[group_key] - if not group_wid.visible: - # Hide everything! - for item in items: # type: Item - item.set_pos() - continue + if no_groups: + group_wid.place_forget() + else: + group_wid.place( + x=0, + y=y_off, + width=width * ITEM_WIDTH, + ) + group_wid.update_idletasks() + y_off += group_wid.winfo_reqheight() + + if not group_wid.visible: + # Hide everything! + for item in items: + item.set_pos() + continue # Place each item - for i, item in enumerate(items): # type: int, Item - if item == self.suggested: - self.sugg_lbl.place( + for i, item in enumerate(items): + if item in self.suggested: + # Reuse an existing suggested label. + try: + sugg_lbl = self._suggest_lbl[suggest_ind] + except IndexError: + # Not enough, make more. + if utils.MAC: + # Labelframe doesn't look good here on OSX + sugg_lbl = ttk.Label( + self.pal_frame, + name=f'suggest_label_{suggest_ind}', + # Draw lines with box drawing characters + text="\u250E\u2500" + gettext("Suggested") + "\u2500\u2512", + ) + else: + sugg_lbl = ttk.LabelFrame( + self.pal_frame, + name=f'suggest_label_{suggest_ind}', + text=gettext("Suggested"), + labelanchor=N, + height=50, + ) + self._suggest_lbl.append(sugg_lbl) + suggest_ind += 1 + sugg_lbl.place( x=(i % width) * ITEM_WIDTH + 1, y=(i // width) * ITEM_HEIGHT + y_off, ) - self.sugg_lbl['width'] = item.button.winfo_width() + sugg_lbl['width'] = item.button.winfo_width() item.set_pos( x=(i % width) * ITEM_WIDTH + 1, y=(i // width) * ITEM_HEIGHT + y_off + 20, @@ -1386,67 +1659,79 @@ def __contains__(self, obj: Union[str, Item]) -> bool: return False def is_suggested(self) -> bool: - """Return whether the current item is the suggested one.""" - return self.suggested == self.selected + """Return whether the current item is a suggested one.""" + return self.selected in self.suggested - def _set_context_font(self, item, font: tk_font.Font) -> None: - """Set the font of an item, and its parent group.""" + def can_suggest(self) -> bool: + """Check if a new item can be suggested.""" + if not self.suggested: + return False + if len(self.suggested) > 1: + return True + # If we suggest one item which is selected, that's + # pointless. + return self.suggested != [self.selected] + # noinspection PyProtectedMember + def _set_context_font(self, item, suggested: bool) -> None: + """Set the font of an item, and its parent group.""" + if item._context_ind is None: + return + new_font = self.sugg_font if suggested else self.norm_font if item.group: group_key = item.group.casefold() menu = self.context_menus[group_key] - # Apply the font to the group header as well. - self.group_widgets[group_key].title['font'] = font + # Apply the font to the group header as well, if suggested. + if suggested: + self.group_widgets[group_key].title['font'] = new_font - # Also highlight the menu - self.context_menu.entryconfig( - menu._context_index, # Use a custom attr to keep track of this... - font=font, - ) + # Also highlight the menu + # noinspection PyUnresolvedReferences + self.context_menu.entryconfig( + menu._context_index, # Use a custom attr to keep track of this... + font=new_font, + ) else: menu = self.context_menu - menu.entryconfig( - item._context_ind, - font=font, - ) + menu.entryconfig(item._context_ind, font=new_font) - def set_suggested(self, suggested: Optional[str] = None) -> None: - """Set the suggested item to the given ID. + def set_suggested(self, suggested: AbstractSet[str]=frozenset()) -> None: + """Set the suggested items to the set of IDs. - If the ID is None or does not exist, the suggested item will be cleared. - If the ID is "", it will be set to the None item. + If it is empty, the suggested ID will be cleared. + If "" is present, the None item will be included. """ - if self.suggested is not None: - self._set_context_font( - self.suggested, - self.norm_font, - ) - # Remove the font from the last suggested item + self.suggested.clear() + # Reset all the header fonts, if any item in that group is highlighted it'll + # re-bold it. + for group_key, header in self.group_widgets.items(): + header.title['font'] = self.norm_font + try: + # noinspection PyProtectedMember, PyUnresolvedReferences + ind = self.context_menus[group_key]._context_index + except AttributeError: + pass + else: + self.context_menu.entryconfig(ind, font=self.norm_font) + + self._set_context_font(self.noneItem, '' in suggested) + + for item in self.item_list: + if item.name in suggested: + self._set_context_font(item, True) + self.suggested.append(item) + else: + self._set_context_font(item, False) - if suggested is None or suggested == '': - self.suggested = None - elif suggested == "": - self.suggested = self.noneItem - else: - for item in self.item_list: - if item.name == suggested: - self.suggested = item - break - else: # Not found - self.suggested = None - - if self.suggested is not None: - self._set_context_font( - self.suggested, - font=self.sugg_font, - ) self.set_disp() # Update the textbox if needed + # Reposition all our items, but only if we're visible. if self.win.winfo_ismapped(): - self.flow_items() # Refresh + self.flow_items() -def test(): +def test() -> None: + """Setup a window with dummy data.""" from BEE2_config import GEN_OPTS from packages import find_packages, PACKAGE_SYS from utils import PackagePath @@ -1500,9 +1785,10 @@ def test(): ), ] - window = selWin( + window = SelectorWin( TK_ROOT, test_list, + save_id='_test_window', has_none=True, has_def=True, callback=functools.partial( @@ -1518,9 +1804,10 @@ def test(): ], ) window.widget(TK_ROOT).grid(row=1, column=0, sticky='EW') - window.set_suggested("SKY_BLACK") + window.set_suggested({"SKY_BLACK"}) - def swap_read(): + def swap_read() -> None: + """Toggle readonly.""" window.readonly = not window.readonly ttk.Button(TK_ROOT, text='Readonly', command=swap_read).grid() diff --git a/src/app/signage_ui.py b/src/app/signage_ui.py index d0bbc28d5..fb86d47be 100644 --- a/src/app/signage_ui.py +++ b/src/app/signage_ui.py @@ -53,43 +53,39 @@ def export_data() -> List[Tuple[str, str]]: ] -@overload -def save_load_signage() -> Property: ... -@overload -def save_load_signage(props: Property) -> None: ... - - -@BEE2_config.option_handler('Signage') -def save_load_signage(props: Property=None) -> Optional[Property]: - """Save or load the signage info.""" - if props is None: # Save to properties. - props = Property('Signage', []) - for timer, slot in SLOTS_SELECTED.items(): - props.append(Property( - str(timer), - '' if slot.contents is None - else slot.contents.id, - )) - return props - else: # Load from provided properties. - for child in props: +@BEE2_config.OPTION_SAVE('Signage') +def save_signage() -> Property: + """Save the signage info to settings or a palette.""" + props = Property('Signage', []) + for timer, slot in SLOTS_SELECTED.items(): + props.append(Property( + str(timer), + '' if slot.contents is None + else slot.contents.id, + )) + return props + + +@BEE2_config.OPTION_LOAD('Signage') +def load_signage(props: Property) -> None: + """Load the signage info from settings or a palette.""" + for child in props: + try: + slot = SLOTS_SELECTED[int(child.name)] + except (ValueError, TypeError): + LOGGER.warning('Non-numeric timer value "{}"!', child.name) + continue + except KeyError: + LOGGER.warning('Invalid timer value {}!', child.name) + continue + + if child.value: try: - slot = SLOTS_SELECTED[int(child.name)] - except (ValueError, TypeError): - LOGGER.warning('Non-numeric timer value "{}"!', child.name) - continue + slot.contents = Signage.by_id(child.value) except KeyError: - LOGGER.warning('Invalid timer value {}!', child.name) - continue - - if child.value: - try: - slot.contents = Signage.by_id(child.value) - except KeyError: - LOGGER.warning('No signage with id "{}"!', child.value) - else: - slot.contents = None - return None + LOGGER.warning('No signage with id "{}"!', child.value) + else: + slot.contents = None def style_changed(new_style: Style) -> None: diff --git a/src/app/sound.py b/src/app/sound.py index 2d1141e37..5411af6ed 100644 --- a/src/app/sound.py +++ b/src/app/sound.py @@ -8,7 +8,10 @@ from tkinter import Event from typing import IO, Optional, Callable import os -import threading +import functools +import shutil + +import trio from app import TK_ROOT from srctools.filesys import FileSystemChain, FileSystem, RawFileSystem @@ -24,7 +27,10 @@ ] LOGGER = srctools.logger.get_logger(__name__) +SAMPLE_WRITE_PATH = utils.conf_location('music_sample/music') play_sound = True +# Nursery to hold sound-related tasks. We can cancel this to shutdown sound logic. +_nursery: trio.Nursery | None = None SOUNDS: dict[str, str] = { 'select': 'rollover', @@ -53,29 +59,39 @@ class NullSound: """Sound implementation which does nothing.""" def __init__(self) -> None: - self._play_fx = True + self._block_count = 0 def _unblock_fx(self) -> None: """Reset the ability to use fx_blockable().""" self._play_fx = True - def block_fx(self) -> None: + async def block_fx(self) -> None: """Block fx_blockable for a short time.""" - self._play_fx = False - TK_ROOT.after(50, self._unblock_fx) + self._block_count += 1 + try: + await trio.sleep(0.50) + finally: + self._block_count -= 1 + + async def load(self, name: str) -> Optional[Source]: + """Load and do nothing.""" + return None - def fx_blockable(self, sound: str) -> None: + async def fx_blockable(self, sound: str) -> None: """Play a sound effect. This waits for a certain amount of time between retriggering sounds so they don't overlap. """ - if play_sound and self._play_fx: - self.fx(sound) - self._play_fx = False - TK_ROOT.after(75, self._unblock_fx) + if play_sound and self._block_count == 0: + self._block_count += 1 + try: + await self.fx(sound) + await trio.sleep(0.75) + finally: + self._block_count -= 1 - def fx(self, sound: str) -> None: + async def fx(self, sound: str) -> None: """Play a sound effect.""" @@ -85,77 +101,83 @@ def __init__(self) -> None: super().__init__() self.sources: dict[str, Source] = {} - def load(self, name: str) -> Source: + async def load(self, name: str) -> Optional[Source]: """Load the given UI sound into a source.""" global sounds fname = SOUNDS[name] path = str(utils.install_path('sounds/{}.ogg'.format(fname))) LOGGER.info('Loading sound "{}" -> {}', name, path) try: - src = pyglet.media.load(path, streaming=False) + src = await trio.to_thread.run_sync(functools.partial( + decoder.decode, + file=None, + filename=path, + streaming=False, + )) except Exception: LOGGER.exception("Couldn't load sound {}:", name) LOGGER.info('UI sounds disabled.') sounds = NullSound() + _nursery.cancel_scope.cancel() + return None else: self.sources[name] = src return src - def fx(self, sound: str) -> None: + async def fx(self, sound: str) -> None: """Play a sound effect.""" global sounds if play_sound: try: snd = self.sources[sound] except KeyError: - # We were called before the BG thread loaded em, load it - # synchronously. + # We were called before the BG thread loaded em, load it now. LOGGER.warning('Sound "{}" couldn\'t be loaded in time!', sound) - snd = self.load(sound) + snd = await self.load(sound) try: - snd.play() + if snd is not None: + snd.play() except Exception: LOGGER.exception("Couldn't play sound {}:", sound) LOGGER.info('UI sounds disabled.') + _nursery.cancel_scope.cancel() sounds = NullSound() -def ticker() -> None: - """We need to constantly trigger pyglet.clock.tick(). - - Instead of re-registering this, cache off the command name. - """ - if isinstance(sounds, PygletSound): - try: - tick(True) # True = don't sleep(). - except Exception: - LOGGER.exception('Pyglet tick failed:') - else: # Succeeded, do this again soon. - TK_ROOT.tk.call(ticker_cmd) - - -def load_fx() -> None: - """Load the FX sounds in the background. +async def sound_task() -> None: + """Task run to manage the sound system. - We don't bother locking, we only modify the shared sound - dict at the end. - If we happen to hit a race condition with the main - thread, all that can happen is we load it twice. + We need to constantly trigger pyglet.clock.tick(). This also provides a nursery for + triggering sound tasks, and gradually loads background sounds. """ - for sound in SOUNDS: - # Copy locally, so this instance check stays valid. - snd = sounds - if isinstance(snd, PygletSound): + global _nursery + async with trio.open_nursery() as _nursery: + # Send off sound tasks. + for sound in SOUNDS: + _nursery.start_soon(_load_bg, sound) + while True: try: - snd.load(sound) + tick(True) # True = don't sleep(). except Exception: - LOGGER.exception('Failed to load sound:') - return + LOGGER.exception('Pyglet tick failed:') + _nursery.cancel_scope.cancel() + break + await trio.sleep(0.1) + + +async def _load_bg(sound: str) -> None: + """Load the FX sounds gradually in the background.""" + try: + await sounds.load(sound) + except Exception: + LOGGER.exception('Failed to load sound:') + return _nursery.cancel_scope.cancel() def fx(name) -> None: """Play a sound effect stored in the sounds{} dict.""" - sounds.fx(name) + if _nursery is not None and not _nursery.cancel_scope.cancel_called: + _nursery.start_soon(sounds.fx, name) def fx_blockable(sound: str) -> None: @@ -164,36 +186,50 @@ def fx_blockable(sound: str) -> None: This waits for a certain amount of time between retriggering sounds so they don't overlap. """ - sounds.fx_blockable(sound) + if _nursery is not None and not _nursery.cancel_scope.cancel_called: + _nursery.start_soon(sounds.fx_blockable, sound) def block_fx() -> None: """Block fx_blockable() for a short time.""" - sounds.block_fx() + if _nursery is not None and not _nursery.cancel_scope.cancel_called: + _nursery.start_soon(sounds.block_fx) def has_sound() -> bool: """Return if the sound system is functional.""" return isinstance(sounds, PygletSound) +if utils.WIN and not utils.FROZEN: + # Add a libs folder for FFmpeg dlls. + os.environ['PATH'] = f'{utils.install_path("lib-" + utils.BITNESS).absolute()};{os.environ["PATH"]}' sounds: NullSound try: import pyglet.media from pyglet.media.codecs import Source + from pyglet.media.codecs.ffmpeg import FFmpegDecoder from pyglet import version as pyglet_version from pyglet.clock import tick + decoder = FFmpegDecoder() sounds = PygletSound() - ticker_cmd = ('after', 150, TK_ROOT.register(ticker)) - TK_ROOT.tk.call(ticker_cmd) - threading.Thread(target=load_fx, name='BEE2.sound.load', daemon=True).start() except Exception: LOGGER.exception('Pyglet not importable:') pyglet_version = '(Not installed)' sounds = NullSound() +def clean_sample_folder() -> None: + """Delete files used by the sample player.""" + for file in SAMPLE_WRITE_PATH.parent.iterdir(): + LOGGER.info('Cleaning up "{}"...', file) + try: + file.unlink() + except (PermissionError, FileNotFoundError): + pass + + class SamplePlayer: """Handles playing a single audio file, and allows toggling it on/off.""" def __init__( @@ -204,8 +240,7 @@ def __init__( ) -> None: """Initialise the sample-playing manager. """ - self.sample: Optional[Source] = None - self.start_time: float = 0 # If set, the time to start the track at. + self.player: Optional[pyglet.media.Player] = None self.after: Optional[str] = None self.start_callback = start_callback self.stop_callback = stop_callback @@ -218,17 +253,9 @@ def __init__( @property def is_playing(self) -> bool: """Is the player currently playing sounds?""" - return self.sample is not None + return self.player is not None - def _close_handles(self) -> None: - """Close down previous sounds.""" - if self._handle is not None: - self._handle.close() - if self._cur_sys is not None: - self._cur_sys.close_ref() - self._handle = self._cur_sys = None - - def play_sample(self, e: Event=None) -> None: + def play_sample(self, _: Event=None) -> None: """Play a sample of music. If music is being played it will be stopped instead. @@ -236,48 +263,39 @@ def play_sample(self, e: Event=None) -> None: if self.cur_file is None: return - if self.sample is not None: + if self.player is not None: self.stop() return - self._close_handles() - - with self.system: - try: - file = self.system[self.cur_file] - except (KeyError, FileNotFoundError): - self.stop_callback() - LOGGER.error('Sound sample not found: "{}"', self.cur_file) - return # Abort if music isn't found.. - - child_sys = self.system.get_system(file) - # Special case raw filesystems - Pyglet is more efficient - # if it can just open the file itself. - if isinstance(child_sys, RawFileSystem): - load_path = os.path.join(child_sys.path, file.path) - self._cur_sys = self._handle = None - LOGGER.debug('Loading music directly from {!r}', load_path) - else: - # Use the file objects directly. - load_path = self.cur_file - self._cur_sys = child_sys - self._cur_sys.open_ref() - self._handle = file.open_bin() - LOGGER.debug('Loading music via {!r}', self._handle) - try: - sound = pyglet.media.load(load_path, self._handle) - except Exception: - self.stop_callback() - LOGGER.exception('Sound sample not valid: "{}"', self.cur_file) - return # Abort if music isn't found or can't be loaded. - - if self.start_time: - try: - sound.seek(self.start_time) - except Exception: - LOGGER.exception('Cannot seek in "{}"!', self.cur_file) + try: + file = self.system[self.cur_file] + except (KeyError, FileNotFoundError): + self.stop_callback() + LOGGER.error('Sound sample not found: "{}"', self.cur_file) + return # Abort if music isn't found.. + + child_sys = self.system.get_system(file) + # Special case raw filesystems - Pyglet is more efficient + # if it can just open the file itself. + if isinstance(child_sys, RawFileSystem): + load_path = os.path.join(child_sys.path, file.path) + LOGGER.debug('Loading music directly from {!r}', load_path) + else: + # In a filesystem, we need to extract it. + # SAMPLE_WRITE_PATH + the appropriate extension. + sample_fname = SAMPLE_WRITE_PATH.with_suffix(os.path.splitext(self.cur_file)[1]) + with file.open_bin() as fsrc, sample_fname.open('wb') as fdest: + shutil.copyfileobj(fsrc, fdest) + LOGGER.debug('Loading music {} as {}', self.cur_file, sample_fname) + load_path = str(sample_fname) + try: + sound = decoder.decode(None, load_path) + except Exception: + self.stop_callback() + LOGGER.exception('Sound sample not valid: "{}"', self.cur_file) + return # Abort if music isn't found or can't be loaded. - self.sample = sound.play() + self.player = sound.play() self.after = TK_ROOT.after( int(sound.duration * 1000), self._finished, @@ -286,13 +304,10 @@ def play_sample(self, e: Event=None) -> None: def stop(self) -> None: """Cancel the music, if it's playing.""" - if self.sample is None: - return - - self.sample.pause() - self.sample = None - self._close_handles() - self.stop_callback() + if self.player is not None: + self.player.pause() + self.player = None + self.stop_callback() if self.after is not None: TK_ROOT.after_cancel(self.after) @@ -300,7 +315,6 @@ def stop(self) -> None: def _finished(self) -> None: """Reset values after the sound has finished.""" - self.sample = None + self.player = None self.after = None - self._close_handles() self.stop_callback() diff --git a/src/app/tkMarkdown.py b/src/app/tkMarkdown.py index fa49ce11b..db5846869 100644 --- a/src/app/tkMarkdown.py +++ b/src/app/tkMarkdown.py @@ -2,35 +2,38 @@ This produces a stream of values, which are fed into richTextBox to display. """ -import mistletoe -from mistletoe import block_token as btok -from mistletoe import span_token as stok -import srctools.logger +from __future__ import annotations +from collections.abc import Sequence import urllib.parse -from typing import Optional, Union, Iterable, List, Tuple, NamedTuple, Sequence +from mistletoe import block_token as btok, span_token as stok +import mistletoe +import attr -import utils from app.img import Handle as ImgHandle +import srctools.logger +import utils LOGGER = srctools.logger.get_logger(__name__) -# Mistletoe toke types. -Token = Union[stok.SpanToken, btok.BlockToken] -class TextSegment(NamedTuple): +class Block: + """The kinds of data contained in MarkdownData.""" + + +@attr.frozen +class TextSegment(Block): """Each section added in text blocks.""" text: str # The text to show - tags: Tuple[str, ...] # Tags - url: Optional[str] # If set, the text should be given this URL as a callback. + tags: tuple[str, ...] # Tags + url: str | None # If set, the text should be given this URL as a callback. -class Image(NamedTuple): +@attr.define +class Image(Block): """An image.""" handle: ImgHandle -# The kinds of data contained in MarkdownData -Block = Union[TextSegment, Image] _HR = [ TextSegment('\n', (), None), @@ -39,18 +42,16 @@ class Image(NamedTuple): ] +@attr.define class MarkdownData: """The output of the conversion, a set of tags and link references for callbacks. Blocks are a list of data. """ - __slots__ = ['blocks'] - blocks: Sequence[Block] # External users shouldn't modify directly. - def __init__( - self, - blocks: Iterable[Block] = (), - ) -> None: - self.blocks = list(blocks) + # External users shouldn't modify directly, so make it readonly. + blocks: Sequence[Block] = attr.ib(converter=list, factory=[].copy) + # richtextbox strips the newlines later on, so we can join with these preserved. + _unstripped: bool = True def __bool__(self) -> bool: """Empty data is false.""" @@ -61,7 +62,7 @@ def copy(self) -> 'MarkdownData': return MarkdownData(self.blocks) @classmethod - def text(cls, text: str, *tags: str, url: Optional[str] = None) -> 'MarkdownData': + def text(cls, text: str, *tags: str, url: str | None = None) -> MarkdownData: """Construct data with a single text segment.""" return cls([TextSegment(text, tags, url)]) @@ -74,18 +75,18 @@ class TKRenderer(mistletoe.BaseRenderer): def __init__(self) -> None: # The lists we're currently generating. # If none it's bulleted, otherwise it's the current count. - self._list_stack: List[Optional[int]] = [] - self.package: Optional[str] = None + self._list_stack: list[int | None] = [] + self.package: str | None = None super().__init__() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type, exc_val, exc_tb) -> None: self._list_stack.clear() self.package = None def render(self, token: btok.BlockToken) -> MarkdownData: return super().render(token) - def render_inner(self, token: Token) -> MarkdownData: + def render_inner(self, token: stok.SpanToken | btok.BlockToken) -> MarkdownData: """ Recursively renders child tokens. Joins the rendered strings with no space in between. @@ -98,41 +99,39 @@ def render_inner(self, token: Token) -> MarkdownData: Arguments: token: a branch node who has children attribute. """ - blocks: List[Block] = [] + blocks: list[Block] = [] # Merge together adjacent text segments. for child in token.children: for data in self.render(child).blocks: - if isinstance(data, TextSegment) and blocks and isinstance(blocks[-1], TextSegment): + if isinstance(data, TextSegment) and blocks: last = blocks[-1] - if last.tags == data.tags and last.url == data.url: - blocks[-1] = TextSegment(last.text + data.text, last.tags, last.url) - continue + if isinstance(last, TextSegment): + if last.tags == data.tags and last.url == data.url: + blocks[-1] = TextSegment(last.text + data.text, last.tags, last.url) + continue blocks.append(data) return MarkdownData(blocks) - def _with_tag(self, token: Token, *tags: str, url: str=None) -> MarkdownData: + def _with_tag(self, token: stok.SpanToken | btok.BlockToken, *tags: str, url: str=None) -> MarkdownData: added_tags = set(tags) result = self.render_inner(token) for i, data in enumerate(result.blocks): if isinstance(data, TextSegment): - result.blocks[i] = TextSegment(data.text, tuple(added_tags.union(data.tags)), url or data.url) + new_seg = TextSegment(data.text, tuple(added_tags.union(data.tags)), url or data.url) + result.blocks[i] = new_seg # type: ignore # Readonly to users. return result - def _text(self, text: str, *tags: str, url: str=None) -> MarkdownData: - """Construct data containing a single text section.""" - return MarkdownData([TextSegment(text, tags, url)]) - def render_auto_link(self, token: stok.AutoLink) -> MarkdownData: """An automatic link - the child is a single raw token.""" [child] = token.children assert isinstance(child, stok.RawText) - return self._text(child.content, 'link', url=token.target) + return MarkdownData.text(child.content, 'link', url=token.target) def render_block_code(self, token: btok.BlockCode) -> MarkdownData: [child] = token.children assert isinstance(child, stok.RawText) - return self._text(child.content, 'codeblock') + return MarkdownData.text(child.content, 'codeblock') def render_document(self, token: btok.Document) -> MarkdownData: """Render the outermost document.""" @@ -141,20 +140,12 @@ def render_document(self, token: btok.Document) -> MarkdownData: if not result.blocks: return result - # Strip newlines from the start and end. - first = result.blocks[0] - if isinstance(first, TextSegment) and first.text.startswith('\n'): - result.blocks[0] = TextSegment(first.text.lstrip('\n'), first.tags, first.url) - - last = result.blocks[-1] - if isinstance(last, TextSegment) and last.text.endswith('\n'): - result.blocks[-1] = TextSegment(last.text.rstrip('\n'), last.tags, last.url) return result def render_escape_sequence(self, token: stok.EscapeSequence) -> MarkdownData: [child] = token.children assert isinstance(child, stok.RawText) - return self._text(child.content) + return MarkdownData.text(child.content) def render_image(self, token: stok.Image) -> MarkdownData: """Embed an image into a file.""" @@ -164,13 +155,13 @@ def render_image(self, token: stok.Image) -> MarkdownData: def render_inline_code(self, token: stok.InlineCode) -> MarkdownData: [child] = token.children assert isinstance(child, stok.RawText) - return self._text(child.content, 'code') + return MarkdownData.text(child.content, 'code') def render_line_break(self, token: stok.LineBreak) -> MarkdownData: if token.soft: return MarkdownData([]) else: - return self._text('\n') + return MarkdownData.text('\n') def render_link(self, token: stok.Link) -> MarkdownData: return self._with_tag(token, url=token.target) @@ -193,7 +184,7 @@ def render_list_item(self, token: btok.ListItem) -> MarkdownData: self._list_stack[-1] += 1 result = join( - self._text(prefix, 'list_start'), + MarkdownData.text(prefix, 'list_start'), self._with_tag(token, 'list'), ) @@ -201,23 +192,23 @@ def render_list_item(self, token: btok.ListItem) -> MarkdownData: def render_paragraph(self, token: btok.Paragraph) -> MarkdownData: if self._list_stack: # Collapse together. - return join(self.render_inner(token), self._text('\n')) + return join(self.render_inner(token), MarkdownData.text('\n')) else: - return join(self._text('\n'), self.render_inner(token), self._text('\n')) + return join(MarkdownData.text('\n'), self.render_inner(token), MarkdownData.text('\n')) def render_raw_text(self, token: stok.RawText) -> MarkdownData: - return self._text(token.content) + return MarkdownData.text(token.content) def render_table(self, token: btok.Table) -> MarkdownData: """We don't support tables.""" # TODO? - return self._text('') + return MarkdownData.text('') def render_table_cell(self, token: btok.TableCell) -> MarkdownData: - return self._text('') + return MarkdownData.text('') def render_table_row(self, token: btok.TableRow) -> MarkdownData: - return self._text('') + return MarkdownData.text('') def render_thematic_break(self, token: btok.ThematicBreak) -> MarkdownData: """Render a horizontal rule.""" @@ -241,7 +232,7 @@ def render_emphasis(self, token: stok.Emphasis) -> MarkdownData: _RENDERER = TKRenderer() -def convert(text: str, package: Optional[str]) -> MarkdownData: +def convert(text: str, package: str | None) -> MarkdownData: """Convert markdown syntax into data ready to be passed to richTextBox. The package must be passed to allow using images in the document. @@ -260,13 +251,15 @@ def join(*args: MarkdownData) -> MarkdownData: # We only have one block, just copy and return. return MarkdownData(args[0].blocks) - blocks: List[Block] = [] + blocks: list[Block] = [] for child in args: for data in child.blocks: - if isinstance(data, TextSegment) and blocks and isinstance(blocks[-1], TextSegment): - if blocks[-1].tags == data.tags and blocks[-1].url == data.url: - blocks[-1] = TextSegment(blocks[-1].text + data.text, blocks[-1].tags, blocks[-1].url) + # We also want to combine together text segments next to each other. + if isinstance(data, TextSegment) and blocks: + last = blocks[-1] + if isinstance(last, TextSegment) and last.tags == data.tags and last.url == data.url: + blocks[-1] = TextSegment(last.text + data.text, last.tags, last.url) continue blocks.append(data) diff --git a/src/app/tk_tools.py b/src/app/tk_tools.py index e7d043b90..27bc3b883 100644 --- a/src/app/tk_tools.py +++ b/src/app/tk_tools.py @@ -3,12 +3,13 @@ """ import functools +import sys from enum import Enum from typing import overload, cast, Any, TypeVar, Protocol, Union, Callable, Optional, Literal from tkinter import ttk from tkinter import font as _tk_font -from tkinter import filedialog, commondialog, simpledialog +from tkinter import filedialog, commondialog, simpledialog, messagebox import tkinter as tk import os.path @@ -163,32 +164,35 @@ class Cursors(str, Enum): REGULAR = 'arrow' LINK = 'hand2' WAIT = 'watch' + ZOOM_IN = 'size_nw_se' STRETCH_VERT = 'sb_v_double_arrow' STRETCH_HORIZ = 'sb_h_double_arrow' - MOVE_ITEM = 'plus' + MOVE_ITEM = 'fleur' DESTROY_ITEM = 'x_cursor' INVALID_DRAG = 'no' elif utils.MAC: - class Cursors(str, Enum): + class Cursors(str, Enum): # type: ignore """Cursors we use, mapping to the relevant OS cursor.""" REGULAR = 'arrow' LINK = 'pointinghand' WAIT = 'spinning' + ZOOM_IN = 'zoom-in' STRETCH_VERT = 'resizeupdown' STRETCH_HORIZ = 'resizeleftright' - MOVE_ITEM = 'plus' + MOVE_ITEM = 'movearrow' DESTROY_ITEM = 'poof' INVALID_DRAG = 'notallowed' elif utils.LINUX: - class Cursors(str, Enum): + class Cursors(str, Enum): # type: ignore """Cursors we use, mapping to the relevant OS cursor.""" REGULAR = 'arrow' LINK = 'hand1' WAIT = 'watch' + ZOOM_IN = 'sizing' STRETCH_VERT = 'bottom_side' STRETCH_HORIZ = 'right_side' - MOVE_ITEM = 'crosshair' - DESTROY_ITEM = 'X_cursor' + MOVE_ITEM = 'fleur' + DESTROY_ITEM = 'x_cursor' INVALID_DRAG = 'circle' else: raise AssertionError @@ -198,52 +202,34 @@ class Cursors(str, Enum): def add_mousewheel(target: tk.XView, *frames: tk.Misc, orient: Literal['x']) -> None: """...""" @overload def add_mousewheel(target: tk.YView, *frames: tk.Misc, orient: Literal['y']='y') -> None: """...""" +def add_mousewheel(target: Union[tk.XView, tk.YView], *frames: tk.Misc, orient: Literal['x', 'y']='y') -> None: + """Add events so scrolling anywhere in a frame will scroll a target. + + frames should be the TK objects to bind to - mainly Frame or + Toplevel objects. + Set orient to 'x' or 'y'. + This is needed since different platforms handle mousewheel events + differently: + - Windows needs the delta value to be divided by 120. + - OS X needs the delta value passed unmodified. + - Linux uses Button-4 and Button-5 events instead of + a MouseWheel event. + """ + scroll_func = getattr(target, orient + 'view_scroll') -if utils.WIN: - def add_mousewheel(target: Union[tk.XView, tk.YView], *frames: tk.Misc, orient: Literal['x', 'y']='y') -> None: - """Add events so scrolling anywhere in a frame will scroll a target. - - frames should be the TK objects to bind to - mainly Frame or - Toplevel objects. - Set orient to 'x' or 'y'. - This is needed since different platforms handle mousewheel events - differently - Windows needs the delta value to be divided by 120. - """ - scroll_func = getattr(target, orient + 'view_scroll') - + if utils.WIN: def mousewheel_handler(event: tk.Event) -> None: """Handle mousewheel events.""" scroll_func(int(event.delta / -120), "units") for frame in frames: frame.bind('', mousewheel_handler, add=True) -elif utils.MAC: - def add_mousewheel(target: Union[tk.XView, tk.YView], *frames: tk.Misc, orient: Literal['x', 'y']='y') -> None: - """Add events so scrolling anywhere in a frame will scroll a target. - - frame should be a sequence of any TK objects, like a Toplevel or Frame. - Set orient to 'x' or 'y'. - This is needed since different platforms handle mousewheel events - differently - OS X needs the delta value passed unmodified. - """ - scroll_func = getattr(target, orient + 'view_scroll') - + elif utils.MAC: def mousewheel_handler(event: tk.Event) -> None: """Handle mousewheel events.""" scroll_func(-event.delta, "units") for frame in frames: frame.bind('', mousewheel_handler, add=True) -elif utils.LINUX: - def add_mousewheel(target: Union[tk.XView, tk.YView], *frames: tk.Misc, orient: Literal['x', 'y']='y') -> None: - """Add events so scrolling anywhere in a frame will scroll a target. - - frame should be a sequence of any TK objects, like a Toplevel or Frame. - Set orient to 'x' or 'y'. - This is needed since different platforms handle mousewheel events - differently - Linux uses Button-4 and Button-5 events instead of - a MouseWheel event. - """ - scroll_func = getattr(target, orient + 'view_scroll') - + elif utils.LINUX: def scroll_up(event: tk.Event) -> None: """Handle scrolling up.""" scroll_func(-1, "units") @@ -255,29 +241,32 @@ def scroll_down(event: tk.Event) -> None: for frame in frames: frame.bind('', scroll_up, add=True) frame.bind('', scroll_down, add=True) + else: + raise AssertionError('Unknown platform ' + sys.platform) -EventFuncT = TypeVar('EventFuncT', bound=Callable[[tk.Event], Any]) +EventFunc = Callable[[tk.Event], Any] +EventFuncT = TypeVar('EventFuncT', bound=EventFunc) class _Binder(Protocol): @overload - def __call__(self, wid: tk.Misc, add: bool=False) -> Callable[[EventFuncT], EventFuncT]: + def __call__(self, wid: tk.Misc, *, add: bool=False) -> Callable[[EventFuncT], EventFuncT]: pass @overload - def __call__(self, wid: tk.Misc, func: EventFuncT, add: bool=False) -> str: + def __call__(self, wid: tk.Misc, func: EventFunc, *, add: bool=False) -> str: pass - def __call__(self, wid: tk.Misc, func: Optional[EventFuncT]=None, add: bool=False): + def __call__(self, wid: tk.Misc, func: Optional[EventFunc]=None, *, add: bool=False) -> Union[Callable[[EventFuncT], EventFuncT], str]: pass -def _bind_event_handler(bind_func: Callable[[tk.Misc, EventFuncT, bool], None]) -> _Binder: +def _bind_event_handler(bind_func: Callable[[tk.Misc, EventFunc, bool], None]) -> _Binder: """Decorator for the bind_click functions. This allows calling directly, or decorating a function with just wid and add attributes. """ - def deco(wid, func=None, add=False): + def deco(wid: tk.Misc, func: Optional[EventFunc]=None, *, add: bool=False): """Decorator or normal interface, func is optional to be a decorator.""" if func is None: def deco_2(func): @@ -344,21 +333,80 @@ def event_cancel(*args, **kwargs) -> str: return 'break' -class QueryShim(simpledialog._QueryString): - """Replicate the new API with the old simpledialog code.""" - def __init__(self, parent, title, message, text0): - super().__init__(title, message, initialvalue=text0, parent=parent) +def _default_validator(value) -> str: + if not value.strip(): + raise ValueError("A value must be provided!") + return value + + +class BasicQueryValidator(simpledialog.Dialog): + """Implement the dialog with the simpledialog code.""" + def __init__( + self, + parent: tk.Misc, + title: str, message: str, initial: str, + validator: Callable[[str], str] = _default_validator, + ) -> None: + self.__validator = validator + self.__title = title + self.__message = message + self.__initial = initial + super().__init__(parent, title) def body(self, master): - """Ensure the window icon is changed.""" + """Ensure the window icon is changed, and copy code from askstring's internals.""" super().body(master) set_window_icon(self) + w = ttk.Label(master, text=self.__message, justify='left') + w.grid(row=0, padx=5, sticky='w') + + self.entry = ttk.Entry(master, name="entry") + self.entry.grid(row=1, padx=5, sticky='we') + + if self.__initial: + self.entry.insert(0, self.__initial) + self.entry.select_range(0, 'end') + + return self.entry + + def validate(self) -> bool: + try: + self.result = self.__validator(self.entry.get()) + except ValueError as exc: + messagebox.showwarning(self.__title, exc.args[0], parent=self) + return False + else: + return True + +Query = None +if Query is not None: + class QueryValidator(Query): + """Implement using IDLE's better code for this.""" + def __init__( + self, + parent: tk.Misc, + title: str, message: str, initial: str, + validator: Callable[[str], str] = _default_validator, + ) -> None: + self.__validator = validator + super().__init__(parent, title, message, text0=initial) + + def entry_ok(self) -> Optional[str]: + """Return non-blank entry or None.""" + try: + return self.__validator(self.entry.get()) + except ValueError as exc: + self.showerror(exc.args[0]) + return None +else: + QueryValidator = BasicQueryValidator def prompt( title: str, message: str, initialvalue: str='', parent: tk.Misc= TK_ROOT, + validator: Callable[[str], str] = _default_validator, ) -> Optional[str]: """Ask the user to enter a string.""" from loadScreen import suppress_screens @@ -370,14 +418,10 @@ def prompt( if Query is None or (utils.WIN and ( not _main_loop_running or not TK_ROOT.winfo_viewable() )): - query_cls = QueryShim + query_cls = BasicQueryValidator else: - query_cls = Query - return query_cls( - parent, - title, message, - text0=initialvalue, - ).result + query_cls = QueryValidator + return query_cls(parent, title, message, initialvalue, validator).result class HidingScroll(ttk.Scrollbar): @@ -439,12 +483,12 @@ def __init__(self, master: tk.Misc, range: Union[range, slice]=None, **kw) -> No @property def value(self) -> int: """Get the value of the spinbox.""" - return self.tk.call(self._w, 'get') + return self.tk.call(self._w, 'get') # type: ignore @value.setter def value(self, value: int) -> None: """Set the spinbox to a value.""" - self.tk.call(self._w, 'set', value) + self.tk.call(self._w, 'set', value) # type: ignore def validate(self) -> bool: """Values must be integers.""" @@ -452,7 +496,7 @@ def validate(self) -> bool: self.old_val = int(self.value) return True except ValueError: - self.value = str(self.old_val) + self.value = self.old_val return False _file_field_font = _tk_font.nametofont('TkFixedFont') # Monospaced font @@ -517,10 +561,14 @@ def __init__( self.browse_btn = ttk.Button( self, text="...", - width=1.5, command=self.browse, ) self.browse_btn.grid(row=0, column=1) + # It should be this narrow, but perhaps this doesn't accept floats? + try: + self.browse_btn['width'] = 1.5 + except tk.TclError: + self.browse_btn['width'] = 2 self._text_var.set(self._truncate(loc)) diff --git a/src/app/voiceEditor.py b/src/app/voiceEditor.py index 66f473b63..72adba482 100644 --- a/src/app/voiceEditor.py +++ b/src/app/voiceEditor.py @@ -12,6 +12,7 @@ from app import img, TK_ROOT import srctools.logger from app import tk_tools +from localisation import gettext import utils from BEE2_config import ConfigFile from app.tooltip import add_tooltip @@ -33,24 +34,24 @@ IMG: Dict[str, Tuple[img.Handle, str]] = { spr: (img.Handle.builtin('icons/quote_' + spr), ctx) for spr, ctx in [ - ('sp', _('Singleplayer')), - ('coop', _('Cooperative')), - ('atlas', _('ATLAS (SP/Coop)')), - ('pbody', _('P-Body (SP/Coop)')), - ('bendy', _('Bendy')), - ('chell', _('Chell')), - ('human', _('Human characters (Bendy and Chell)')), - ('robot', _('AI characters (ATLAS, P-Body, or Coop)')), + ('sp', gettext('Singleplayer')), + ('coop', gettext('Cooperative')), + ('atlas', gettext('ATLAS (SP/Coop)')), + ('pbody', gettext('P-Body (SP/Coop)')), + ('bendy', gettext('Bendy')), + ('chell', gettext('Chell')), + ('human', gettext('Human characters (Bendy and Chell)')), + ('robot', gettext('AI characters (ATLAS, P-Body, or Coop)')), ] } # Friendly names given to certain response channels. RESPONSE_NAMES = { - 'death_goo': _('Death - Toxic Goo'), - 'death_turret': _('Death - Turrets'), - 'death_crush': _('Death - Crusher'), - 'death_laserfield': _('Death - LaserField'), + 'death_goo': gettext('Death - Toxic Goo'), + 'death_turret': gettext('Death - Turrets'), + 'death_crush': gettext('Death - Crusher'), + 'death_laserfield': gettext('Death - LaserField'), } config: Optional[ConfigFile] = None @@ -103,7 +104,7 @@ def init_widgets(): ttk.Label( trans_frame, - text=_('Transcript:'), + text=gettext('Transcript:'), ).grid( row=0, column=0, @@ -142,7 +143,7 @@ def init_widgets(): ttk.Button( win, - text=_('Save'), + text=gettext('Save'), command=save, ).grid(row=2, column=0) @@ -217,7 +218,7 @@ def add_tabs(): compound=RIGHT, image=img.Handle.builtin('icons/resp_quote', 16, 16), #Note: 'response' tab name, should be short. - text=_('Resp') + text=gettext('Resp') ) else: notebook.tab(tab, text=tab.nb_text) @@ -234,7 +235,7 @@ def show(quote_pack): voice_item = quote_pack - win.title(_('BEE2 - Configure "{}"').format(voice_item.selitem_data.name)) + win.title(gettext('BEE2 - Configure "{}"').format(voice_item.selitem_data.name)) win.grab_set() notebook = UI['tabs'] @@ -311,17 +312,17 @@ def make_tab(group, config: ConfigFile, tab_type): """Create all the widgets for a tab.""" if tab_type is TabTypes.MIDCHAMBER: # Mid-chamber voice lines have predefined values. - group_name = _('Mid - Chamber') + group_name = gettext('Mid - Chamber') group_id = 'MIDCHAMBER' - group_desc = _( + group_desc = gettext( 'Lines played during the actual chamber, ' 'after specific events have occurred.' ) elif tab_type is TabTypes.RESPONSE: # Note: 'Response' tab header, and description - group_name = _('Responses') + group_name = gettext('Responses') group_id = None - group_desc = _( + group_desc = gettext( 'Lines played in response to certain events in Coop.' ) elif tab_type is TabTypes.NORM: @@ -419,7 +420,7 @@ def make_tab(group, config: ConfigFile, tab_type): group_id = quote.name else: # note: default for quote names - name = quote['name', _('No Name!')] + name = quote['name', gettext('No Name!')] ttk.Label( frame, @@ -455,7 +456,7 @@ def make_tab(group, config: ConfigFile, tab_type): check = ttk.Checkbutton( line_frame, # note: default voice line name next to checkbox. - text=line['name', _('No Name?')], + text=line['name', gettext('No Name?')], ) check.quote_var = IntVar( @@ -540,4 +541,3 @@ def get_trans_lines(trans_block): yield name.rstrip(), ': "' + trans.lstrip() + '"' else: yield '', '"' + prop.value + '"' - diff --git a/src/bg_daemon.py b/src/bg_daemon.py index 59761998d..bdabcfaef 100644 --- a/src/bg_daemon.py +++ b/src/bg_daemon.py @@ -144,8 +144,11 @@ def op_step(self, stage: str) -> None: def op_set_length(self, stage: str, num: int) -> None: """Set the number of items in a stage.""" - self.maxes[stage] = num - self.update_stage(stage) + if num == 0: + self.op_skip_stage(stage) + else: + self.maxes[stage] = num + self.update_stage(stage) def op_skip_stage(self, stage: str) -> None: """Skip over this stage of the loading process.""" @@ -473,14 +476,18 @@ def __init__(self, *args): ) def update_stage(self, stage): - text = '{}: ({}/{})'.format( - self.names[stage], - self.values[stage], - self.maxes[stage], - ) + if self.maxes[stage] == 0: + text = f'{self.names[stage]}: (0/0)' + self.set_bar(stage, 1) + else: + text = ( + f'{self.names[stage]}: ' + f'({self.values[stage]}/{self.maxes[stage]})' + ) + self.set_bar(stage, self.values[stage] / self.maxes[stage]) + self.sml_canvas.itemconfig('text_' + stage, text=text) self.lrg_canvas.itemconfig('text_' + stage, text=text) - self.set_bar(stage, self.values[stage] / self.maxes[stage]) def set_bar(self, stage, fraction): """Set a progress bar to this fractional length.""" @@ -504,7 +511,7 @@ def op_set_length(self, stage, num): canvas.delete('tick_' + stage) if num == 0: - return # No ticks + continue # No ticks # Draw the ticks in... _, y1, _, y2 = canvas.coords('bar_' + stage) @@ -762,34 +769,44 @@ def check_queue(): """Update stages from the parent process.""" nonlocal force_ontop had_values = False - while PIPE_REC.poll(): # Pop off all the values. - had_values = True - operation, scr_id, args = PIPE_REC.recv() - if operation == 'init': - # Create a new loadscreen. - is_main, title, stages = args - screen = (SplashScreen if is_main else LoadScreen)(scr_id, title, force_ontop, stages) - SCREENS[scr_id] = screen - elif operation == 'set_force_ontop': - [force_ontop] = args - for screen in SCREENS.values(): - screen.win.attributes('-topmost', force_ontop) - else: - try: - func = getattr(SCREENS[scr_id], 'op_' + operation) - except AttributeError: - raise ValueError(f'Bad command "{operation}"!') - try: - func(*args) - except Exception: - raise Exception(operation) - while log_pipe_rec.poll(): - log_window.handle(log_pipe_rec.recv()) + try: + while PIPE_REC.poll(): # Pop off all the values. + had_values = True + operation, scr_id, args = PIPE_REC.recv() + if operation == 'init': + # Create a new loadscreen. + is_main, title, stages = args + screen = (SplashScreen if is_main else LoadScreen)(scr_id, title, force_ontop, stages) + SCREENS[scr_id] = screen + elif operation == 'quit_daemon': + # Shutdown. + TK_ROOT.quit() + return + elif operation == 'set_force_ontop': + [force_ontop] = args + for screen in SCREENS.values(): + screen.win.attributes('-topmost', force_ontop) + else: + try: + func = getattr(SCREENS[scr_id], 'op_' + operation) + except AttributeError: + raise ValueError(f'Bad command "{operation}"!') + try: + func(*args) + except Exception: + raise Exception(operation) + while log_pipe_rec.poll(): + log_window.handle(log_pipe_rec.recv()) + except BrokenPipeError: + # A pipe failed, means the main app quit. Terminate ourselves. + print('BG: Lost pipe!') + TK_ROOT.quit() + return # Continually re-run this function in the TK loop. # If we didn't find anything in the pipe, wait longer. # Otherwise we hog the CPU. TK_ROOT.after(1 if had_values else 200, check_queue) - + TK_ROOT.after(10, check_queue) TK_ROOT.mainloop() # Infinite loop, until the entire process tree quits. diff --git a/src/compiler.spec b/src/compiler.spec index 5144630cb..1fe01abcd 100644 --- a/src/compiler.spec +++ b/src/compiler.spec @@ -6,6 +6,12 @@ import pkgutil import os import sys +# Injected by PyInstaller. +workpath: str +SPECPATH: str + +# Allow importing utils. +sys.path.append(SPECPATH) # THe BEE2 modules cannot be imported inside the spec files. WIN = sys.platform.startswith('win') @@ -33,26 +39,17 @@ if not transform_loc.exists(): # Unneeded packages that cx_freeze detects: EXCLUDES = [ - 'argparse', # Used in __main__ of some modules 'bz2', # We aren't using this compression format (shutil, zipfile etc handle ImportError).. - 'distutils', # Found in shutil, used if zipfile is not availible - 'doctest', # Used in __main__ of decimal and heapq - 'optparse', # Used in calendar.__main__ - 'pprint', # From pickle, not needed - 'textwrap', # Used in zipfile.__main__ - - # We don't localise the compiler, but utils imports the modules. - 'locale', 'gettext', # This isn't ever used in the compiler. 'tkinter', - # We aren't using the Python 2 code, for obvious reasons. - 'importlib_resources._py2', + # 3.6 backport + 'importlib_resources', 'win32api', 'win32com', - 'win32wnet' + 'win32wnet', # Imported by logging handlers which we don't use.. 'win32evtlog', @@ -62,6 +59,8 @@ EXCLUDES = [ # Imported in utils, but not required in compiler. 'bg_daemon', + # We don't need to actually run versioning at runtime. + 'versioningit', ] # The modules made available for plugins to use. @@ -72,7 +71,7 @@ INCLUDES = [ 'io', 'itertools', 'json', 'math', 'random', 're', 'statistics', 'string', 'struct', ] -INCLUDES += collect_submodules('srctools', lambda name: 'pyinstaller' not in name and 'test' not in name and 'script' not in name) +INCLUDES += collect_submodules('srctools', lambda name: 'pyinstaller' not in name and 'script' not in name) # These also aren't required by logging really, but by default # they're imported unconditionally. Check to see if it's modified first. @@ -105,14 +104,12 @@ INCLUDES += [ ] -bee_version = input('BEE2 Version ("x.y.z" or blank for dev): ') -if bee_version: - bee_version = '2 v' + bee_version - # Write this to the temp folder, so it's picked up and included. # Don't write it out though if it's the same, so PyInstaller doesn't reparse. -version_val = 'BEE_VERSION=' + repr(bee_version) -version_filename = os.path.join(workpath, 'BUILD_CONSTANTS.py') +import utils +version_val = 'BEE_VERSION=' + repr(utils.get_git_version(SPECPATH)) +print(version_val) +version_filename = os.path.join(workpath, '_compiled_version.py') with contextlib.suppress(FileNotFoundError), open(version_filename) as f: if f.read().strip() == version_val: @@ -122,14 +119,6 @@ if version_val: with open(version_filename, 'w') as f: f.write(version_val) -# Empty module to be the package __init__. -transforms_stub = Path(workpath, 'transforms_stub.py') -try: - with transforms_stub.open('x') as f: - f.write('__path__ = []\n') -except FileExistsError: - pass - # Finally, run the PyInstaller analysis process. from PyInstaller.building.build_main import Analysis, PYZ, EXE, COLLECT @@ -143,14 +132,33 @@ vbsp_vrad_an = Analysis( ) # Force the BSP transforms to be included in their own location. +# Map package -> module. +names: 'dict[str, list[str]]' = {} for mod in transform_loc.rglob('*.py'): rel_path = mod.relative_to(transform_loc) if rel_path.name.casefold() == '__init__.py': rel_path = rel_path.parent mod_name = rel_path.with_suffix('') - dotted = str(mod_name).replace('\\', '.').replace('/', '.') - vbsp_vrad_an.pure.append(('postcomp.transforms.' + dotted, str(mod), 'PYMODULE')) + dotted = 'postcomp.transforms.' + str(mod_name).replace('\\', '.').replace('/', '.') + package, module = dotted.rsplit('.', 1) + names.setdefault(package, []).append(module) + vbsp_vrad_an.pure.append((dotted, str(mod), 'PYMODULE')) + +# The package's __init__, where we add the names of all the transforms. +# Build up a bunch of import statements to import them all. +transforms_stub = Path(workpath, 'transforms_stub.py') +with transforms_stub.open('w') as f: + f.write(f'__path__ = []\n') # Make it a package. + # Sort long first, then by name. + for pack, modnames in sorted(names.items(), key=lambda t: (-len(t[1]), t[0])): + if pack: + f.write(f'from {pack} import ') + else: + f.write('import ') + modnames.sort() + f.write(', '.join(modnames)) + f.write('\n') vbsp_vrad_an.pure.append(('postcomp.transforms', str(transforms_stub), 'PYMODULE')) diff --git a/src/connections.py b/src/connections.py index 2e69e8a70..9cc5113a7 100644 --- a/src/connections.py +++ b/src/connections.py @@ -2,11 +2,11 @@ This controls how I/O is generated for each item. """ +from __future__ import annotations import sys from enum import Enum from typing import Dict, Optional, Tuple, Iterable, List -import consts from srctools import Output, Vec, Property @@ -115,11 +115,11 @@ def __init__( input_type: InputType=InputType.DEFAULT, spawn_fire: FeatureMode=FeatureMode.NEVER, - invert_var: str = '0', enable_cmd: Iterable[Output]=(), disable_cmd: Iterable[Output]=(), + sec_spawn_fire: FeatureMode=FeatureMode.NEVER, sec_invert_var: str='0', sec_enable_cmd: Iterable[Output]=(), sec_disable_cmd: Iterable[Output]=(), @@ -165,6 +165,7 @@ def __init__( # Same for secondary items. self.sec_invert_var = sec_invert_var + self.sec_spawn_fire = sec_spawn_fire self.sec_enable_cmd = tuple(sec_enable_cmd) self.sec_disable_cmd = tuple(sec_disable_cmd) @@ -267,6 +268,11 @@ def get_outputs(prop_name): spawn_fire = FeatureMode.ALWAYS if spawn_fire_bool else FeatureMode.NEVER + try: + sec_spawn_fire = FeatureMode(conf['sec_spawnfire', 'never'].casefold()) + except ValueError: # Default to primary value. + sec_spawn_fire = FeatureMode.NEVER + if input_type is InputType.DUAL: sec_enable_cmd = get_outputs('sec_enable_cmd') sec_disable_cmd = get_outputs('sec_disable_cmd') @@ -330,9 +336,9 @@ def get_input(prop_name: str): ] return Config( - item_id, default_dual, input_type, spawn_fire, - invert_var, enable_cmd, disable_cmd, - sec_invert_var, sec_enable_cmd, sec_disable_cmd, + item_id, default_dual, input_type, + spawn_fire, invert_var, enable_cmd, disable_cmd, + sec_spawn_fire, sec_invert_var, sec_enable_cmd, sec_disable_cmd, output_type, out_act, out_deact, lock_cmd, unlock_cmd, out_lock, out_unlock, inf_lock_only, timer_sound_pos, timer_done_cmd, force_timer_sound, @@ -358,6 +364,7 @@ def __getstate__(self) -> tuple: self.disable_cmd, self.default_dual, sys.intern(self.sec_invert_var), + self.sec_spawn_fire, self.sec_enable_cmd, self.sec_disable_cmd, self.output_type, @@ -385,6 +392,7 @@ def __setstate__(self, state: tuple) -> None: self.disable_cmd, self.default_dual, self.sec_invert_var, + self.sec_spawn_fire, self.sec_enable_cmd, self.sec_disable_cmd, self.output_type, diff --git a/src/consts.py b/src/consts.py index 7693373a3..39eacfb0c 100644 --- a/src/consts.py +++ b/src/consts.py @@ -1,8 +1,11 @@ """Various constant values (Mainly texture names.)""" +from __future__ import annotations +from typing import cast, Any, TypeVar, Type, MutableMapping, Iterator from enum import Enum, EnumMeta from srctools import Side +T = TypeVar('T') __all__ = [ 'MaterialGroup', @@ -15,30 +18,53 @@ 'FixupVars', 'COUNTER_AND_ON', 'COUNTER_AND_OFF', 'COUNTER_OR_ON', 'COUNTER_OR_OFF', - 'SEL_ICON_SIZE', 'SEL_ICON_SIZE_LRG', + 'SEL_ICON_SIZE', 'SEL_ICON_SIZE_LRG', 'SEL_ICON_CROP_SHRINK', ] +class _MaterialGroupNS(MutableMapping[str, Any]): + """Wraps around the enum mapping, to lowercase the values.""" + def __init__(self, orig: MutableMapping[str, Any]) -> None: + self.mapping = orig + + def __setitem__(self, key: str, value: Any) -> None: + """Make string objects lowercase when set.""" + if isinstance(value, str): + value = value.casefold() + self.mapping[key] = value + + def __delitem__(self, value: str) -> None: + del self.mapping[value] + + def __getitem__(self, key: str) -> Any: + return self.mapping[key] + + def __len__(self) -> int: + return len(self.mapping) + + def __iter__(self) -> Iterator[Any]: + return iter(self.mapping) + + class MaterialGroupMeta(EnumMeta): """Metaclass for MaterialGroup, to implement some of its features.""" + _value2member_map_: dict[str, Any] # Enum defines. + @classmethod - def __prepare__(mcs, cls, bases): + def __prepare__(mcs, name: str, bases: tuple[type, ...], **kwargs: Any) -> MutableMapping[str, Any]: """Override Enum class-dict type. - + This makes string-values lowercase when set. """ - # The original class is private - grab it via prepare, and make - # a subclass right here. - namespace = super().__prepare__(cls, bases) + namespace = super().__prepare__(name, bases, **kwargs) + return _MaterialGroupNS(cast(MutableMapping[str, Any], namespace)) - class RepDict(type(namespace)): - def __setitem__(self, key, value): - if isinstance(value, str): - value = value.casefold() - super().__setitem__(key, value) + def __new__(mcs, cls: type, bases: tuple[type, ...], classdict: _MaterialGroupNS, **kwds: Any) -> EnumMeta: + """Unpack the dict type back to the original for EnumMeta. - namespace.__type__ = RepDict - return namespace + It accesses attributes, so it can't have our wrapper. + """ + return super().__new__(mcs, cls, bases, classdict.mapping, **kwds) def __contains__(cls, value) -> bool: """MaterialGroup can check if strings are equal to a member.""" @@ -47,13 +73,13 @@ def __contains__(cls, value) -> bool: elif isinstance(value, Side): return value.mat.casefold() in cls._value2member_map_ return super().__contains__(value) - - def __call__(cls, value, *args, **kwargs): - if args or kwargs: - return super().__call__(value, *args, **kwargs) - return cls.__new__(cls, value.casefold()) - __call__.__doc__ = EnumMeta.__call__.__doc__ + # Need to ignore types here, EnumMeta does not match type's signature. + def __call__(cls: Type[T], value: str, *args, **kwargs) -> T: # type: ignore + """Find the existing member with this name.""" + if args or kwargs: + return super().__call__(value, *args, **kwargs) # type: ignore + return cls.__new__(cls, value.casefold()) # type: ignore class MaterialGroup(str, Enum, metaclass=MaterialGroupMeta): @@ -174,6 +200,7 @@ class Special(MaterialGroup): class Goo(MaterialGroup): REFLECTIVE = "nature/toxicslime_a2_bridge_intro" CHEAP = "nature/toxicslime_puzzlemaker_cheap" + TIDELINE = "overlays/tideline01b" class Antlines(MaterialGroup): @@ -186,18 +213,18 @@ class Tools(MaterialGroup): NODRAW = 'tools/toolsnodraw' INVISIBLE = 'tools/toolsinvisible' TRIGGER = 'tools/toolstrigger' - + AREAPORTAL = 'tools/toolsareaportal' - SKIP = 'tools/toolsskip' + SKIP = 'tools/toolsskip' HINT = 'tools/toolshint' OCCLUDER = 'tools/toolsoccluder' - + CLIP = 'tools/toolsclip' BLOCK_LOS = 'tools/toolsblock_los' BLOCK_LIGHT = 'tools/toolsblocklight' BLOCK_BULLETS = 'tools/toolsblockbullets' PLAYER_CLIP = 'tools/toolsplayerclip' - + SKYBOX = 'tools/toolsskybox' BLACK = 'tools/toolsblack' @@ -231,3 +258,4 @@ class MusicChannel(Enum): SEL_ICON_SIZE = 96 # Size of the selector win icons SEL_ICON_SIZE_LRG = (256, 192) # Size of the larger icon shown in description. +SEL_ICON_CROP_SHRINK = (32, 0, 256 - 32, 192) # Bounds required to crop from lrg to small. diff --git a/src/editoritems.py b/src/editoritems.py index 1f2affe6c..167bb1366 100644 --- a/src/editoritems.py +++ b/src/editoritems.py @@ -1,19 +1,19 @@ """Parses the Puzzlemaker's item format.""" +from __future__ import annotations import sys from collections import defaultdict +from collections.abc import Iterable, Iterator, Mapping from enum import Enum, Flag -from typing import ( - Optional, Type, Callable, NamedTuple, - List, Dict, Tuple, Set, - Iterable, IO, Iterator, Mapping, -) +from typing import Type, Callable, ClassVar, Protocol, Any from pathlib import PurePosixPath as FSPath +import attr + from srctools import Vec, logger, conv_int, conv_bool, Property, Output from srctools.tokenizer import Tokenizer, Token from connections import Config as ConnConfig, InputType, OutNames -from editoritems_props import ItemProp, PROP_TYPES +from editoritems_props import ItemProp, UnknownProp, PROP_TYPES LOGGER = logger.get_logger(__name__) @@ -21,7 +21,16 @@ class ItemClass(Enum): """PeTI item classes.""" - # Value: (ID, instance count, models per subitem) + # Value: + # - The ID used in the configs. + # - The number of instances items of this type have, at maximum. + # - The number of models to provide for each subtype. + + def __init__(self, name: str, inst_count: int, models: int) -> None: + """Initialise attributes.""" + self.id = name + self.inst_count = inst_count + self.mdl_per_subtype = models # Default UNCLASSED = ('ItemBase', 1, 1) @@ -42,7 +51,7 @@ class ItemClass(Enum): FAITH_PLATE = 'ItemCatapult', 1, 1 CUBE_DROPPER = 'ItemCubeDropper', 1, 1 - GEL_DROPPER = PAINT_DROPPER = 'ItemPaintDropper', 1, 1 + GEL_DROPPER = PAINT_DROPPER = 'ItemPaintDropper', 1, 4 FAITH_TARGET = 'ItemCatapultTarget', 1, 1 # Input-less items @@ -60,7 +69,7 @@ class ItemClass(Enum): # Extent/handle pseudo-items HANDLE_FIZZLER = 'ItemBarrierHazardExtent', 0, 1 HANDLE_GLASS = 'ItemBarrierExtent', 0, 1 - HANDLE_PISTON_PLATFORM = 'ItemPistonPlatformExtent', 0, 1 + HANDLE_PISTON_PLATFORM = 'ItemPistonPlatformExtent', 0, 2 HANDLE_TRACK_PLATFORM = 'ItemRailPlatformExtent', 0, 1 # Entry/Exit corridors @@ -69,21 +78,6 @@ class ItemClass(Enum): DOOR_EXIT_SP = 'ItemExitDoor', 6, 2 DOOR_EXIT_COOP = 'ItemCoopExitDoor', 6, 2 - @property - def id(self) -> str: - """The ID used in the configs.""" - return self.value[0] - - @property - def inst_count(self) -> int: - """The number of intances items of this type have, at maximum.""" - return self.value[1] - - @property - def models_per_subtype(self) -> int: - """The number of models to provide for each subtype.""" - return self.value[2] - CLASS_BY_NAME = { itemclass.id.casefold(): itemclass @@ -150,11 +144,13 @@ class CollType(Flag): EVERYTHING = 0b1111111 GRATE = GRATING + NONE = NOTHING # If unset, everything but physics collides. DEFAULT = SOLID | GRATE | GLASS | BRIDGE | FIZZLER | PHYSICS | ANTLINES @classmethod def parse(cls, tok: Tokenizer) -> 'CollType': + """Parse the collision type value.""" coll = cls.NOTHING for part in tok.expect(Token.STRING).split(): try: @@ -227,7 +223,7 @@ class Anim(Enum): BAD_PLACE_HIDE = 'ANIM_ICON_HIDE' @classmethod - def parse_block(cls, anims: Dict['Anim', int], tok: Tokenizer) -> None: + def parse_block(cls, anims: dict[Anim, int], tok: Tokenizer) -> None: """Parse a block of animation definitions.""" for anim_name in tok.block('Animations'): try: @@ -251,14 +247,21 @@ class ConnTypes(Enum): FIZZ = 'CONNECTION_HAZARD' # Output from base. -class Connection(NamedTuple): +class _TextFile(Protocol): + """The functions we require in order to write text to a file.""" + def write(self, __text: str) -> Any: + """We simply need a write() method, and ignore the return value.""" + + +@attr.define +class Connection: """Activate/deactivate pair defined for connections.""" - act_name: Optional[str] - activate: str # Input/output used to activate. - deact_name: Optional[str] - deactivate: str # Input/output used to deactivate. + act_name: str | None + activate: str | None # Input/output used to activate. + deact_name: str | None + deactivate: str | None # Input/output used to deactivate. - def write(self, f: IO[str], conn_type: str) -> None: + def write(self, f: _TextFile, conn_type: str) -> None: """Produce the activate/deactivate keys.""" if self.activate is None and self.deactivate is None: return @@ -277,15 +280,18 @@ def write(self, f: IO[str], conn_type: str) -> None: f.write('\t\t\t\t}\n') -class InstCount(NamedTuple): +@attr.define +class InstCount: """Instances have several associated counts.""" - inst: FSPath # The actual filename. - ent_count: int - brush_count: int - face_count: int + # The actual filename. + inst: FSPath = attr.ib(converter=FSPath) + ent_count: int = 0 + brush_count: int = 0 + face_count: int = 0 -class Coord(NamedTuple): +@attr.frozen +class Coord: """Integer coordinates.""" x: int y: int @@ -295,6 +301,25 @@ def __str__(self) -> str: """The string form has no delimiters.""" return f'{self.x} {self.y} {self.z}' + def __iter__(self) -> Iterator[int]: + """Return each coordinate in order.""" + yield self.x + yield self.y + yield self.z + + @classmethod + def from_vec(cls, vec: Vec) -> 'Coord': + """Round a vector to grid coordinates.""" + x = round(vec.x) + y = round(vec.y) + z = round(vec.z) + try: + return _coord_cache[x, y, z] + except KeyError: + result = cls(x, y, z) + _coord_cache[x, y, z] = result + return result + @classmethod def parse(cls, value: str, error_func: Callable[..., BaseException]) -> 'Coord': """Parse from a string, using the function to raise errors.""" @@ -336,14 +361,16 @@ def bbox(self, other: 'Coord') -> Iterator['Coord']: yield result -class EmbedFace(NamedTuple): +@attr.define +class EmbedFace: """A face generated by the editor.""" center: Vec # Center point, Z always is 128. size: Vec # Size of the tile. type: FaceType # Surface material. -class Overlay(NamedTuple): +@attr.define +class Overlay: """An overlay placed by the editor on the ground.""" material: str # Material to show. center: Vec # Center point. @@ -352,7 +379,7 @@ class Overlay(NamedTuple): # Cache these coordinates, since most items are going to be near the origin. -_coord_cache: Dict[Tuple[int, int, int], Coord] = {} +_coord_cache: dict[tuple[int, int, int], Coord] = {} NORMALS = { Coord(0, 0, -1), @@ -362,22 +389,25 @@ class Overlay(NamedTuple): Coord(-1, 0, 0), Coord(+1, 0, 0), } -_coord_cache.update(zip(map(tuple, NORMALS), NORMALS)) +_coord_cache.update({ + (c.x, c.y, c.z): c + for c in NORMALS +}) # Cache the computed value shapes. -_coll_type_str: Dict[int, str] = { +_coll_type_str: dict[int, str] = { CollType.NOTHING.value: 'COLLIDE_NOTHING', CollType.EVERYTHING.value: 'COLLIDE_EVERYTHING', } -ITEM_CLASSES: Dict[str, ItemClass] = { +ITEM_CLASSES: dict[str, ItemClass] = { cls.id.casefold(): cls for cls in ItemClass } -FACE_TYPES: Dict[str, FaceType] = { +FACE_TYPES: dict[str, FaceType] = { face.value.casefold(): face for face in FaceType } -COLL_TYPES: Dict[str, CollType] = { +COLL_TYPES: dict[str, CollType] = { 'COLLIDE_' + coll.name: coll for coll in CollType } @@ -398,16 +428,34 @@ class Overlay(NamedTuple): Sound.CREATE: 'P2Editor.PlaceOther', Sound.DELETE: 'P2Editor.RemoveOther', } -_BLANK_INST = [ InstCount(FSPath(), 0, 0, 0) ] +_BLANK_INST = [InstCount(FSPath(), 0, 0, 0)] class ConnSide(Enum): - """Sides of an item, where antlines connect to.""" + """Sides of an item, where antlines connect to. + + The name is the side of the item it attaches, the vector is the direction the end points - so + they're reversed compared to each other. + """ LEFT = Coord(1, 0, 0) RIGHT = Coord(-1, 0, 0) UP = Coord(0, 1, 0) DOWN = Coord(0, -1, 0) + @classmethod + def from_yaw(cls, value: int) -> 'ConnSide': + """Return the the side pointing in this yaw direction.""" + value %= 360 + if value == 0: + return ConnSide.LEFT + elif value == 90: + return ConnSide.DOWN + elif value == 180: + return ConnSide.RIGHT + elif value == 270: + return ConnSide.UP + raise ValueError(f'Invalid yaw {value}!') + @classmethod def parse(cls, value: str, error_func: Callable[..., BaseException]) -> 'ConnSide': """Parse a connection side.""" @@ -430,35 +478,60 @@ def parse(cls, value: str, error_func: Callable[..., BaseException]) -> 'ConnSid elif y == -1: return ConnSide.DOWN elif y == 0: - if x == 1: - return ConnSide.LEFT - elif x == -1: + if x == -1: return ConnSide.RIGHT + elif x == 1: + return ConnSide.LEFT raise error_func('Unknown connection side ({}, {}, 0)', x, y) + @property + def x(self) -> int: + """Return the X coordinate.""" + return self.value.x -class AntlinePoint(NamedTuple): + @property + def y(self) -> int: + """Return the X coordinate.""" + return self.value.y + + @property + def yaw(self) -> int: + """Return the yaw direction.""" + if self is ConnSide.LEFT: + return 0 + if self is ConnSide.DOWN: + return 90 + if self is ConnSide.RIGHT: + return 180 + if self is ConnSide.UP: + return 270 + raise AssertionError(f'Unknown value {self!r}') + + +@attr.frozen +class AntlinePoint: """Locations antlines can connect to.""" pos: Coord sign_off: Coord priority: int - group: Optional[int] + group: int | None -class OccupiedVoxel(NamedTuple): +@attr.frozen +class OccupiedVoxel: """Represents the collision information for an item. If normal is not None, this is a side and not a cube. If subpos is not None, this is a 32x32 cube and not a full voxel. """ type: CollType - against: Optional[CollType] # TODO: Don't know what the default is. + against: CollType | None # TODO: Don't know what the default is. pos: Coord - subpos: Optional[Coord] - normal: Optional[Coord] + subpos: Coord | None = None + normal: Coord | None = None -def bounding_boxes(voxels: Iterable[Coord]) -> Iterator[Tuple[Coord, Coord]]: +def bounding_boxes(voxels: Iterable[Coord]) -> Iterator[tuple[Coord, Coord]]: """Decompose a bunch of points into a small list of bounding boxes enclosing them. This is used to determine a good set of Volume definitions to write out. @@ -471,41 +544,41 @@ def bounding_boxes(voxels: Iterable[Coord]) -> Iterator[Tuple[Coord, Coord]]: x1, y1, z1 = x2, y2, z2 = todo.pop() # X+: for x in range(x1 + 1, x1 + EXTENT): - if (x, y1, z1) in todo: + if Coord(x, y1, z1) in todo: x2 = x else: break # X-: for x in range(x1 - 1, x1 - EXTENT, -1): - if (x, y1, z1) in todo: + if Coord(x, y1, z1) in todo: x1 = x else: break # Y+: for y in range(y1 + 1, y1 + EXTENT): - if all((x, y, z1) in todo for x in range(x1, x2+1)): + if all(Coord(x, y, z1) in todo for x in range(x1, x2+1)): y2 = y else: break # Y-: for y in range(y1 - 1, y1 - EXTENT, -1): - if all((x, y, z1) in todo for x in range(x1, x2+1)): + if all(Coord(x, y, z1) in todo for x in range(x1, x2+1)): y1 = y else: break # Y+: for z in range(z1 + 1, z1 + EXTENT): - if all((x, y, z) in todo for x in range(x1, x2+1) for y in range(y1, y2+1)): + if all(Coord(x, y, z) in todo for x in range(x1, x2+1) for y in range(y1, y2+1)): z2 = z else: break # Y-: for z in range(z1 - 1, z1 - EXTENT, -1): - if all((x, y, z) in todo for x in range(x1, x2+1) for y in range(y1, y2+1)): + if all(Coord(x, y, z) in todo for x in range(x1, x2+1) for y in range(y1, y2+1)): z1 = z else: break @@ -517,25 +590,21 @@ def bounding_boxes(voxels: Iterable[Coord]) -> Iterator[Tuple[Coord, Coord]]: yield Coord(x1, y1, z1), Coord(x2, y2, z2) +@attr.define class Renderable: """Simpler definition used for the heart and error icons.""" - _types = {r.value.casefold(): r for r in RenderableType} - def __init__( - self, - typ: RenderableType, - model: str, - animations: Dict[Anim, int], - ): - self.type = typ - self.model = model - self.animations = animations + _types: ClassVar = {r.value.casefold(): r for r in RenderableType} + + type: RenderableType + model: str + animations: dict[Anim, int] @classmethod - def parse(cls, tok: Tokenizer) -> 'Renderable': + def parse(cls, tok: Tokenizer) -> Renderable: """Parse a renderable.""" - kind: Optional[RenderableType] = None + kind: RenderableType | None = None model = '' - anims = {} + anims: dict[Anim, int] = {} for key in tok.block("Renderable Item"): if key.casefold() == "type": @@ -555,46 +624,29 @@ def parse(cls, tok: Tokenizer) -> 'Renderable': return Renderable(kind, model, anims) +@attr.define class SubType: """Represents a single sub-item component of an overall item. Should not be constructed directly. """ # The name, shown on remove connection windows. - name: str + name: str = '' # The models this uses, in order. The editoritems format includes # a texture name for each of these, but it's never used. - models: List[FSPath] + models: list[FSPath] = attr.Factory(list) # For each sound type, the soundscript to use. - sounds: Dict[Sound, str] + sounds: dict[Sound, str] = attr.Factory(DEFAULT_SOUNDS.copy) # For each animation category, the sequence index. - anims: Dict[Anim, int] + anims: dict[Anim, int] = attr.Factory(dict) # The capitalised name to display in the bottom of the palette window. # If not on the palette, set to ''. - pal_name: str + pal_name: str = '' # X/Y position on the palette, or None if not on the palette - pal_pos: Optional[Tuple[int, int]] + pal_pos: tuple[int, int] | None = None # The path to the icon VTF, in 'models/props_map_editor'. - pal_icon: Optional[FSPath] - - def __init__( - self, - name: str, - models: List[FSPath], - sounds: Dict[Sound, str], - anims: Dict[Anim, int], - pal_name: str, - pal_pos: Optional[Tuple[int, int]], - pal_icon: Optional[FSPath], - ) -> None: - self.name = name - self.models = models - self.sounds = sounds - self.anims = anims - self.pal_name = pal_name - self.pal_pos = pal_pos - self.pal_icon = pal_icon + pal_icon: FSPath | None = None def copy(self) -> 'SubType': """Duplicate this subtype.""" @@ -610,7 +662,7 @@ def copy(self) -> 'SubType': __copy__ = copy - def __deepcopy__(self, memodict: Optional[dict] = None) -> 'SubType': + def __deepcopy__(self, memodict: dict | None = None) -> SubType: """Duplicate this subtype. We don't need to deep-copy the contents of the containers, @@ -640,7 +692,7 @@ def __getstate__(self) -> object: self.name, list(map(str, self.models)), # These are mostly the same, intern so it deduplicates. - [sys.intern(self.sounds.get(snd, None)) for snd in Sound], + [sys.intern(self.sounds.get(snd, '')) for snd in Sound], anim, self.pal_name, x, y, @@ -653,7 +705,7 @@ def __setstate__(self, state: tuple) -> None: self.sounds = { snd: sndscript for snd, sndscript in zip(Sound, snds) - if sndscript is not None + if sndscript } self.anims = { anim: ind @@ -666,9 +718,9 @@ def __setstate__(self, state: tuple) -> None: self.pal_pos = None @classmethod - def parse(cls, tok: Tokenizer) -> 'SubType': + def parse(cls, tok: Tokenizer) -> SubType: """Parse a subtype from editoritems.""" - subtype: SubType = cls('', [], DEFAULT_SOUNDS.copy(), {}, '', None, None) + subtype = SubType() for key in tok.block('Subtype'): folded_key = key.casefold() if folded_key == 'name': @@ -677,7 +729,7 @@ def parse(cls, tok: Tokenizer) -> 'SubType': # In the original file this is a block, but allow a name # since the texture is unused. token, tok_value = next(tok.skipping_newlines()) - model_name: Optional[FSPath] = None + model_name: FSPath | None = None if token is Token.STRING: model_name = FSPath(tok_value) elif token is Token.BRACE_OPEN: @@ -732,7 +784,7 @@ def parse(cls, tok: Tokenizer) -> 'SubType': raise tok.error('Unknown subtype option "{}"!', key) return subtype - def export(self, f: IO[str]) -> None: + def export(self, f: _TextFile) -> None: """Write the subtype to a file.""" f.write('\t\t"SubType"\n\t\t\t{\n') if self.name: @@ -768,84 +820,64 @@ def export(self, f: IO[str]) -> None: f.write('\t\t\t}\n') +@attr.define class Item: """A specific item.""" id: str # The item's unique ID. # The C++ class used to instantiate the item in the editor. - cls: ItemClass - subtype_prop: Optional[Type[ItemProp]] + cls: ItemClass = ItemClass.UNCLASSED + # Type if known, or string if unknown property. + subtype_prop: Type[ItemProp] | str | None = None + subtypes: list[SubType] = attr.Factory(list) # Each subtype in order. # Movement handle - handle: Handle - facing: DesiredFacing - invalid_surf: Set[Surface] - animations: Dict[Anim, int] # Anim name to sequence index. - - anchor_barriers: bool - anchor_goo: bool - occupies_voxel: bool - copiable: bool - deletable: bool - - subtypes: List[SubType] # Each subtype in order. - - def __init__( - self, - item_id: str, - cls: ItemClass, - subtype_prop: Optional[Type[ItemProp]] = None, - movement_handle: Handle = Handle.NONE, - desired_facing: DesiredFacing = DesiredFacing.NONE, - invalid_surf: Iterable[Surface] = (), - anchor_on_barriers: bool = False, - anchor_on_goo: bool = False, - occupies_voxel: bool = False - ) -> None: - self.animations = {} - self.id = item_id - self.cls = cls - self.subtype_prop = subtype_prop - self.subtypes = [] - self.properties: Dict[str, ItemProp] = {} - self.handle = movement_handle - self.facing = desired_facing - self.invalid_surf = set(invalid_surf) - self.anchor_barriers = anchor_on_barriers - self.anchor_goo = anchor_on_goo - self.occupies_voxel = occupies_voxel - self.copiable = True - self.deletable = True - self.pseduo_handle = False - # The default is 0 0 0, but this isn't useful since the rotation point - # is wrong. So just make it the useful default, users can override. - self.offset = Vec(64, 64, 64) - self.targetname = '' - - # The instances used by the editor, then custom slots used by - # conditions. For the latter we don't care about the counts. - self.instances: List[InstCount] = [] - self.cust_instances: Dict[str, FSPath] = {} - self.antline_points: Dict[ConnSide, List[AntlinePoint]] = { - side: [] for side in ConnSide - } - # The points this collides with. - self.occupy_voxels: Set[OccupiedVoxel] = set() - # The voxels this hollows out inside the floor. - self.embed_voxels: Set[Coord] = set() - # Brushes automatically created - self.embed_faces: List[EmbedFace] = [] - # Overlays automatically placed - self.overlays: List[Overlay] = [] - - # Connection types that don't represent item I/O, like for droppers - # or fizzlers. - self.conn_inputs: Dict[ConnTypes, Connection] = {} - self.conn_outputs: Dict[ConnTypes, Connection] = {} - - # The configuration for actual item I/O. - self.conn_config: Optional[ConnConfig] = None - # If we want to force this item to have inputs/outputs, - # like for linking together items. - self.force_input = self.force_output = False + handle: Handle = Handle.NONE + facing: DesiredFacing = DesiredFacing.NONE + invalid_surf: set[Surface] = attr.ib(factory=set, converter=set) + # Anim name to sequence index. + animations: dict[Anim, int] = attr.ib(factory=dict) + + anchor_barriers: bool = False + anchor_goo: bool = False + occupies_voxel: bool = False + pseudo_handle: bool = False + copiable: bool = True + deletable: bool = True + + targetname: str = '' + # The default is 0 0 0, but this isn't useful since the rotation point + # is wrong. So just make it the useful default, users can override. + offset: Vec = attr.Factory(lambda: Vec(64, 64, 64)) + + properties: dict[str, ItemProp] = attr.Factory(dict) + + # The instances used by the editor, then custom slots used by + # conditions. For the latter we don't care about the counts. + instances: list[InstCount] = attr.Factory(list) + cust_instances: dict[str, FSPath] = attr.Factory(dict) + + antline_points: dict[ConnSide, list[AntlinePoint]] = attr.Factory(lambda: { + side: [] for side in ConnSide + }) + # The points this collides with. + occupy_voxels: set[OccupiedVoxel] = attr.Factory(set) + # The voxels this hollows out inside the floor. + embed_voxels: set[Coord] = attr.Factory(set) + # Brushes automatically created + embed_faces: list[EmbedFace] = attr.Factory(list) + # Overlays automatically placed + overlays: list[Overlay] = attr.Factory(list) + + # Connection types that don't represent item I/O, like for droppers + # or fizzlers. + conn_inputs: dict[ConnTypes, Connection] = attr.Factory(dict) + conn_outputs: dict[ConnTypes, Connection] = attr.Factory(dict) + + # The configuration for actual item I/O. + conn_config: ConnConfig | None = None + # If we want to force this item to have inputs/outputs, + # like for linking together items. + force_input: bool = False + force_output: bool = False def has_prim_input(self) -> bool: """Check whether this item has a primary input.""" @@ -888,15 +920,15 @@ def set_inst(self, ind: int, inst: InstCount) -> None: def parse( cls, file: Iterable[str], - filename: Optional[str] = None, - ) -> Tuple[List['Item'], Dict[RenderableType, Renderable]]: + filename: str | None = None, + ) -> tuple[list[Item], dict[RenderableType, Renderable]]: """Parse an entire editoritems file. The "ItemData" {} wrapper may optionally be included. """ - known_ids: Set[str] = set() - items: List[Item] = [] - icons: Dict[RenderableType, Renderable] = {} + known_ids: set[str] = set() + items: list[Item] = [] + icons: dict[RenderableType, Renderable] = {} tok = Tokenizer(file, filename) # Check for the itemdata header. @@ -940,14 +972,14 @@ def parse( return items, icons @classmethod - def parse_one(cls, tok: Tokenizer) -> 'Item': + def parse_one(cls, tok: Tokenizer) -> Item: """Parse an item. This expects the "Item" token to have been read already. """ - connections = Property(None, []) + connections = Property('Connections', []) tok.expect(Token.BRACE_OPEN) - item: Item = cls('', ItemClass.UNCLASSED) + item = Item('') for token, tok_value in tok: if token is Token.BRACE_CLOSE: @@ -982,7 +1014,7 @@ def parse_one(cls, tok: Tokenizer) -> 'Item': elif tok_value == 'properties': item._parse_properties_block(tok) elif tok_value == 'exporting': - connections += item._parse_export_block(tok) + item._parse_export_block(tok, connections) elif tok_value in ('author', 'description', 'filter'): # These are BEE2.2 values, which are not used. tok.expect(Token.STRING) @@ -995,6 +1027,17 @@ def parse_one(cls, tok: Tokenizer) -> 'Item': if not item.id: raise tok.error('No item ID (Type) set!') + # If the user defined a subtype property, that prop's default value should not be changed. + if item.subtype_prop is not None: + if isinstance(item.subtype_prop, str): + prop_name = item.subtype_prop + else: + prop_name = item.subtype_prop.id + try: + item.properties[prop_name.casefold()].allow_user_default = False + except KeyError: + LOGGER.warning('Subtype property of "{}" set, but property not present!', prop_name) + # Parse the connections info, if it exists. if connections or item.conn_inputs or item.conn_outputs: item.conn_config = ConnConfig.parse(item.id, connections) @@ -1026,35 +1069,47 @@ def _parse_editor_block(self, tok: Tokenizer) -> None: try: self.handle = Handle(handle_str.upper()) except ValueError: - raise tok.error('Unknown handle type {}', handle_str) + LOGGER.warning( + 'Unknown movement handle "{}" in ({}:{})', + handle_str, tok.filename, tok.line_num, + ) elif folded_key == 'invalidsurface': for word in tok.expect(Token.STRING).split(): try: self.invalid_surf.add(Surface[word.upper()]) except KeyError: - raise tok.error('Unknown surface type {}', word) + LOGGER.warning( + 'Unknown invalid surface "{}" in ({}:{})', + word, tok.filename, tok.line_num, + ) elif folded_key == 'subtypeproperty': subtype_prop = tok.expect(Token.STRING) try: self.subtype_prop = PROP_TYPES[subtype_prop.casefold()] except ValueError: - raise tok.error('Unknown property {}', subtype_prop) + LOGGER.warning('Unknown subtype property "{}"!', subtype_prop) + self.subtype_prop = subtype_prop elif folded_key == 'desiredfacing': desired_facing = tok.expect(Token.STRING) try: self.facing = DesiredFacing(desired_facing.upper()) except ValueError: - raise tok.error('Unknown desired facing {}', desired_facing) + LOGGER.warning( + 'Unknown desired facing {}, assuming ANYTHING in ({}:{})', + desired_facing, tok.filename, tok.line_num, + ) + self.facing = DesiredFacing.NONE elif folded_key == 'rendercolor': # Rendercolor is on catapult targets, and is useless. tok.expect(Token.STRING) else: + # These flag keyvalues can be handled the same way. try: - attr = self._BOOL_ATTRS[folded_key] + conf_attr = self._BOOL_ATTRS[folded_key] except KeyError: raise tok.error('Unknown editor option {}', key) else: - setattr(self, attr, conv_bool(tok.expect(Token.STRING))) + setattr(self, conf_attr, conv_bool(tok.expect(Token.STRING))) def _parse_properties_block(self, tok: Tokenizer) -> None: """Parse the properties block of the item definitions.""" @@ -1062,7 +1117,8 @@ def _parse_properties_block(self, tok: Tokenizer) -> None: try: prop_type = PROP_TYPES[prop_str.casefold()] except KeyError: - raise tok.error(f'Unknown property "{prop_str}"!') + LOGGER.warning('Unknown property "{}"!', prop_str) + prop_type = UnknownProp default = '' index = 0 @@ -1075,18 +1131,31 @@ def _parse_properties_block(self, tok: Tokenizer) -> None: index = conv_int(tok.expect(Token.STRING)) elif prop_value == 'bee2_ignore': user_default = conv_bool(tok.expect(Token.STRING), user_default) + if prop_type is UnknownProp: + LOGGER.warning('Unknown properties cannot have defaults set!') else: raise tok.error('Unknown property option "{}"!', prop_value) - try: - self.properties[prop_type.id.casefold()] = prop_type(default, index, user_default) - except ValueError: - raise tok.error('Default value {} is not valid for {} properties!', default, prop_type.id) + if prop_type is UnknownProp: + self.properties[prop_str.casefold()] = UnknownProp( + prop_str, default, index, + ) + else: + try: + self.properties[prop_type.id.casefold()] = prop_type( + default, index, user_default, + ) + except ValueError: + raise tok.error( + 'Default value {} is not valid for {} properties!', + default, prop_type.id, + ) + + def _parse_export_block(self, tok: Tokenizer, connections: Property) -> None: + """Parse the export block of the item definitions. This returns the parsed connections info. - def _parse_export_block(self, tok: Tokenizer) -> Property: - """Parse the export block of the item definitions. This returns the parsed connections info.""" - # Accumulate here, since we want to parse the input/output block - # together. - connection = Property(None, []) + Since the standard input/output blocks must be parsed in one group, we collect those in the + passed property. + """ for key in tok.block('Exporting'): folded_key = key.casefold() @@ -1111,17 +1180,16 @@ def _parse_export_block(self, tok: Tokenizer) -> Property: elif folded_key == 'overlay': self._parse_overlay(tok) elif folded_key == 'inputs': - self._parse_connections(tok, connection, self.conn_inputs) + self._parse_connections(tok, connections, self.conn_inputs) elif folded_key == 'outputs': - self._parse_connections(tok, connection, self.conn_outputs) + self._parse_connections(tok, connections, self.conn_outputs) else: raise tok.error('Unknown export option {}!', key) - return connection def _parse_instance_block(self, tok: Tokenizer, inst_name: str) -> None: """Parse a section in the instances block.""" - inst_ind: Optional[int] - inst_file: Optional[str] + inst_ind: int | None + inst_file: str | None try: inst_ind = int(inst_name) except ValueError: @@ -1152,10 +1220,9 @@ def _parse_instance_block(self, tok: Tokenizer, inst_name: str) -> None: raise tok.error('Unknown instance option {}', block_key) if inst_file is None: raise tok.error('No instance filename provided!') - inst = InstCount(FSPath(inst_file), ent_count, brush_count, - side_count) + inst = InstCount(inst_file, ent_count, brush_count, side_count) elif block_tok is Token.STRING: - inst = InstCount(FSPath(inst_file), 0, 0, 0) + inst = InstCount(inst_file) else: raise tok.error(block_tok) if inst_ind is not None: @@ -1167,7 +1234,7 @@ def _parse_connections( self, tok: Tokenizer, prop_block: Property, - target: Dict[ConnTypes, Connection], + target: dict[ConnTypes, Connection], ) -> None: """Parse either an inputs or outputs block. @@ -1180,7 +1247,7 @@ def _parse_connections( try: conn_type = ConnTypes(conn_name.upper()) except ValueError: - # Our custom BEEmod options. + # Our custom BEEMOD options. if conn_name.casefold() in ('bee', 'bee2'): for key in tok.block(conn_name): value = tok.expect(Token.STRING, skip_newline=False) @@ -1196,10 +1263,10 @@ def _parse_connections( else: raise tok.error('Unknown connection type "{}"!', conn_name) - act_name: Optional[str] = None - activate: Optional[str] = None - deact_name: Optional[str] = None - deactivate: Optional[str] = None + act_name: str | None = None + activate: str | None = None + deact_name: str | None = None + deactivate: str | None = None for conn_key in tok.block(conn_name): if conn_key.casefold() == 'activate': value = tok.expect(Token.STRING, skip_newline=False) @@ -1294,10 +1361,10 @@ def _parse_connection_points(self, tok: Tokenizer) -> None: for point_key in tok.block('ConnectionPoints'): if point_key.casefold() != 'point': raise tok.error('Unknown connection point "{}"!', point_key) - direction: Optional[ConnSide] = None - pos: Optional[Coord] = None - sign_pos: Optional[Coord] = None - group_id: Optional[int] = None + direction: ConnSide | None = None + pos: Coord | None = None + sign_pos: Coord | None = None + group_id: int | None = None priority = 0 for conn_key in tok.block('Point'): folded_key = conn_key.casefold() @@ -1325,16 +1392,16 @@ def _parse_occupied_voxels(self, tok: Tokenizer) -> None: """Parse occupied voxel definitions. We add on the volume variant for convienience.""" for occu_key in tok.block('OccupiedVoxels'): collide_type = CollType.DEFAULT - collide_against: Optional[CollType] = None + collide_against: CollType | None = None pos1 = Coord(0, 0, 0) - pos2: Optional[Coord] = None - normal: Optional[Coord] = None + pos2: Coord | None = None + normal: Coord | None = None # If no directions are specified, this is a full voxel. - added_parts: Set[Tuple[Optional[Coord], Optional[Coord]]] = set() + added_parts: set[tuple[Coord | None, Coord | None]] = set() # Extension, specify pairs of subpos points to bounding box include # all of them. - subpos_pairs: List[Coord] = [] + subpos_pairs: list[Coord] = [] occu_type = occu_key.casefold() if occu_type not in ('voxel', 'surfacevolume', 'volume'): @@ -1357,8 +1424,8 @@ def _parse_occupied_voxels(self, tok: Tokenizer) -> None: elif folded_key in ('subpos', 'subpos1', 'subpos2'): subpos_pairs.append(Coord.parse(tok.expect(Token.STRING), tok.error)) elif folded_key == 'surface': - sub_normal: Optional[Coord] = None - sub_pos: Optional[Coord] = None + sub_normal: Coord | None = None + sub_pos: Coord | None = None for surf_key in tok.block('Surface'): folded_key = surf_key.casefold() if folded_key == 'pos': @@ -1379,6 +1446,7 @@ def _parse_occupied_voxels(self, tok: Tokenizer) -> None: if not added_parts: # Default to a single voxel. added_parts.add((None, None)) + volume: Iterable[Coord] if pos2 is None: volume = [pos1] else: @@ -1397,8 +1465,8 @@ def _parse_embedded_voxels(self, tok: Tokenizer) -> None: for embed_key in tok.block('EmbeddedVoxels'): folded_key = embed_key.casefold() if folded_key == 'volume': - pos_1: Optional[Coord] = None - pos_2: Optional[Coord] = None + pos_1: Coord | None = None + pos_2: Coord | None = None for pos_key in tok.block('EmbeddedVolume'): if pos_key.casefold() == 'pos1': pos_1 = Coord.parse(tok.expect(Token.STRING), tok.error) @@ -1426,8 +1494,8 @@ def _parse_embed_faces(self, tok: Tokenizer) -> None: for solid_key in tok.block('EmbedFace'): if solid_key.casefold() != 'solid': raise tok.error('Unknown Embed Face type "{}"!', solid_key) - center: Optional[Vec] = None - size: Optional[Vec] = None + center: Vec | None = None + size: Vec | None = None grid = FaceType.NORMAL for opt_key in tok.block('Solid'): folded_key = opt_key.casefold() @@ -1451,8 +1519,8 @@ def _parse_embed_faces(self, tok: Tokenizer) -> None: def _parse_overlay(self, tok: Tokenizer) -> None: """Parse overlay definitions, which place overlays.""" - center: Optional[Vec] = None - size: Optional[Vec] = None + center: Vec | None = None + size: Vec | None = None material = '' rotation = 0 for opt_key in tok.block('Overlay'): @@ -1474,7 +1542,7 @@ def _parse_overlay(self, tok: Tokenizer) -> None: self.overlays.append(Overlay(material, center, size, rotation)) @classmethod - def export(cls, f: IO[str], items: Iterable['Item'], renderables: Mapping[RenderableType, Renderable]) -> None: + def export(cls, f: _TextFile, items: Iterable[Item], renderables: Mapping[RenderableType, Renderable]) -> None: """Write a full editoritems file out.""" f.write('"ItemData"\n{\n') for item in items: @@ -1492,7 +1560,7 @@ def export(cls, f: IO[str], items: Iterable['Item'], renderables: Mapping[Render f.write('\t}\n') f.write('}\n') - def export_one(self, f: IO[str]) -> None: + def export_one(self, f: _TextFile) -> None: """Write a single item out to a file.""" f.write('"Item"\n\t{\n') if self.cls is not ItemClass.UNCLASSED: @@ -1502,7 +1570,10 @@ def export_one(self, f: IO[str]) -> None: f.write(f'\t"Type" "{self.id}"\n') f.write('\t"Editor"\n\t\t{\n') if self.subtype_prop is not None: - f.write(f'\t\t"SubtypeProperty" "{self.subtype_prop.id}"\n') + if isinstance(self.subtype_prop, str): + f.write(f'\t\t"SubtypeProperty" "{self.subtype_prop}"\n') + else: + f.write(f'\t\t"SubtypeProperty" "{self.subtype_prop.id}"\n') for subtype in self.subtypes: subtype.export(f) f.write(f'\t\t"MovementHandle" "{self.handle.value}"\n') @@ -1523,7 +1594,7 @@ def export_one(self, f: IO[str]) -> None: f.write(f'\t\t"Copyable" "0"\n') if not self.deletable: f.write(f'\t\t"Deletable" "0"\n') - if self.pseduo_handle: + if self.pseudo_handle: f.write(f'\t\t"PseudoHandle" "1"\n') f.write('\t\t}\n') @@ -1654,11 +1725,11 @@ def export_one(self, f: IO[str]) -> None: f.write('\t\t}\n') f.write('\t}\n') - def _export_occupied_voxels(self, f: IO[str]) -> None: + def _export_occupied_voxels(self, f: _TextFile) -> None: """Write occupied voxels to a file.""" - voxel_groups: Dict[Tuple[Coord, CollType, CollType], List[OccupiedVoxel]] = defaultdict(list) + voxel_groups: dict[tuple[Coord, CollType, CollType | None], list[OccupiedVoxel]] = defaultdict(list) voxel: OccupiedVoxel - voxels: List[OccupiedVoxel] + voxels: list[OccupiedVoxel] for voxel in self.occupy_voxels: voxel_groups[voxel.pos, voxel.type, voxel.against].append(voxel) @@ -1710,7 +1781,7 @@ def __getstate__(self) -> tuple: self.occupies_voxel, self.copiable, self.deletable, - self.pseduo_handle, + self.pseudo_handle, self.offset, self.targetname, self.instances, @@ -1728,6 +1799,8 @@ def __getstate__(self) -> tuple: ) def __setstate__(self, state: tuple) -> None: + props: list[ItemProp] + antline_points: list[list[AntlinePoint]] ( self.id, self.cls, @@ -1743,7 +1816,7 @@ def __setstate__(self, state: tuple) -> None: self.occupies_voxel, self.copiable, self.deletable, - self.pseduo_handle, + self.pseudo_handle, self.offset, self.targetname, self.instances, @@ -1759,11 +1832,40 @@ def __setstate__(self, state: tuple) -> None: self.force_input, self.force_output, ) = state - props: List[ItemProp] - antline_points: List[List[AntlinePoint]] self.properties = { - prop.id: prop + prop.id.casefold(): prop for prop in props } self.antline_points = dict(zip(ConnSide, antline_points)) + + def validate(self) -> None: + """Look through the item and check for potential mistakes in configuration.""" + for i, subtype in enumerate(self.subtypes, 1): + if len(subtype.models) != self.cls.mdl_per_subtype: + # Suppress, this is a Reflection Gel hack. + if len(subtype.models) == 5 and self.cls is ItemClass.PAINT_DROPPER: + continue + + LOGGER.warning( + '{} items expect {} models, but subtype {} has {} models!', + self.cls.id, self.cls.mdl_per_subtype, + i, len(subtype.models), + ) + if 'buttontype' in self.properties and self.cls is not ItemClass.FLOOR_BUTTON: + LOGGER.warning( + 'The ButtonType property does nothing if the ' + 'item class is not ItemButtonFloor, only instance 0 is shown.' + ) + if self.has_prim_input() and 'connectioncount' not in self.properties: + LOGGER.warning( + 'Items with inputs must have ConnectionCount to work!' + ) + # Track platform is a special case, it has hardcoded connections for + # each track segment. + if ( + (self.has_prim_input() or self.has_sec_input() or self.has_output()) + and not self.antline_points + and self.cls is not ItemClass.TRACK_PLATFORM + ): + LOGGER.warning('Items with inputs or outputs need ConnectionPoints definition!') diff --git a/src/editoritems_props.py b/src/editoritems_props.py index 6aac87020..4df80b9a5 100644 --- a/src/editoritems_props.py +++ b/src/editoritems_props.py @@ -1,8 +1,8 @@ """The different properties defineable for items.""" -from typing import Type, TypeVar, Generic, ClassVar, Sequence, Dict, Tuple +from __future__ import annotations +from typing import Type, TypeVar, Generic, ClassVar, Sequence, cast from abc import abstractmethod from enum import Enum -from srctools import Property as KeyValues # Prevent confusion from srctools import conv_bool, conv_int, conv_float, bool_as_int, Angle @@ -28,16 +28,16 @@ def __repr__(self) -> str: """Generic repr() for properties.""" return f'{type(self).__qualname__}({self.default!r})' - def __eq__(self, other): + def __eq__(self, other: object) -> bool: """Subclasses do not compare equal.""" if type(self) is type(other): - return self.default == other.default + return self.default == cast(ItemProp, other).default return NotImplemented - def __ne__(self, other): + def __ne__(self, other: object) -> bool: """Subclasses do not compare equal.""" if type(self) is type(other): - return self.default != other.default + return self.default != cast(ItemProp, other).default return NotImplemented def __hash__(self) -> int: @@ -70,7 +70,56 @@ def _export_value(value: ValueT) -> str: # ID -> class -PROP_TYPES: Dict[str, Type[ItemProp]] = {} +PROP_TYPES: dict[str, Type[ItemProp]] = {} + + +class UnknownProp(ItemProp[str]): + """Placeholder for unknown properties.""" + def __init__( + self, + name: str, + default: str, + index: int, + ) -> None: + self.name = name + super().__init__(default, index, user_default=False) + + @property + def id(self) -> str: + """Override to specify the custom name.""" + return self.name + + @property + def trans_name(self) -> str: + """Use the raw name as the display name.""" + return self.name + + def __repr__(self) -> str: + """Generic repr() for properties.""" + return f'UnknownProp({self.name!r}, {self.default!r})' + + def __eq__(self, other: object) -> bool: + """Compare the name also.""" + if isinstance(other, UnknownProp): + return self.name == other.name and self.default == other.default + return NotImplemented + + def __ne__(self, other: object) -> bool: + """Compare the name also.""" + if isinstance(other, UnknownProp): + return self.name != other.name or self.default != other.default + return NotImplemented + + def __getstate__(self): + """All the properties have the same attributes.""" + return (self.name, self.default, self.index, self.allow_user_default) + + def __setstate__(self, state): + (self.name, self.default, self.index, self.allow_user_default) = state + + @staticmethod + def _parse_value(value: str) -> str: + return value class _BoolProp(ItemProp[bool]): @@ -109,7 +158,7 @@ def _parse_value(cls, value: str) -> ValueT: @staticmethod def _export_value(typ: ValueT) -> str: - return str(typ.value) + return str(typ.value) # type: ignore class _InternalStrProp(ItemProp[str]): @@ -646,8 +695,9 @@ class GlassTypeProp(_EnumProp[GlassTypes]): PROP_TYPES.update({ # If no ID, it's an internal implementation # class. - prop_type.id.casefold(): prop_type + prop_type.id.casefold(): prop_type # type: ignore for prop_type in globals().values() if isinstance(prop_type, type) - and hasattr(prop_type, 'id') + and hasattr(prop_type, 'id') + and prop_type is not UnknownProp }) diff --git a/src/editoritems_vmf.py b/src/editoritems_vmf.py new file mode 100644 index 000000000..4f3ccc9ac --- /dev/null +++ b/src/editoritems_vmf.py @@ -0,0 +1,293 @@ +"""Use pseudo-entities to make creating editoritems data more easily.""" +from __future__ import annotations + +from typing import Callable, Iterator, Dict +from srctools import Matrix, Angle, Vec, logger, conv_int +from srctools.vmf import VMF, Entity, ValidKVs + +from editoritems import Item, ConnSide, CollType, AntlinePoint, Coord, OccupiedVoxel, bounding_boxes + + +LOGGER = logger.get_logger(__name__) +LOAD_FUNCS: dict[str, Callable[[Item, Entity], None]] = {} +SAVE_FUNCS: list[Callable[[Item, VMF], None]] = [] + + +def load(item: Item, vmf: VMF) -> None: + """Search the map for important entities, and apply it to the item.""" + with logger.context(item.id): + for ent in vmf.entities: + classname = ent['classname'].casefold() + if not classname.startswith('bee2_editor_') or ent.hidden: + continue + try: + func = LOAD_FUNCS[classname] + except KeyError: + LOGGER.warning('Unknown item configuration entity "{}"!', classname) + else: + func(item, ent) + + +def save(item: Item) -> VMF: + """Export out relevant item options into a VMF.""" + vmf = VMF() + with logger.context(item.id): + for func in SAVE_FUNCS: + func(item, vmf) + return vmf + + +SKIN_TO_CONN_OFFSETS = { + # Skin -> antline offset. + '1': Vec(-0.5, +0.5), + '2': Vec(-0.5, -0.5), + '3': Vec(+0.5, +0.5), + '4': Vec(+0.5, -0.5), +} +# Opposite transform. +CONN_OFFSET_TO_SKIN = { + (2 * vec).as_tuple(): skin + for skin, vec in SKIN_TO_CONN_OFFSETS.items() +} + + +def parse_colltype(value: str) -> CollType: + """Parse a collide type specification from the VMF.""" + val = CollType.NOTHING + for word in value.split(): + word = word.upper() + if word.startswith('COLLIDE_'): + word = word[8:] + try: + val |= CollType[word] + except KeyError: + LOGGER.warning('Unknown collide type "{}"', word) + return val + + +def load_connectionpoint(item: Item, ent: Entity) -> None: + """Allow more conveniently defining connectionpoints.""" + origin = Vec.from_str(ent['origin']) + angles = Angle.from_str(ent['angles']) + if round(angles.pitch) != 0.0 or round(angles.roll) != 0.0: + LOGGER.warning( + "Connection Point at {} is not flat on the floor, PeTI doesn't allow this.", + origin, + ) + return + try: + side = ConnSide.from_yaw(round(angles.yaw)) + except ValueError: + LOGGER.warning( + "Connection Point at {} must point in a cardinal direction, not {}!", + origin, angles, + ) + return + + orient = Matrix.from_yaw(round(angles.yaw)) + + center = (origin - (-56, 56, 0)) / 16 + center.z = 0 + center.y = -center.y + try: + offset = SKIN_TO_CONN_OFFSETS[ent['skin']] @ orient + except KeyError: + LOGGER.warning('Connection Point at {} has invalid skin "{}"!', origin) + return + ant_pos = Coord(round(center.x + offset.x), round(center.y - offset.y), 0) + sign_pos = Coord(round(center.x - offset.x), round(center.y + offset.y), 0) + + group_str = ent['group_id'] + + item.antline_points[side].append(AntlinePoint( + ant_pos, + sign_pos, + conv_int(ent['priority']), + int(group_str) if group_str.strip() else None + )) + + +def save_connectionpoint(item: Item, vmf: VMF) -> None: + """Write connectionpoints to a VMF.""" + for side, points in item.antline_points.items(): + yaw = side.yaw + inv_orient = Matrix.from_yaw(-yaw) + for point in points: + ant_pos = Vec(point.pos.x, -point.pos.y, -64) + sign_pos = Vec(point.sign_off.x, -point.sign_off.y, -64) + + offset = (ant_pos - sign_pos) @ inv_orient + try: + skin = CONN_OFFSET_TO_SKIN[offset.as_tuple()] + except KeyError: + LOGGER.warning('Pos=({}), Sign=({}) -> ({}) is not a valid offset for signs!', point.pos, point.sign_off, offset) + continue + pos: Vec = round((ant_pos + sign_pos) / 2.0 * 16.0, 0) + + vmf.create_ent( + 'bee2_editor_connectionpoint', + origin=Vec(pos.x - 56, pos.y + 56, -64), + angles=f'0 {yaw} 0', + skin=skin, + priority=point.priority, + group_id='' if point.group is None else point.group, + ) + + +def load_embeddedvoxel(item: Item, ent: Entity) -> None: + """Parse embed definitions contained in the VMF.""" + bbox_min, bbox_max = ent.get_bbox() + bbox_min = round(bbox_min, 0) + bbox_max = round(bbox_max, 0) + + if bbox_min % 128 != (64.0, 64.0, 64.0) or bbox_max % 128 != (64.0, 64.0, 64.0): + LOGGER.warning( + 'Embedded voxel definition ({}) - ({}) is not aligned to grid!', + bbox_min, bbox_max, + ) + return + + item.embed_voxels.update(map(Coord.from_vec, Vec.iter_grid( + (bbox_min + (64, 64, 64)) / 128, + (bbox_max - (64, 64, 64)) / 128, + ))) + + +def save_embeddedvoxel(item: Item, vmf: VMF) -> None: + """Save embedded voxel volumes.""" + for bbox_min, bbox_max in bounding_boxes(item.embed_voxels): + vmf.create_ent('bee2_editor_embeddedvoxel').solids.append(vmf.make_prism( + Vec(bbox_min) * 128 + (-64.0, -64.0, -192.0), + Vec(bbox_max) * 128 + (+64.0, +64.0, -64.0), + # Entirely ignored, but makes it easier to distinguish. + 'tools/toolshint', + ).solid) + + +def load_occupiedvoxel(item: Item, ent: Entity) -> None: + """Parse voxel collisions contained in the VMF.""" + bbox_min, bbox_max = ent.get_bbox() + bbox_min = round(bbox_min, 0) + bbox_max = round(bbox_max, 0) + + coll_type = parse_colltype(ent['coll_type']) + if ent['coll_against']: + coll_against = parse_colltype(ent['coll_against']) + else: + coll_against = None + + if bbox_min % 128 == (64.0, 64.0, 64.0) and bbox_max % 128 == (64.0, 64.0, 64.0): + # Full voxels. + for voxel in Vec.iter_grid( + (bbox_min + (64, 64, 64)) / 128, + (bbox_max - (64, 64, 64)) / 128, + ): + item.occupy_voxels.add(OccupiedVoxel( + coll_type, coll_against, + Coord.from_vec(voxel), + )) + return + elif bbox_min % 32 == (0.0, 0.0, 0.0) and bbox_max % 32 == (0.0, 0.0, 0.0): + # Subvoxel sections. + for subvoxel in Vec.iter_grid( + bbox_min / 32, + (bbox_max - (32.0, 32.0, 32.0)) / 32, + ): + item.occupy_voxels.add(OccupiedVoxel( + coll_type, coll_against, + Coord.from_vec((subvoxel + (2, 2, 2)) // 4), + Coord.from_vec((subvoxel - (2, 2, 2)) % 4), + )) + return + # else, is this a surface definition? + size = round(bbox_max - bbox_min, 0) + for axis in ['x', 'y', 'z']: + if size[axis] < 8: + u, v = Vec.INV_AXIS[axis] + # Figure out if we're aligned to the min or max side of the voxel. + # Compute the normal, then flatten to zero thick. + if bbox_min[axis] % 32 == 0: + norm = +1 + plane_dist = bbox_max[axis] = bbox_min[axis] + elif bbox_max[axis] % 32 == 0: + norm = -1 + plane_dist = bbox_min[axis] = bbox_max[axis] + else: + # Both faces aren't aligned to the grid, skip to error. + break + + if bbox_min[u] % 128 == bbox_min[v] % 128 == bbox_max[v] % 128 == bbox_max[v] % 128 == 64.0: + # Full voxel surface definitions. + for voxel in Vec.iter_grid( + Vec.with_axes(u, bbox_min[u] + 64, v, bbox_min[v] + 64, axis, plane_dist + 64 * norm) / 128, + Vec.with_axes(u, bbox_max[u] - 64, v, bbox_max[v] - 64, axis, plane_dist + 64 * norm) / 128, + ): + item.occupy_voxels.add(OccupiedVoxel( + coll_type, coll_against, + Coord.from_vec(voxel), + normal=Coord.from_vec(Vec.with_axes(axis, norm)), + )) + return + elif bbox_min[u] % 32 == bbox_min[v] % 32 == bbox_max[v] % 32 == bbox_max[v] % 32 == 0.0: + # Subvoxel surface definitions. + return + else: + # Not aligned to grid, skip to error. + break + + LOGGER.warning( + 'Unknown occupied voxel definition: ({}) - ({}), type="{}", against="{}"', + bbox_min, bbox_max, ent['coll_type'], ent['coll_against'], + ) + + +def save_occupiedvoxel(item: Item, vmf: VMF) -> None: + """Save occupied voxel volumes.""" + for voxel in item.occupy_voxels: + pos = Vec(voxel.pos) * 128 + + if voxel.subpos is not None: + pos += Vec(voxel.subpos) * 32 - (48, 48, 48) + p1 = pos - (16.0, 16.0, 16.0) + p2 = pos + (16.0, 16.0, 16.0) + norm_dist = 32.0 - 4.0 + else: + p1 = pos - (64.0, 64.0, 64.0) + p2 = pos + (64.0, 64.0, 64.0) + norm_dist = 128.0 - 4.0 + + if voxel.normal is not None: + for axis in ['x', 'y', 'z']: + val = getattr(voxel.normal, axis) + if val == +1: + p2[axis] -= norm_dist + elif val == -1: + p1[axis] += norm_dist + + if voxel.against is not None: + against = str(voxel.against).replace('COLLIDE_', '') + else: + against = '' + + vmf.create_ent( + 'bee2_editor_occupiedvoxel', + coll_type=str(voxel.type).replace('COLLIDE_', ''), + coll_against=against, + ).solids.append(vmf.make_prism( + p1, p2, + # Use clip for voxels, invisible for normals. + # Entirely ignored, but makes it easier to use. + 'tools/toolsclip' if voxel.normal is None else 'tools/toolsinvisible', + ).solid) + + +LOAD_FUNCS.update({ + 'bee2_editor' + name[4:]: func + for name, func in globals().items() + if name.startswith('load_') +}) +SAVE_FUNCS.extend([ + func + for name, func in globals().items() + if name.startswith('save_') +]) diff --git a/src/loadScreen.py b/src/loadScreen.py index f585e7f52..16c38a9cf 100644 --- a/src/loadScreen.py +++ b/src/loadScreen.py @@ -8,11 +8,12 @@ from types import TracebackType from tkinter import commondialog from weakref import WeakSet -from abc import abstractmethod import contextlib import multiprocessing +import time from app import logWindow +from localisation import gettext from BEE2_config import GEN_OPTS import utils import srctools.logger @@ -79,7 +80,7 @@ def suppress_screens() -> Any: # Messageboxes, file dialogs and colorchooser all inherit from Dialog, # so patching .show() will fix them all. # contextlib managers can also be used as decorators. -commondialog.Dialog.show = suppress_screens()(commondialog.Dialog.show) +commondialog.Dialog.show = suppress_screens()(commondialog.Dialog.show) # type: ignore class LoadScreen: @@ -97,6 +98,7 @@ def __init__( is_splash: bool=False, ): self.active = False + self._time = 0.0 self.stage_ids = {st_id for st_id, title in stages} # active determines whether the screen is on, and if False stops most # functions from doing anything @@ -155,21 +157,28 @@ def set_length(self, stage: str, num: int) -> None: raise KeyError(f'"{stage}" not valid for {self.stage_ids}!') self._send_msg('set_length', stage, num) - def step(self, stage: str) -> None: + def step(self, stage: str, disp_name: str='') -> None: """Increment the specified stage.""" if stage not in self.stage_ids: raise KeyError(f'"{stage}" not valid for {self.stage_ids}!') + cur = time.perf_counter() + diff = cur - self._time + if diff > 0.1: + LOGGER.debug('{}: "{}" = {:.3}s', stage, disp_name, diff) + self._time = cur self._send_msg('step', stage) def skip_stage(self, stage: str) -> None: """Skip over this stage of the loading process.""" if stage not in self.stage_ids: raise KeyError(f'"{stage}" not valid for {self.stage_ids}!') + self._time = time.perf_counter() self._send_msg('skip_stage', stage) def show(self) -> None: """Display the loading screen.""" self.active = True + self._time = time.perf_counter() self._send_msg('show') def reset(self) -> None: @@ -194,6 +203,13 @@ def unsuppress(self) -> None: self._send_msg('show') +def shutdown() -> None: + """Instruct the daemon process to shutdown.""" + try: + _PIPE_MAIN_SEND.send(('quit_daemon', None, None)) + except BrokenPipeError: # Already quit, don't care. + pass + # Initialise the daemon. # noinspection PyProtectedMember BG_PROC = multiprocessing.Process( @@ -205,17 +221,17 @@ def unsuppress(self) -> None: logWindow.PIPE_DAEMON_REC, # Pass translation strings. { - 'skip': _('Skipped!'), - 'version': _('Version: ') + utils.BEE_VERSION, - 'cancel': _('Cancel'), - 'clear': _('Clear'), - 'copy': _('Copy'), - 'log_show': _('Show:'), - 'log_title': _('Logs - {}').format(utils.BEE_VERSION), + 'skip': gettext('Skipped!'), + 'version': gettext('Version: ') + utils.BEE_VERSION, + 'cancel': gettext('Cancel'), + 'clear': gettext('Clear'), + 'copy': gettext('Copy'), + 'log_show': gettext('Show:'), + 'log_title': gettext('Logs - {}').format(utils.BEE_VERSION), 'level_text': [ - _('Debug messages'), - _('Default'), - _('Warnings Only'), + gettext('Debug messages'), + gettext('Default'), + gettext('Warnings Only'), ], } ), @@ -225,9 +241,9 @@ def unsuppress(self) -> None: BG_PROC.start() main_loader = LoadScreen( - ('PAK', _('Packages')), - ('OBJ', _('Loading Objects')), - ('UI', _('Initialising UI')), - title_text=_('Better Extended Editor for Portal 2'), + ('PAK', gettext('Packages')), + ('OBJ', gettext('Loading Objects')), + ('UI', gettext('Initialising UI')), + title_text=gettext('Better Extended Editor for Portal 2'), is_splash=True, ) diff --git a/src/localisation.py b/src/localisation.py new file mode 100644 index 000000000..0ef01ef9b --- /dev/null +++ b/src/localisation.py @@ -0,0 +1,121 @@ +"""Wraps gettext, to localise all UI text.""" +import gettext as gettext_mod +import locale +import logging +import sys + +from srctools.property_parser import PROP_FLAGS_DEFAULT +import utils + +_TRANSLATOR = gettext_mod.NullTranslations() + + +class DummyTranslations(gettext_mod.NullTranslations): + """Dummy form for identifying missing translation entries.""" + + def gettext(self, message: str) -> str: + """Generate placeholder of the right size.""" + # We don't want to leave {arr} intact. + return ''.join([ + '#' if s.isalnum() or s in '{}' else s + for s in message + ]) + + def ngettext(self, msgid1: str, msgid2: str, n: int) -> str: + """Generate placeholder of the right size for plurals.""" + return self.gettext(msgid1 if n == 1 else msgid2) + + lgettext = gettext + lngettext = ngettext + + +def gettext(message: str) -> str: + """Translate the given string.""" + return _TRANSLATOR.gettext(message) + + +def ngettext(msg_sing: str, msg_plural: str, count: int) -> str: + """Translate the given string, with the count to allow plural forms.""" + return _TRANSLATOR.ngettext(msg_sing, msg_plural, count) + + +def setup(logger: logging.Logger) -> None: + """Setup gettext localisations.""" + global _TRANSLATOR + # Get the 'en_US' style language code + lang_code = locale.getdefaultlocale()[0] + + # Allow overriding through command line. + if len(sys.argv) > 1: + for arg in sys.argv[1:]: + if arg.casefold().startswith('lang='): + lang_code = arg[5:] + break + + # Expands single code to parent categories. + expanded_langs = gettext_mod._expand_lang(lang_code) + + logger.info('Language: {!r}', lang_code) + logger.debug('Language codes: {!r}', expanded_langs) + + # Add these to Property's default flags, so config files can also + # be localised. + for lang in expanded_langs: + PROP_FLAGS_DEFAULT['lang_' + lang] = True + + lang_folder = utils.install_path('i18n') + + + for lang in expanded_langs: + try: + file = open(lang_folder / (lang + '.mo').format(lang), 'rb') + except FileNotFoundError: + continue + with file: + _TRANSLATOR = gettext_mod.GNUTranslations(file) + break + else: + # To help identify missing translations, replace everything with + # something noticeable. + if lang_code == 'dummy': + _TRANSLATOR = DummyTranslations() + # No translations, fallback to English. + # That's fine if the user's language is actually English. + else: + if 'en' not in expanded_langs: + logger.warning( + "Can't find translation for codes: {!r}!", + expanded_langs, + ) + _TRANSLATOR = gettext_mod.NullTranslations() + + # Add these functions to builtins, plus _=gettext + _TRANSLATOR.install(['gettext', 'ngettext']) + + # Override the global funcs, to more efficiently delegate if people import + # later. + globals()['gettext'] = _TRANSLATOR.gettext + globals()['ngettext'] = _TRANSLATOR.ngettext + + # Some lang-specific overrides.. + + if gettext('__LANG_USE_SANS_SERIF__') == 'YES': + # For Japanese/Chinese, we want a 'sans-serif' / gothic font + # style. + try: + from tkinter import font + except ImportError: + return + font_names = [ + 'TkDefaultFont', + 'TkHeadingFont', + 'TkTooltipFont', + 'TkMenuFont', + 'TkTextFont', + 'TkCaptionFont', + 'TkSmallCaptionFont', + 'TkIconFont', + # Note - not fixed-width... + ] + for font_name in font_names: + font.nametofont(font_name).configure(family='sans-serif') diff --git a/src/packages/__init__.py b/src/packages/__init__.py index e9ce14532..ad1a634e5 100644 --- a/src/packages/__init__.py +++ b/src/packages/__init__.py @@ -2,12 +2,14 @@ Handles scanning through the zip packages to find all items, styles, etc. """ from __future__ import annotations -import os from collections import defaultdict +from pathlib import Path + import attr +import trio import srctools -from app import tkMarkdown, img +from app import tkMarkdown, img, lazy_conf import utils import consts from app.packageMan import PACK_CONFIG @@ -44,9 +46,12 @@ class SelitemData: auth: list[str] # List of authors. icon: Optional[img.Handle] # Small square icon. large_icon: Optional[img.Handle] # Larger, landscape icon. + previews: list[img.Handle] # Full size images used for previews. desc: tkMarkdown.MarkdownData group: Optional[str] sort_key: str + # The packages used to define this, used for debugging. + packages: set[str] = attr.Factory(frozenset) @classmethod def parse(cls, info: Property, pack_id: str) -> SelitemData: @@ -71,13 +76,33 @@ def parse(cls, info: Property, pack_id: str) -> SelitemData: except LookupError: icon = None try: + large_key = info.find_key('iconLarge') + except LookupError: + large_icon = large_key = None + else: large_icon = img.Handle.parse( - info.find_key('iconlarge'), + large_key, pack_id, *consts.SEL_ICON_SIZE_LRG, ) + try: + preview_block = info.find_block('previews') except LookupError: - large_icon = None + # Use the large icon, if present. + if large_key is not None: + previews = [img.Handle.parse( + large_key, + pack_id, + 0, 0, + )] + else: + previews = [] + else: + previews = [img.Handle.parse( + prop, + pack_id, + 0, 0, + ) for prop in preview_block] return cls( name, @@ -85,9 +110,11 @@ def parse(cls, info: Property, pack_id: str) -> SelitemData: auth, icon, large_icon, + previews, desc, group, sort_key, + frozenset({pack_id}), ) def __add__(self, other: SelitemData) -> SelitemData: @@ -105,9 +132,11 @@ def __add__(self, other: SelitemData) -> SelitemData: self.auth + other.auth, other.icon or self.icon, other.large_icon or self.large_icon, + self.previews + other.previews, tkMarkdown.join(self.desc, other.desc), other.group or self.group, other.sort_key or self.sort_key, + self.packages | other.packages, ) @@ -228,7 +257,7 @@ def __init_subclass__( cls.allow_mult = allow_mult @classmethod - def parse(cls: Type[PakT], data: ParseData) -> PakT: + async def parse(cls: Type[PakT], data: ParseData) -> PakT: """Parse the package object from the info.txt block. ParseData is a namedtuple containing relevant info: @@ -261,7 +290,7 @@ def export(exp_data: ExportData) -> None: @classmethod def post_parse(cls) -> None: - """Do processing after all objects have been fully parsed.""" + """Do processing after all objects of this type have been fully parsed.""" pass @classmethod @@ -296,42 +325,37 @@ def reraise_keyerror(err: BaseException, obj_id: str) -> NoReturn: def get_config( - prop_block: Property, - fsys: FileSystem, - folder: str, - pak_id='', - prop_name='config', - extension='.cfg', - ): - """Extract a config file referred to by the given property block. + prop_block: Property, + folder: str, + pak_id: str, + prop_name: str='config', + extension: str='.cfg', + source: str='', +) -> lazy_conf.LazyConf: + """Lazily extract a config file referred to by the given property block. Looks for the prop_name key in the given prop_block. If the keyvalue has a value of "", an empty tree is returned. - If it has children, a copy of them is returned. + If it has children, a copy of them will be returned. Otherwise the value is a filename in the zip which will be parsed. + + If source is supplied, set_cond_source() will be run. """ prop_block = prop_block.find_key(prop_name, "") if prop_block.has_children(): prop = prop_block.copy() - prop.name = None - return prop + prop.name = "" + return lazy_conf.raw_prop(prop, source=source) if prop_block.value == '': - return Property(None, []) + return lazy_conf.BLANK # Zips must use '/' for the separator, even on Windows! - path = folder + '/' + prop_block.value + path = f'{folder}/{prop_block.value}' if len(path) < 3 or path[-4] != '.': # Add extension path += extension - try: - return fsys.read_prop(path) - except FileNotFoundError: - LOGGER.warning('"{id}:{path}" not in zip!', id=pak_id, path=path) - return Property(None, []) - except UnicodeDecodeError: - LOGGER.exception('Unable to read "{id}:{path}"', id=pak_id, path=path) - raise + return lazy_conf.from_file(utils.PackagePath(pak_id, path), source=source) def set_cond_source(props: Property, source: str) -> None: @@ -345,57 +369,48 @@ def set_cond_source(props: Property, source: str) -> None: cond['__src__'] = source -def find_packages(pak_dir: str) -> None: +async def find_packages(nursery: trio.Nursery, pak_dir: Path) -> None: """Search a folder for packages, recursing if necessary.""" found_pak = False - for name in os.listdir(pak_dir): # Both files and dirs - name = os.path.join(pak_dir, name) - folded = name.casefold() + try: + contents = list(pak_dir.iterdir()) + except FileNotFoundError: + LOGGER.warning('Package search location "{}" does not exist!', pak_dir) + return + + for name in contents: # Both files and dirs + folded = name.stem.casefold() if folded.endswith('.vpk') and not folded.endswith('_dir.vpk'): # _000.vpk files, useless without the directory continue - if os.path.isdir(name): + if name.is_dir(): filesys = RawFileSystem(name) else: - ext = os.path.splitext(folded)[1] + ext = name.suffix.casefold() if ext in ('.bee_pack', '.zip'): - filesys = ZipFileSystem(name) + filesys = await trio.to_thread.run_sync(ZipFileSystem, name, cancellable=True) elif ext == '.vpk': - filesys = VPKFileSystem(name) + filesys = await trio.to_thread.run_sync(VPKFileSystem, name, cancellable=True) else: LOGGER.info('Extra file: {}', name) continue - LOGGER.debug('Reading package "' + name + '"') - - # Gain a persistent hold on the filesystem's handle. - # That means we don't need to reopen the zip files constantly. - filesys.open_ref() + LOGGER.debug('Reading package "{}"', name) # Valid packages must have an info.txt file! try: - info = filesys.read_prop('info.txt') + info = await trio.to_thread.run_sync(filesys.read_prop, 'info.txt', cancellable=True) except FileNotFoundError: - # Close the ref we've gotten, since it's not in the dict - # it won't be done by load_packages(). - filesys.close_ref() - - if os.path.isdir(name): + if name.is_dir(): # This isn't a package, so check the subfolders too... LOGGER.debug('Checking subdir "{}" for packages...', name) - find_packages(name) + nursery.start_soon(find_packages, nursery, name) else: LOGGER.warning('ERROR: package "{}" has no info.txt!', name) # Don't continue to parse this "package" continue - try: - pak_id = info['ID'] - except IndexError: - # Close the ref we've gotten, since it's not in the dict - # it won't be done by load_packages(). - filesys.close_ref() - raise + pak_id = info['ID'] if pak_id.casefold() in packages: raise ValueError( @@ -417,24 +432,33 @@ def find_packages(pak_dir: str) -> None: LOGGER.info('No packages in folder {}!', pak_dir) -def no_packages_err(pak_dir: str, msg: str) -> NoReturn: +def no_packages_err(pak_dirs: list[Path], msg: str) -> NoReturn: """Show an error message indicating no packages are present.""" from tkinter import messagebox import sys # We don't have a packages directory! + if len(pak_dirs) == 1: + trailer = str(pak_dirs[0]) + else: + trailer = ( + 'one of the following locations:\n' + + '\n'.join(f' - {fold}' for fold in pak_dirs) + ) + message = ( + f'{msg}\nGet the packages from ' + '"https://github.com/BEEmod/BEE2-items" ' + f'and place them in {trailer}' + ) + LOGGER.error(message) messagebox.showerror( title='BEE2 - Invalid Packages Directory!', - message=( - '{}\nGet the packages from ' - '"https://github.com/BEEmod/BEE2-items" ' - 'and place them in "{}".').format(msg, pak_dir + os.path.sep), - # Add slash to the end to indicate it's a folder. + message=message, ) sys.exit() -def load_packages( - pak_dir: str, +async def load_packages( + pak_dirs: list[Path], loader: LoadScreen, log_item_fallbacks=False, log_missing_styles=False, @@ -445,41 +469,37 @@ def load_packages( ) -> Mapping[str, FileSystem]: """Scan and read in all packages.""" global CHECK_PACKFILE_CORRECTNESS - pak_dir = os.path.abspath(pak_dir) - - if not os.path.isdir(pak_dir): - no_packages_err(pak_dir, 'The given packages directory is not present!') Item.log_ent_count = log_missing_ent_count CHECK_PACKFILE_CORRECTNESS = log_incorrect_packfile - # If we fail we want to clean up our filesystems. - should_close_filesystems = True - try: - find_packages(pak_dir) + async with trio.open_nursery() as find_nurs: + for pak_dir in pak_dirs: + find_nurs.start_soon(find_packages, find_nurs, pak_dir) - pack_count = len(packages) - loader.set_length("PAK", pack_count) + pack_count = len(packages) + loader.set_length("PAK", pack_count) - if pack_count == 0: - no_packages_err(pak_dir, 'No packages found!') + if pack_count == 0: + no_packages_err(pak_dirs, 'No packages found!') - # We must have the clean style package. - if CLEAN_PACKAGE not in packages: - no_packages_err( - pak_dir, - 'No Clean Style package! This is required for some ' - 'essential resources and objects.' - ) + # We must have the clean style package. + if CLEAN_PACKAGE not in packages: + no_packages_err( + pak_dirs, + 'No Clean Style package! This is required for some ' + 'essential resources and objects.' + ) - data: dict[Type[PakT], list[PakT]] = {} - obj_override: dict[Type[PakObject], dict[str, list[ParseData]]] = {} + data: dict[Type[PakT], list[PakT]] = {} + obj_override: dict[Type[PakObject], dict[str, list[ParseData]]] = {} - for obj_type in OBJ_TYPES.values(): - all_obj[obj_type] = {} - obj_override[obj_type] = defaultdict(list) - data[obj_type] = [] + for obj_type in OBJ_TYPES.values(): + all_obj[obj_type] = {} + obj_override[obj_type] = defaultdict(list) + data[obj_type] = [] + async with trio.open_nursery() as nursery: for pack in packages.values(): if not pack.enabled: LOGGER.info('Package {id} disabled!', id=pack.id) @@ -487,82 +507,25 @@ def load_packages( loader.set_length("PAK", pack_count) continue - with srctools.logger.context(pack.id): - parse_package(pack, obj_override, has_tag_music, has_mel_music) - loader.step("PAK") + nursery.start_soon(parse_package, nursery, pack, obj_override, loader, has_tag_music, has_mel_music) + LOGGER.debug('Submitted packages.') - loader.set_length("OBJ", sum( - len(obj_type) - for obj_type in - all_obj.values() - )) + LOGGER.debug('Parsed packages, now parsing objects.') + + loader.set_length("OBJ", sum( + len(obj_type) + for obj_type in + all_obj.values() + )) + async with trio.open_nursery() as nursery: for obj_class, objs in all_obj.items(): for obj_id, obj_data in objs.items(): - # parse through the object and return the resultant class - try: - with srctools.logger.context(f'{obj_data.pak_id}:{obj_id}'): - object_ = obj_class.parse( - ParseData( - obj_data.fsys, - obj_id, - obj_data.info_block, - obj_data.pak_id, - False, - ) - ) - except (NoKeyError, IndexError) as e: - reraise_keyerror(e, obj_id) - raise # Never reached. - except TokenSyntaxError as e: - # Add the relevant package to the filename. - if e.file: - e.file = f'{obj_data.pak_id}:{e.file}' - raise - except Exception as e: - raise ValueError( - 'Error occured parsing ' - f'{obj_data.pak_id}:{obj_id} item!' - ) from e - - if not hasattr(object_, 'id'): - raise ValueError( - '"{}" object {} has no ID!'.format(obj_class.__name__, object_) - ) - - # Store in this database so we can find all objects for each type. - # noinspection PyProtectedMember - obj_class._id_to_obj[object_.id.casefold()] = object_ - - object_.pak_id = obj_data.pak_id - object_.pak_name = obj_data.disp_name - for override_data in obj_override[obj_class].get(obj_id, []): - try: - with srctools.logger.context(f'override {override_data.pak_id}:{obj_id}'): - override = obj_class.parse(override_data) - except (NoKeyError, IndexError) as e: - reraise_keyerror(e, f'{override_data.pak_id}:{obj_id}') - raise # Never reached. - except TokenSyntaxError as e: - # Add the relevant package to the filename. - if e.file: - e.file = f'{override_data.pak_id}:{e.file}' - raise - except Exception as e: - raise ValueError( - f'Error occured parsing {obj_id} override' - f'from package {override_data.pak_id}!' - ) from e - - object_.add_over(override) - data[obj_class].append(object_) - loader.step("OBJ") - - should_close_filesystems = False - finally: - if should_close_filesystems: - for sys in PACKAGE_SYS.values(): - sys.close_ref() + overrides = obj_override[obj_class].get(obj_id, []) + nursery.start_soon( + parse_object, + obj_class, obj_id, obj_data, overrides, data, loader, + ) LOGGER.info('Object counts:\n{}\n', '\n'.join( '{:<15}: {}'.format(obj_type.__name__, len(objs)) @@ -576,16 +539,17 @@ def load_packages( # This has to be done after styles. LOGGER.info('Allocating styled items...') - assign_styled_items( - log_item_fallbacks, - log_missing_styles, - ) + async with trio.open_nursery() as nursery: + for it in Item.all(): + nursery.start_soon(assign_styled_items, log_item_fallbacks, log_missing_styles, it) return PACKAGE_SYS -def parse_package( +async def parse_package( + nursery: trio.Nursery, pack: Package, obj_override: dict[Type[PakObject], dict[str, list[ParseData]]], + loader: LoadScreen, has_tag: bool=False, has_mel: bool=False, ) -> None: @@ -611,6 +575,7 @@ def parse_package( desc: list[str] = [] for obj in pack.info: + await trio.sleep(0) if obj.name in ['prerequisites', 'id', 'name']: # Not object IDs. continue @@ -679,8 +644,78 @@ def parse_package( pack.desc = '\n'.join(desc) for template in pack.fsys.walk_folder('templates'): + await trio.sleep(0) if template.path.casefold().endswith('.vmf'): - template_brush.parse_template(pack.id, template) + nursery.start_soon(template_brush.parse_template, pack.id, template) + loader.step('PAK') + + +async def parse_object( + obj_class: Type[PakObject], obj_id: str, obj_data: ParseData, obj_override: list[ParseData], + data: dict[Type[PakObject], list[PakObject]], + loader: LoadScreen, +) -> None: + """Parse through the object and store the resultant class.""" + try: + with srctools.logger.context(f'{obj_data.pak_id}:{obj_id}'): + object_ = await obj_class.parse( + ParseData( + obj_data.fsys, + obj_id, + obj_data.info_block, + obj_data.pak_id, + False, + ) + ) + await trio.sleep(0) + except (NoKeyError, IndexError) as e: + reraise_keyerror(e, obj_id) + raise # Never reached. + except TokenSyntaxError as e: + # Add the relevant package to the filename. + if e.file: + e.file = f'{obj_data.pak_id}:{e.file}' + raise + except Exception as e: + raise ValueError( + 'Error occured parsing ' + f'{obj_data.pak_id}:{obj_id} item!' + ) from e + + if not hasattr(object_, 'id'): + raise ValueError( + '"{}" object {} has no ID!'.format(obj_class.__name__, object_) + ) + + # Store in this database so we can find all objects for each type. + # noinspection PyProtectedMember + obj_class._id_to_obj[object_.id.casefold()] = object_ + + object_.pak_id = obj_data.pak_id + object_.pak_name = obj_data.disp_name + for override_data in obj_override: + await trio.sleep(0) + try: + with srctools.logger.context(f'override {override_data.pak_id}:{obj_id}'): + override = await obj_class.parse(override_data) + except (NoKeyError, IndexError) as e: + reraise_keyerror(e, f'{override_data.pak_id}:{obj_id}') + raise # Never reached. + except TokenSyntaxError as e: + # Add the relevant package to the filename. + if e.file: + e.file = f'{override_data.pak_id}:{e.file}' + raise + except Exception as e: + raise ValueError( + f'Error occured parsing {obj_id} override' + f'from package {override_data.pak_id}!' + ) from e + + await trio.sleep(0) + object_.add_over(override) + data[obj_class].append(object_) + loader.step("OBJ") class Package: @@ -690,7 +725,7 @@ def __init__( pak_id: str, filesystem: FileSystem, info: Property, - name: str, + path: Path, ) -> None: disp_name = info['Name', None] if disp_name is None: @@ -700,14 +735,14 @@ def __init__( self.id = pak_id self.fsys = filesystem self.info = info - self.name = name + self.path = path self.disp_name = disp_name self.desc = '' # Filled in by parse_package. @property def enabled(self) -> bool: """Should this package be loaded?""" - if self.id == CLEAN_PACKAGE: + if self.id.casefold() == CLEAN_PACKAGE: # The clean style package is special! # It must be present. return True @@ -717,7 +752,7 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, value: bool) -> None: """Enable or disable the package.""" - if self.id == CLEAN_PACKAGE: + if self.id.casefold() == CLEAN_PACKAGE: raise ValueError('The Clean Style package cannot be disabled!') PACK_CONFIG[self.id]['Enabled'] = srctools.bool_as_int(value) @@ -729,7 +764,7 @@ def is_stale(self, mod_time: int) -> bool: LOGGER.info('Need to extract resources - {} is unzipped!', self.id) return True - zip_modtime = int(os.stat(self.name).st_mtime) + zip_modtime = int(self.path.stat().st_mtime) # If zero, it's never extracted... if zip_modtime != mod_time or mod_time == 0: @@ -744,7 +779,9 @@ def get_modtime(self) -> int: # No modification time return 0 else: - return int(os.stat(self.name).st_mtime) + return int(self.path.stat().st_mtime) + + class Style(PakObject): @@ -755,9 +792,9 @@ def __init__( selitem_data: SelitemData, items: list[EditorItem], renderables: dict[RenderableType, Renderable], - config=None, + suggested: tuple[set[str], set[str], set[str], set[str]], + config: lazy_conf.LazyConf = lazy_conf.BLANK, base_style: Optional[str]=None, - suggested: tuple[str, str, str, str]=None, has_video: bool=True, vpk_name: str='', corridors: dict[tuple[str, int], CorrDesc]=None, @@ -770,7 +807,7 @@ def __init__( # Set by post_parse() after all objects are read. # this is a list of this style, plus parents in order. self.bases: list[Style] = [] - self.suggested = suggested or ('', '', 'SKY_BLACK', '') + self.suggested = suggested self.has_video = has_video self.vpk_name = vpk_name self.corridors: dict[tuple[str, int], CorrDesc] = {} @@ -782,15 +819,10 @@ def __init__( except KeyError: self.corridors[group, i] = CorrDesc() - if config is None: - self.config = Property(None, []) - else: - self.config = config - - set_cond_source(self.config, 'Style <{}>'.format(style_id)) + self.config = config @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData): """Parse a style definition.""" info = data.info selitem_data = SelitemData.parse(info, data.pak_id) @@ -801,32 +833,34 @@ def parse(cls, data: ParseData): ) vpk_name = info['vpk_name', ''].casefold() - sugg = info.find_key('suggested', []) - if data.is_override: - # For overrides, we default to no suggestion.. - sugg = ( - sugg['quote', ''], - sugg['music', ''], - sugg['skybox', ''], - sugg['elev', ''], - ) - else: - sugg = ( - sugg['quote', ''], - sugg['music', ''], - sugg['skybox', 'SKY_BLACK'], - sugg['elev', ''], - ) + sugg: dict[str, set[str]] = { + 'quote': set(), + 'music': set(), + 'skybox': set(), + 'elev': set(), + } + for prop in info.find_children('suggested'): + try: + sugg[prop.name].add(prop.value) + except KeyError: + LOGGER.warning('Unknown suggestion types for style {}: {}', data.id, prop.name) + + sugg_tup = ( + sugg['quote'], + sugg['music'], + sugg['skybox'], + sugg['elev'], + ) - corr_conf = info.find_key('corridors', []) + corr_conf = info.find_key('corridors', or_blank=True) corridors = {} icon_folder = corr_conf['icon_folder', ''] for group, length in CORRIDOR_COUNTS.items(): - group_prop = corr_conf.find_key(group, []) + group_prop = corr_conf.find_key(group, or_blank=True) for i in range(1, length + 1): - prop = group_prop.find_key(str(i), '') # type: Property + prop = group_prop.find_key(str(i), '') if icon_folder: icon = utils.PackagePath(data.pak_id, 'corr/{}/{}/{}.jpg'.format(icon_folder, group, i)) @@ -850,23 +884,23 @@ def parse(cls, data: ParseData): base = None try: folder = 'styles/' + info['folder'] - except IndexError: + except LookupError: # It's OK for override styles to be missing their 'folder' # value. if data.is_override: items = [] renderables = {} - vbsp = None + vbsp = lazy_conf.BLANK else: raise ValueError(f'Style "{data.id}" missing configuration folder!') else: - with data.fsys: - with data.fsys[folder + '/items.txt'].open_str() as f: - items, renderables = EditorItem.parse(f) - try: - vbsp = data.fsys.read_prop(folder + '/vbsp_config.cfg') - except FileNotFoundError: - vbsp = None + with data.fsys[folder + '/items.txt'].open_str() as f: + items, renderables = await trio.to_thread.run_sync(EditorItem.parse, f) + vbsp = lazy_conf.from_file( + utils.PackagePath(data.pak_id, folder + '/vbsp_config.cfg'), + missing_ok=True, + source=f'Style <{data.id}>', + ) return cls( style_id=data.id, @@ -875,7 +909,7 @@ def parse(cls, data: ParseData): renderables=renderables, config=vbsp, base_style=base, - suggested=sugg, + suggested=sugg_tup, has_video=has_video, corridors=corridors, vpk_name=vpk_name, @@ -885,24 +919,29 @@ def add_over(self, override: Style) -> None: """Add the additional commands to ourselves.""" self.items.extend(override.items) self.renderables.update(override.renderables) - self.config += override.config + self.config = lazy_conf.concat(self.config, override.config) self.selitem_data += override.selitem_data self.has_video = self.has_video or override.has_video - # If overrides have suggested IDs, use those. Unset values = ''. + # If overrides have suggested IDs, add those. self.suggested = tuple( - over_sugg or self_sugg + over_sugg | self_sugg for self_sugg, over_sugg in zip(self.suggested, override.suggested) ) @classmethod def post_parse(cls) -> None: - """Assign the bases lists for all styles.""" + """Assign the bases lists for all styles, and set default suggested items.""" all_styles: dict[str, Style] = {} + defaults = ['', '', 'SKY_BLACK', ''] + for style in cls.all(): all_styles[style.id] = style + for default, sugg_set in zip(defaults, style.suggested): + if not sugg_set: + sugg_set.add(default) for style in all_styles.values(): base = [] @@ -927,7 +966,7 @@ def export(self) -> tuple[list[EditorItem], dict[RenderableType, Renderable], Pr This is a special case, since styles should go first in the lists. """ vbsp_config = Property(None, []) - vbsp_config += self.config.copy() + vbsp_config += self.config() return self.items, self.renderables, vbsp_config diff --git a/src/packages/editor_sound.py b/src/packages/editor_sound.py index a887fd74d..85a0c9147 100644 --- a/src/packages/editor_sound.py +++ b/src/packages/editor_sound.py @@ -16,11 +16,11 @@ def __init__(self, snd_name: str, data: Property) -> None: data.name = self.id @classmethod - def parse(cls, data: ParseData) -> 'EditorSound': + async def parse(cls, data: ParseData) -> 'EditorSound': """Parse editor sounds from the package.""" return cls( snd_name=data.id, - data=data.info.find_key('keys', []) + data=data.info.find_key('keys', or_blank=True) ) @staticmethod @@ -29,4 +29,4 @@ def export(exp_data: ExportData): # Just command the game to do the writing. exp_data.game.add_editor_sounds( EditorSound.all() - ) \ No newline at end of file + ) diff --git a/src/packages/elevator.py b/src/packages/elevator.py index b4fd15760..e6b8cd0a3 100644 --- a/src/packages/elevator.py +++ b/src/packages/elevator.py @@ -28,7 +28,7 @@ def __init__( self.vert_video = vert_video @classmethod - def parse(cls, data: ParseData) -> 'Elevator': + async def parse(cls, data: ParseData) -> 'Elevator': """Read elevator videos from the package.""" info = data.info selitem_data = SelitemData.parse(info, data.pak_id) diff --git a/src/packages/item.py b/src/packages/item.py index d01268296..2f2693b0b 100644 --- a/src/packages/item.py +++ b/src/packages/item.py @@ -3,27 +3,26 @@ A system is provided so configurations can be shared and partially modified as required. """ +from __future__ import annotations import operator import re import copy -from typing import ( - Optional, Union, Tuple, NamedTuple, - Dict, List, Match, Set, cast, -) -from srctools import FileSystem, Property, EmptyMapping +from typing import NamedTuple, Match, cast from pathlib import PurePosixPath as FSPath + +from srctools import FileSystem, Property, EmptyMapping, VMF +from srctools.tokenizer import Tokenizer, Token import srctools.logger -from app import tkMarkdown, img +from app import tkMarkdown, img, lazy_conf, DEV_MODE from packages import ( - PakObject, ParseData, ExportData, - sep_values, desc_parse, - set_cond_source, get_config, - Style, + PakObject, ParseData, ExportData, Style, + sep_values, desc_parse, get_config, ) from editoritems import Item as EditorItem, InstCount from connections import Config as ConnConfig -from srctools.tokenizer import Tokenizer, Token +import editoritems_vmf +import utils LOGGER = srctools.logger.get_logger(__name__) @@ -31,18 +30,14 @@ # Finds names surrounded by %s RE_PERCENT_VAR = re.compile(r'%(\w*)%') -# The name given to standard connections - regular input/outputs in editoritems. -CONN_NORM = 'CONNECTION_STANDARD' -CONN_FUNNEL = 'CONNECTION_TBEAM_POLARITY' - class UnParsedItemVariant(NamedTuple): """The desired variant for an item, before we've figured out the dependencies.""" pak_id: str # The package that defined this variant. filesys: FileSystem # The original filesystem. - folder: Optional[str] # If set, use the given folder from our package. - style: Optional[str] # Inherit from a specific style (implies folder is None) - config: Optional[Property] # Config for editing + folder: str | None # If set, use the given folder from our package. + style: str | None # Inherit from a specific style (implies folder is None) + config: Property | None # Config for editing class ItemVariant: @@ -50,22 +45,24 @@ class ItemVariant: def __init__( self, + pak_id: str, editoritems: EditorItem, - vbsp_config: Property, - editor_extra: List[EditorItem], - authors: List[str], - tags: List[str], + vbsp_config: lazy_conf.LazyConf, + editor_extra: list[EditorItem], + authors: list[str], + tags: list[str], desc: tkMarkdown.MarkdownData, - icons: Dict[str, img.Handle], + icons: dict[str, img.Handle], ent_count: str='', url: str = None, all_name: str=None, all_icon: FSPath=None, source: str='', - ): + ) -> None: self.editor = editoritems self.editor_extra = editor_extra self.vbsp_config = vbsp_config + self.pak_id = pak_id self.source = source # Original location of configs self.authors = authors @@ -79,11 +76,12 @@ def __init__( self.all_name = all_name self.all_icon = all_icon - def copy(self) -> 'ItemVariant': + def copy(self) -> ItemVariant: """Make a copy of all the data.""" return ItemVariant( + self.pak_id, self.editor, - self.vbsp_config.copy(), + self.vbsp_config, self.editor_extra.copy(), self.authors.copy(), self.tags.copy(), @@ -103,45 +101,40 @@ def can_group(self) -> bool: self.all_name is not None ) - def override_from_folder(self, other: 'ItemVariant') -> None: + def override_from_folder(self, other: ItemVariant) -> None: """Perform the override from another item folder.""" self.authors.extend(other.authors) self.tags.extend(self.tags) - self.vbsp_config += other.vbsp_config + self.vbsp_config = lazy_conf.concat(self.vbsp_config, other.vbsp_config) self.desc = tkMarkdown.join(self.desc, other.desc) - def modify(self, fsys: FileSystem, pak_id: str, props: Property, source: str) -> 'ItemVariant': + async def modify(self, pak_id: str, props: Property, source: str) -> ItemVariant: """Apply a config to this item variant. This produces a copy with various modifications - switching out palette or instance values, changing the config, etc. """ + vbsp_config: lazy_conf.LazyConf if 'config' in props: # Item.parse() has resolved this to the actual config. vbsp_config = get_config( props, - fsys, 'items', pak_id, ) else: - vbsp_config = self.vbsp_config.copy() + vbsp_config = self.vbsp_config if 'replace' in props: # Replace property values in the config via regex. - replace_vals = [ + vbsp_config = lazy_conf.replace(vbsp_config, [ (re.compile(prop.real_name, re.IGNORECASE), prop.value) for prop in props.find_children('Replace') - ] - for prop in vbsp_config.iter_tree(): - for regex, sub in replace_vals: - prop.name = regex.sub(sub, prop.real_name) - prop.value = regex.sub(sub, prop.value) + ]) - vbsp_config += list(get_config( + vbsp_config = lazy_conf.concat(vbsp_config, get_config( props, - fsys, 'items', pak_id, prop_name='append', @@ -169,6 +162,7 @@ def modify(self, fsys: FileSystem, pak_id: str, props: Property, source: str) -> tags = self.tags.copy() variant = ItemVariant( + pak_id, self.editor, vbsp_config, self.editor_extra.copy(), @@ -180,7 +174,7 @@ def modify(self, fsys: FileSystem, pak_id: str, props: Property, source: str) -> url=props['url', self.url], all_name=self.all_name, all_icon=self.all_icon, - source='{} from {}'.format(source, self.source), + source=f'{source} from {self.source}', ) [variant.editor] = variant._modify_editoritems( props, @@ -204,11 +198,11 @@ def modify(self, fsys: FileSystem, pak_id: str, props: Property, source: str) -> def _modify_editoritems( self, props: Property, - editor: List[EditorItem], + editor: list[EditorItem], pak_id: str, source: str, is_extra: bool, - ) -> List[EditorItem]: + ) -> list[EditorItem]: """Modify either the base or extra editoritems block.""" # We can share a lot of the data, if it isn't changed and we take # care to copy modified parts. @@ -229,7 +223,11 @@ def _modify_editoritems( pal_icon = None pal_name = item['pal_name', None] # Name for the palette icon try: - bee2_icon = img.Handle.parse(item.find_key('BEE2'), pak_id, 64, 64, subfolder='items') + bee2_icon = img.Handle.parse( + item.find_key('BEE2'), pak_id, + 64, 64, + subfolder='items', + ) except LookupError: bee2_icon = None @@ -237,10 +235,12 @@ def _modify_editoritems( if is_extra: raise Exception( 'Cannot specify "all" for hidden ' - 'editoritems blocks in {}!'.format(source) + f'editoritems blocks in {source}!' ) if pal_icon is not None: self.all_icon = pal_icon + # If a previous BEE icon was present, remove so we use the VTF. + self.icons.pop('all', None) if pal_name is not None: self.all_name = pal_name if bee2_icon is not None: @@ -252,8 +252,8 @@ def _modify_editoritems( subtype_item, subtype_ind, subtype = subtype_lookup[subtype_ind] except (IndexError, ValueError, TypeError): raise Exception( - 'Invalid index "{}" when modifying ' - 'editoritems for {}'.format(item.name, source) + f'Invalid index "{item.name}" when modifying ' + f'editoritems for {source}' ) subtype_item.subtypes = subtype_item.subtypes.copy() subtype_item.subtypes[subtype_ind] = subtype = copy.deepcopy(subtype) @@ -275,9 +275,12 @@ def _modify_editoritems( if is_extra: raise ValueError( 'Cannot specify BEE2 icons for hidden ' - 'editoritems blocks in {}!'.format(source) + f'editoritems blocks in {source}!' ) self.icons[item.name] = bee2_icon + elif pal_icon is not None: + # If a previous BEE icon was present, remove so we use the VTF. + self.icons.pop(item.name, None) if pal_name is not None: subtype.pal_name = pal_name @@ -288,7 +291,7 @@ def _modify_editoritems( if len(editor) != 1: raise ValueError( 'Cannot specify instances for multiple ' - 'editoritems blocks in {}!'.format(source) + f'editoritems blocks in {source}!' ) editor[0].instances = editor[0].instances.copy() editor[0].cust_instances = editor[0].cust_instances.copy() @@ -330,7 +333,7 @@ def _modify_editoritems( if len(editor) != 1: raise ValueError( 'Cannot specify I/O for multiple ' - 'editoritems blocks in {}!'.format(source) + f'editoritems blocks in {source}!' ) force = io_props['force', ''] editor[0].conn_config = ConnConfig.parse(editor[0].id, io_props) @@ -355,8 +358,8 @@ def __init__( vers_id: str, name: str, isolate: bool, - styles: Dict[str, ItemVariant], - def_style: Union[ItemVariant, Union[str, ItemVariant]], + styles: dict[str, ItemVariant], + def_style: ItemVariant | str, ) -> None: self.name = name self.id = vers_id @@ -375,21 +378,21 @@ class Item(PakObject): def __init__( self, item_id: str, - versions: Dict[str, Version], + versions: dict[str, Version], def_version: Version, needs_unlock: bool=False, - all_conf: Optional[Property]=None, + all_conf: lazy_conf.LazyConf=lazy_conf.BLANK, unstyled: bool=False, isolate_versions: bool=False, glob_desc: tkMarkdown.MarkdownData=(), desc_last: bool=False, - folders: Dict[Tuple[FileSystem, str], ItemVariant]=EmptyMapping, + folders: dict[tuple[FileSystem, str], ItemVariant]=EmptyMapping, ) -> None: self.id = item_id self.versions = versions self.def_ver = def_version self.needs_unlock = needs_unlock - self.all_conf = all_conf or Property(None, []) + self.all_conf = all_conf # If set or set on a version, don't look at the first version # for unstyled items. self.isolate_versions = isolate_versions @@ -400,13 +403,13 @@ def __init__( self.folders = folders @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData): """Parse an item definition.""" - versions: Dict[str, Version] = {} - def_version: Optional[Version] = None + versions: dict[str, Version] = {} + def_version: Version | None = None # The folders we parse for this - we don't want to parse the same # one twice. - folders_to_parse: Set[str] = set() + folders_to_parse: set[str] = set() unstyled = data.info.bool('unstyled') glob_desc = desc_parse(data.info, 'global:' + data.id, data.pak_id) @@ -414,19 +417,16 @@ def parse(cls, data: ParseData): all_config = get_config( data.info, - data.fsys, 'items', pak_id=data.pak_id, prop_name='all_conf', + source=f'', ) - set_cond_source(all_config, ''.format( - data.id, - )) for ver in data.info.find_all('version'): ver_name = ver['name', 'Regular'] ver_id = ver['ID', 'VER_DEFAULT'] - styles: Dict[str, ItemVariant] = {} + styles: dict[str, ItemVariant] = {} ver_isolate = ver.bool('isolated') def_style = None @@ -472,11 +472,9 @@ def parse(cls, data: ParseData): if style.real_name == folder.style: raise ValueError( - 'Item "{}"\'s "{}" style ' - 'can\'t inherit from itself!'.format( - data.id, - style.real_name, - )) + f'Item "{data.id}"\'s "{style.real_name}" style ' + "can't inherit from itself!" + ) versions[ver_id] = version = Version( ver_id, ver_name, ver_isolate, styles, def_style, ) @@ -540,7 +538,7 @@ def parse(cls, data: ParseData): def add_over(self, override: 'Item') -> None: """Add the other item data to ourselves.""" # Copy over all_conf always. - self.all_conf += override.all_conf + self.all_conf = lazy_conf.concat(self.all_conf, override.all_conf) self.folders.update(override.folders) @@ -564,10 +562,10 @@ def add_over(self, override: 'Item') -> None: # our_style.override_from_folder(style) def __repr__(self) -> str: - return ''.format(self.id) + return f'' @staticmethod - def export(exp_data: ExportData): + def export(exp_data: ExportData) -> None: """Export all items into the configs. For the selected attribute, this takes a tuple of values: @@ -582,12 +580,11 @@ def export(exp_data: ExportData): style_id = exp_data.selected_style.id - aux_item_configs: Dict[str, ItemConfig] = { + aux_item_configs: dict[str, ItemConfig] = { conf.id: conf for conf in ItemConfig.all() } - item: Item for item in sorted(Item.all(), key=operator.attrgetter('id')): ver_id = versions.get(item.id, 'VER_DEFAULT') @@ -599,7 +596,7 @@ def export(exp_data: ExportData): ) exp_data.all_items.extend(items) - vbsp_config += apply_replacements(config_part) + vbsp_config.extend(apply_replacements(config_part(), item.id)) # Add auxiliary configs as well. try: @@ -607,7 +604,7 @@ def export(exp_data: ExportData): except KeyError: pass else: - vbsp_config += apply_replacements(aux_conf.all_conf) + vbsp_config.extend(apply_replacements(aux_conf.all_conf(), item.id + ':aux_all')) try: version_data = aux_conf.versions[ver_id] except KeyError: @@ -617,9 +614,10 @@ def export(exp_data: ExportData): # that's defined for this config for poss_style in exp_data.selected_style.bases: if poss_style.id in version_data: - vbsp_config += apply_replacements( - version_data[poss_style.id] - ) + vbsp_config.extend(apply_replacements( + version_data[poss_style.id](), + item.id + ':aux' + )) break def _get_export_data( @@ -627,8 +625,8 @@ def _get_export_data( pal_list, ver_id, style_id, - prop_conf: Dict[str, Dict[str, str]], - ) -> Tuple[List[EditorItem], Property]: + prop_conf: dict[str, dict[str, str]], + ) -> tuple[list[EditorItem], lazy_conf.LazyConf]: """Get the data for an exported item.""" # Build a dictionary of this item's palette positions, @@ -675,7 +673,7 @@ def _get_export_data( return ( [new_item] + item_data.editor_extra, # Add all_conf first so it's conditions run first by default - self.all_conf + item_data.vbsp_config, + lazy_conf.concat(self.all_conf, item_data.vbsp_config), ) @@ -687,61 +685,54 @@ class ItemConfig(PakObject, allow_mult=True): def __init__( self, it_id, - all_conf: Property, - version_conf: Dict[str, Dict[str, Property]], + all_conf: lazy_conf.LazyConf, + version_conf: dict[str, dict[str, lazy_conf.LazyConf]], ) -> None: self.id = it_id self.versions = version_conf self.all_conf = all_conf @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData) -> ItemConfig: """Parse from config files.""" - filesystem = data.fsys - vers = {} + vers: dict[str, dict[str, lazy_conf.LazyConf]] = {} + styles: dict[str, lazy_conf.LazyConf] all_config = get_config( data.info, - data.fsys, 'items', pak_id=data.pak_id, prop_name='all_conf', + source=f'', ) - set_cond_source(all_config, ''.format( - data.pak_id, data.id, - )) - - with filesystem: - for ver in data.info.find_all('Version'): # type: Property - ver_id = ver['ID', 'VER_DEFAULT'] - vers[ver_id] = styles = {} - for sty_block in ver.find_all('Styles'): - for style in sty_block: - styles[style.real_name] = conf = filesystem.read_prop( - 'items/' + style.value + '.cfg' - ) - set_cond_source(conf, "".format( - data.pak_id, data.id, style.real_name, - )) + for ver in data.info.find_all('Version'): + ver_id = ver['ID', 'VER_DEFAULT'] + vers[ver_id] = styles = {} + for sty_block in ver.find_all('Styles'): + for style in sty_block: + styles[style.real_name] = lazy_conf.from_file( + utils.PackagePath(data.pak_id, f'items/{style.value}.cfg'), + source=f'', + ) - return cls( + return ItemConfig( data.id, all_config, vers, ) - def add_over(self, override: 'ItemConfig') -> None: + def add_over(self, override: ItemConfig) -> None: """Add additional style configs to the original config.""" - self.all_conf += override.all_conf.copy() + self.all_conf = lazy_conf.concat(self.all_conf, override.all_conf) for vers_id, styles in override.versions.items(): our_styles = self.versions.setdefault(vers_id, {}) for sty_id, style in styles.items(): if sty_id not in our_styles: - our_styles[sty_id] = style.copy() + our_styles[sty_id] = style else: - our_styles[sty_id] += style.copy() + our_styles[sty_id] = lazy_conf.concat(our_styles[sty_id], style) @staticmethod def export(exp_data: ExportData) -> None: @@ -753,51 +744,57 @@ def export(exp_data: ExportData) -> None: def parse_item_folder( - folders_to_parse: Set[str], + folders_to_parse: set[str], filesystem: FileSystem, pak_id: str, -) -> Dict[str, ItemVariant]: +) -> dict[str, ItemVariant]: """Parse through the data in item/ folders. folders is a dict, with the keys set to the folder names we want. The values will be filled in with itemVariant values """ - folders: Dict[str, ItemVariant] = {} + folders: dict[str, ItemVariant] = {} for fold in folders_to_parse: prop_path = 'items/' + fold + '/properties.txt' editor_path = 'items/' + fold + '/editoritems.txt' config_path = 'items/' + fold + '/vbsp_config.cfg' - first_item: Optional[Item] = None - extra_items: List[EditorItem] = [] - with filesystem: - try: - props = filesystem.read_prop(prop_path).find_key('Properties') - f = filesystem[editor_path].open_str() - except FileNotFoundError as err: - raise IOError( - '"' + pak_id + ':items/' + fold + '" not valid! ' - 'Folder likely missing! ' - ) from err - with f: - tok = Tokenizer(f, editor_path) - for tok_type, tok_value in tok: - if tok_type is Token.STRING: - if tok_value.casefold() != 'item': - raise tok.error('Unknown item option "{}"!', tok_value) - if first_item is None: - first_item = EditorItem.parse_one(tok) - else: - extra_items.append(EditorItem.parse_one(tok)) - elif tok_type is not Token.NEWLINE: - raise tok.error(tok_type) + first_item: Item | None = None + extra_items: list[EditorItem] = [] + try: + props = filesystem.read_prop(prop_path).find_key('Properties') + f = filesystem[editor_path].open_str() + except FileNotFoundError as err: + raise IOError( + '"' + pak_id + ':items/' + fold + '" not valid! ' + 'Folder likely missing! ' + ) from err + with f: + tok = Tokenizer(f, editor_path) + for tok_type, tok_value in tok: + if tok_type is Token.STRING: + if tok_value.casefold() != 'item': + raise tok.error('Unknown item option "{}"!', tok_value) + if first_item is None: + first_item = EditorItem.parse_one(tok) + else: + extra_items.append(EditorItem.parse_one(tok)) + elif tok_type is not Token.NEWLINE: + raise tok.error(tok_type) if first_item is None: raise ValueError( - '"{}:items/{}/editoritems.txt has no ' - '"Item" block!'.format(pak_id, fold) + f'"{pak_id}:items/{fold}/editoritems.txt has no ' + '"Item" block!' ) + try: + editor_vmf = VMF.parse(filesystem.read_prop(editor_path[:-3] + 'vmf')) + except FileNotFoundError: + pass + else: + editoritems_vmf.load(first_item, editor_vmf) + # extra_items is any extra blocks (offset catchers, extent items). # These must not have a palette section - it'll override any the user # chooses. @@ -805,12 +802,12 @@ def parse_item_folder( for subtype in extra_item.subtypes: if subtype.pal_pos is not None: LOGGER.warning( - '"{}:items/{}/editoritems.txt has palette set for extra' - ' item blocks. Deleting.'.format(pak_id, fold) + f'"{pak_id}:items/{fold}/editoritems.txt has ' + f'palette set for extra item blocks. Deleting.' ) subtype.pal_icon = subtype.pal_pos = subtype.pal_name = None - # In files this is specificed as PNG, but it's always really VTF. + # In files this is specified as PNG, but it's always really VTF. try: all_icon = FSPath(props['all_icon']).with_suffix('.vtf') except LookupError: @@ -823,7 +820,8 @@ def parse_item_folder( # Add the folder the item definition comes from, # so we can trace it later for debug messages. source=f'<{pak_id}>/items/{fold}', - vbsp_config=Property(None, []), + pak_id=pak_id, + vbsp_config=lazy_conf.BLANK, authors=sep_values(props['authors', '']), tags=sep_values(props['tags', '']), @@ -831,7 +829,11 @@ def parse_item_folder( ent_count=props['ent_count', ''], url=props['infoURL', None], icons={ - prop.name: img.Handle.parse(prop, pak_id, 64, 64, subfolder='items') + prop.name: img.Handle.parse( + prop, pak_id, + 64, 64, + subfolder='items', + ) for prop in props.find_children('icon') }, @@ -857,27 +859,23 @@ def parse_item_folder( id=pak_id, path=prop_path, ) - try: - with filesystem: - folders[fold].vbsp_config = conf = filesystem.read_prop( - config_path, - ) - except FileNotFoundError: - folders[fold].vbsp_config = conf = Property(None, []) - - set_cond_source(conf, folders[fold].source) + folders[fold].vbsp_config = lazy_conf.from_file( + utils.PackagePath(pak_id, config_path), + missing_ok=True, + source=folders[fold].source, + ) return folders -def apply_replacements(conf: Property) -> Property: +def apply_replacements(conf: Property, item_id: str) -> Property: """Apply a set of replacement values to a config file, returning a new copy. The replacements are found in a 'Replacements' block in the property. These replace %values% starting and ending with percents. A double-percent allows literal percents. Unassigned values are an error. """ - replace = {} - new_conf = Property(conf.real_name, []) + replace: dict[str, str] = {} + new_conf = Property.root() if conf.is_root() else Property(conf.real_name, []) # Strip the replacement blocks from the config, and save the values. for prop in conf: @@ -887,7 +885,7 @@ def apply_replacements(conf: Property) -> Property: else: new_conf.append(prop) - def rep_func(match: Match): + def rep_func(match: Match) -> str: """Does the replacement.""" var = match.group(1) if not var: # %% becomes %. @@ -895,7 +893,7 @@ def rep_func(match: Match): try: return replace[var.casefold()] except KeyError: - raise ValueError('Unresolved variable: {!r}\n{}'.format(var, replace)) + raise ValueError(f'Unresolved variable in "{item_id}": {var!r}\nValid vars: {replace}') for prop in new_conf.iter_tree(blocks=True): prop.name = RE_PERCENT_VAR.sub(rep_func, prop.real_name) @@ -905,9 +903,10 @@ def rep_func(match: Match): return new_conf -def assign_styled_items( +async def assign_styled_items( log_fallbacks: bool, log_missing_styles: bool, + item: Item, ) -> None: """Handle inheritance across item folders. @@ -922,136 +921,144 @@ def assign_styled_items( """ # To do inheritance, we simply copy the data to ensure all items # have data defined for every used style. - for item in Item.all(): - all_ver = list(item.versions.values()) - - # Move default version to the beginning, so it's read first. - # that ensures it's got all styles set if we need to fallback. - all_ver.remove(item.def_ver) - all_ver.insert(0, item.def_ver) - - for vers in all_ver: - # We need to repeatedly loop to handle the chains of - # dependencies. This is a list of (style_id, UnParsed). - to_change: List[Tuple[str, UnParsedItemVariant]] = [] - styles: Dict[str, Union[UnParsedItemVariant, ItemVariant, None]] = vers.styles - for sty_id, conf in styles.items(): - to_change.append((sty_id, conf)) - # Not done yet - styles[sty_id] = None - - # Evaluate style lookups and modifications - while to_change: - # Needs to be done next loop. - deferred = [] - # UnParsedItemVariant options: - # filesys: FileSystem # The original filesystem. - # folder: str # If set, use the given folder from our package. - # style: str # Inherit from a specific style (implies folder is None) - # config: Property # Config for editing - for sty_id, conf in to_change: - if conf.style: - try: - if ':' in conf.style: - ver_id, base_style_id = conf.style.split(':', 1) - start_data = item.versions[ver_id].styles[base_style_id] - else: - start_data = styles[conf.style] - except KeyError: - raise ValueError( - 'Item {}\'s {} style referenced ' - 'invalid style "{}"'.format( - item.id, - sty_id, - conf.style, - )) - if start_data is None: - # Not done yet! - deferred.append((sty_id, conf)) - continue - # Can't have both! - if conf.folder: - raise ValueError( - 'Item {}\'s {} style has both folder and' - ' style!'.format( - item.id, - sty_id, - )) - elif conf.folder: - # Just a folder ref, we can do it immediately. - # We know this dict should be set. - try: - start_data = item.folders[conf.filesys, conf.folder] - except KeyError: - LOGGER.info('Folders: {}', item.folders.keys()) - raise - else: - # No source for our data! + all_ver = list(item.versions.values()) + + # Move default version to the beginning, so it's read first. + # that ensures it's got all styles set if we need to fallback. + all_ver.remove(item.def_ver) + all_ver.insert(0, item.def_ver) + + for vers in all_ver: + # We need to repeatedly loop to handle the chains of + # dependencies. This is a list of (style_id, UnParsed). + to_change: list[tuple[str, UnParsedItemVariant]] = [] + styles: dict[str, UnParsedItemVariant | ItemVariant | None] = vers.styles + for sty_id, conf in styles.items(): + to_change.append((sty_id, conf)) + # Not done yet + styles[sty_id] = None + + # If we have multiple versions, mention them. + vers_desc = f' with version {vers.id}' if len(all_ver) > 1 else '' + + # Evaluate style lookups and modifications + while to_change: + # Needs to be done next loop. + deferred: list[tuple[str, UnParsedItemVariant]] = [] + # UnParsedItemVariant options: + # filesys: FileSystem # The original filesystem. + # folder: str # If set, use the given folder from our package. + # style: str # Inherit from a specific style (implies folder is None) + # config: Property # Config for editing + for sty_id, conf in to_change: + if conf.style: + try: + if ':' in conf.style: + ver_id, base_style_id = conf.style.split(':', 1) + start_data = item.versions[ver_id].styles[base_style_id] + else: + start_data = styles[conf.style] + except KeyError: raise ValueError( - 'Item {}\'s {} style has no data source!'.format( - item.id, - sty_id, - )) - - if conf.config is None: - styles[sty_id] = start_data.copy() - else: - styles[sty_id] = start_data.modify( - conf.filesys, - conf.pak_id, - conf.config, - '<{}:{}.{}>'.format(item.id, vers.id, sty_id), + f'Item {item.id}\'s {sty_id} style{vers_desc} ' + f'referenced invalid style "{conf.style}"' ) - - # If we defer all the styles, there must be a loop somewhere. - # We can't resolve that! - if len(deferred) == len(to_change): - raise ValueError( - 'Loop in style references!\nNot resolved:\n' + '\n'.join( - '{} -> {}'.format(conf.style, sty_id) - for sty_id, conf in deferred + if start_data is None: + # Not done yet! + deferred.append((sty_id, conf)) + continue + # Can't have both! + if conf.folder: + raise ValueError( + f'Item {item.id}\'s {sty_id} style has ' + f'both folder and style{vers_desc}!' ) + elif conf.folder: + # Just a folder ref, we can do it immediately. + # We know this dict should be set. + try: + start_data = item.folders[conf.filesys, conf.folder] + except KeyError: + LOGGER.info('Folders: {}', item.folders.keys()) + raise + else: + # No source for our data! + raise ValueError( + f"Item {item.id}'s {sty_id} style has no data " + f"source{vers_desc}!" ) - to_change = deferred - - # Fix this reference to point to the actual value. - vers.def_style = styles[vers.def_style] - - for style in Style.all(): - if style.id in styles: - continue # We already have a definition - for base_style in style.bases: - if base_style.id in styles: - # Copy the values for the parent to the child style - styles[style.id] = styles[base_style.id] - if log_fallbacks and not item.unstyled: - LOGGER.warning( - 'Item "{item}" using parent ' - '"{rep}" for "{style}"!', - item=item.id, - rep=base_style.id, - style=style.id, - ) - break + + if conf.config is None: + styles[sty_id] = start_data.copy() else: - # No parent matches! - if log_missing_styles and not item.unstyled: + styles[sty_id] = await start_data.modify( + conf.pak_id, + conf.config, + f'<{item.id}:{vers.id}.{sty_id}>', + ) + + # If we defer all the styles, there must be a loop somewhere. + # We can't resolve that! + if len(deferred) == len(to_change): + unresolved = '\n'.join( + f'{conf.style} -> {sty_id}' + for sty_id, conf in deferred + ) + raise ValueError( + f'Loop in style references for item {item.id}' + f'{vers_desc}!\nNot resolved:\n{unresolved}' + ) + to_change = deferred + + # Fix this reference to point to the actual value. + vers.def_style = styles[vers.def_style] + + if DEV_MODE.get(): + # Check each editoritem definition for some known issues. + for sty_id, variant in styles.items(): + assert isinstance(variant, ItemVariant), f'{item.id}:{sty_id} = {variant!r}!!' + with srctools.logger.context(f'{item.id}:{sty_id}'): + variant.editor.validate() + for extra in variant.editor_extra: + with srctools.logger.context(f'{item.id}:{sty_id} -> {extra.id}'): + extra.validate() + + for style in Style.all(): + if style.id in styles: + continue # We already have a definition + for base_style in style.bases: + if base_style.id in styles: + # Copy the values for the parent to the child style + styles[style.id] = styles[base_style.id] + if log_fallbacks and not item.unstyled: LOGGER.warning( - 'Item "{item}" using ' - 'inappropriate style for "{style}"!', + 'Item "{item}" using parent ' + '"{rep}" for "{style}"!', item=item.id, + rep=base_style.id, style=style.id, ) - - # If 'isolate versions' is set on the item, - # we never consult other versions for matching styles. - # There we just use our first style (Clean usually). - # The default version is always isolated. - # If not isolated, we get the version from the default - # version. Note the default one is computed first, - # so it's guaranteed to have a value. - styles[style.id] = ( - vers.def_style if - item.isolate_versions or vers.isolate - else item.def_ver.styles[style.id] + break + else: + # No parent matches! + if log_missing_styles and not item.unstyled: + LOGGER.warning( + 'Item "{0.id}"{2} using ' + 'inappropriate style for "{1.id}"!', + item, + style, + vers_desc, ) + + # If 'isolate versions' is set on the item, + # we never consult other versions for matching styles. + # There we just use our first style (Clean usually). + # The default version is always isolated. + # If not isolated, we get the version from the default + # version. Note the default one is computed first, + # so it's guaranteed to have a value. + styles[style.id] = ( + vers.def_style if + item.isolate_versions or vers.isolate + else item.def_ver.styles[style.id] + ) diff --git a/src/packages/music.py b/src/packages/music.py index ae496cfe6..10d2437d5 100644 --- a/src/packages/music.py +++ b/src/packages/music.py @@ -1,12 +1,13 @@ -from typing import Dict, List, Optional, FrozenSet +"""Definitions for background music used in the map.""" +from __future__ import annotations +from collections.abc import Iterable + from srctools import Property import srctools.logger from consts import MusicChannel -from packages import ( - PakObject, set_cond_source, ParseData, SelitemData, - get_config, ExportData, -) +from app import lazy_conf +from packages import PakObject, ParseData, SelitemData, get_config, ExportData LOGGER = srctools.logger.get_logger(__name__) @@ -18,20 +19,19 @@ class Music(PakObject): def __init__( self, music_id, - selitem_data: 'SelitemData', - sound: Dict[MusicChannel, List[str]], - children: Dict[MusicChannel, str], - config: Property=None, - inst=None, - sample: Dict[MusicChannel, Optional[str]]=None, - pack=(), - loop_len=0, - synch_tbeam=False, - ): + selitem_data: SelitemData, + sound: dict[MusicChannel, list[str]], + children: dict[MusicChannel, str], + config: lazy_conf.LazyConf = lazy_conf.BLANK, + inst: str | None = None, + sample: dict[MusicChannel, str | None] = None, + pack: Iterable[str] = (), + loop_len: int = 0, + synch_tbeam: bool = False, + ) -> None: self.id = music_id - self.config = config or Property(None, []) + self.config = config self.children = children - set_cond_source(config, 'Music <{}>'.format(music_id)) self.inst = inst self.sound = sound self.packfiles = list(pack) @@ -43,25 +43,18 @@ def __init__( self.has_synced_tbeam = synch_tbeam @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData): """Parse a music definition.""" selitem_data = SelitemData.parse(data.info, data.pak_id) inst = data.info['instance', None] - sound = data.info.find_key('soundscript', []) # type: Property + sound = data.info.find_key('soundscript', or_blank=True) if sound.has_children(): sounds = {} for channel in MusicChannel: sounds[channel] = channel_snd = [] for prop in sound.find_all(channel.value): - if prop.has_children(): - channel_snd += [ - subprop.value - for subprop in - prop - ] - else: - channel_snd.append(prop.value) + channel_snd.extend(prop.as_array()) synch_tbeam = sound.bool('sync_funnel') else: @@ -75,9 +68,9 @@ def parse(cls, data: ParseData): synch_tbeam = False # The sample music file to play, if found. - sample_block = data.info.find_key('sample', '') # type: Property + sample_block = data.info.find_key('sample', '') if sample_block.has_children(): - sample = {} # type: Dict[MusicChannel, Optional[str]] + sample: dict[MusicChannel, str | None] = {} for channel in MusicChannel: chan_sample = sample[channel] = sample_block[channel.value, ''] if chan_sample: @@ -97,9 +90,9 @@ def parse(cls, data: ParseData): else: sample[channel] = None else: - # Single value, fill it into all channels we define. + # Single value, fill it into all channels. sample = { - channel: sample_block.value if sounds[channel] else None + channel: sample_block.value for channel in MusicChannel } @@ -117,7 +110,7 @@ def parse(cls, data: ParseData): data.info.find_all('pack') ] - children_prop = data.info.find_key('children', []) + children_prop = data.info.find_block('children', or_blank=True) children = { channel: children_prop[channel.value, ''] for channel in MusicChannel @@ -126,9 +119,9 @@ def parse(cls, data: ParseData): config = get_config( data.info, - data.fsys, 'music', pak_id=data.pak_id, + source=f'Music <{data.id}>', ) return cls( data.id, @@ -143,15 +136,15 @@ def parse(cls, data: ParseData): synch_tbeam=synch_tbeam, ) - def add_over(self, override: 'Music'): + def add_over(self, override: 'Music') -> None: """Add the additional vbsp_config commands to ourselves.""" - self.config.append(override.config) + self.config = lazy_conf.concat(self.config, override.config) self.selitem_data += override.selitem_data - def __repr__(self): + def __repr__(self) -> str: return '' - def provides_channel(self, channel: MusicChannel): + def provides_channel(self, channel: MusicChannel) -> bool: """Check if this music has this channel.""" if self.sound[channel]: return True @@ -160,7 +153,7 @@ def provides_channel(self, channel: MusicChannel): return True return False - def has_channel(self, channel: MusicChannel): + def has_channel(self, channel: MusicChannel) -> bool: """Check if this track or its children has a channel.""" if self.sound[channel]: return True @@ -171,9 +164,9 @@ def has_channel(self, channel: MusicChannel): children = Music.by_id(self.children[channel]) except KeyError: return False - return children.sound[channel] + return bool(children.sound[channel]) - def get_attrs(self) -> Dict[str, bool]: + def get_attrs(self) -> dict[str, bool]: """Generate attributes for SelectorWin.""" attrs = { channel.name: self.has_channel(channel) @@ -183,7 +176,7 @@ def get_attrs(self) -> Dict[str, bool]: attrs['TBEAM_SYNC'] = self.has_synced_tbeam return attrs - def get_suggestion(self, channel: MusicChannel): + def get_suggestion(self, channel: MusicChannel) -> str | None: """Get the ID we want to suggest for a channel.""" try: child = Music.by_id(self.children[channel]) @@ -193,7 +186,7 @@ def get_suggestion(self, channel: MusicChannel): return child.id return None - def get_sample(self, channel: MusicChannel) -> Optional[str]: + def get_sample(self, channel: MusicChannel) -> str | None: """Get the path to the sample file, if present.""" if self.sample[channel]: return self.sample[channel] @@ -206,14 +199,14 @@ def get_sample(self, channel: MusicChannel) -> Optional[str]: @staticmethod def export(exp_data: ExportData): """Export the selected music.""" - selected = exp_data.selected # type: Dict[MusicChannel, Optional[Music]] + selected: dict[MusicChannel, Music | None] = exp_data.selected base_music = selected[MusicChannel.BASE] vbsp_config = exp_data.vbsp_conf if base_music is not None: - vbsp_config += base_music.config.copy() + vbsp_config += base_music.config() music_conf = Property('MusicScript', []) vbsp_config.append(music_conf) @@ -234,6 +227,14 @@ def export(exp_data: ExportData): to_pack.update(music.packfiles) + # If we need to pack, add the files to be unconditionally + # packed. + if to_pack: + music_conf.append(Property('pack', [ + Property('file', filename) + for filename in to_pack + ])) + if base_music is not None: vbsp_config.set_key( ('Options', 'music_looplen'), @@ -249,24 +250,13 @@ def export(exp_data: ExportData): base_music.inst or '', ) - # If we need to pack, add the files to be unconditionally - # packed. - if to_pack: - vbsp_config.set_key( - ('PackTriggers', 'Forced'), - [ - Property('File', file) - for file in to_pack - ], - ) - @classmethod def post_parse(cls) -> None: """Check children of each music item actually exist. This must be done after they all were parsed. """ - sounds: Dict[FrozenSet[str], str] = {} + sounds: dict[frozenset[str], str] = {} for music in cls.all(): for channel in MusicChannel: @@ -274,7 +264,7 @@ def post_parse(cls) -> None: child_id = music.children.get(channel, '') if child_id: try: - child = cls.by_id(child_id) + cls.by_id(child_id) except KeyError: LOGGER.warning( 'Music "{}" refers to nonexistent' diff --git a/src/packages/pack_list.py b/src/packages/pack_list.py index 8f62678a9..d0999b4a3 100644 --- a/src/packages/pack_list.py +++ b/src/packages/pack_list.py @@ -16,7 +16,7 @@ def __init__(self, pak_id: str, files: List[str]) -> None: self.files = files @classmethod - def parse(cls, data: ParseData) -> 'PackList': + async def parse(cls, data: ParseData) -> 'PackList': """Read pack lists from packages.""" filesystem = data.fsys conf = data.info.find_key('Config', '') @@ -39,7 +39,7 @@ def parse(cls, data: ParseData) -> 'PackList': ] elif conf.value: path = 'pack/' + conf.value + '.cfg' - with filesystem, filesystem.open_str(path) as f: + with filesystem.open_str(path) as f: # Each line is a file to pack. # Skip blank lines, strip whitespace, and # allow // comments. @@ -115,4 +115,4 @@ def export(exp_data: ExportData) -> None: LOGGER.info('Writing packing list!') with open(exp_data.game.abs_path('bin/bee2/pack_list.cfg'), 'w') as pack_file: for line in pack_block.export(): - pack_file.write(line) \ No newline at end of file + pack_file.write(line) diff --git a/src/packages/quote_pack.py b/src/packages/quote_pack.py index bf355ae56..bc78aee8f 100644 --- a/src/packages/quote_pack.py +++ b/src/packages/quote_pack.py @@ -42,7 +42,7 @@ def __init__( self.turret_hate = turret_hate @classmethod - def parse(cls, data: ParseData) -> 'QuotePack': + async def parse(cls, data: ParseData) -> 'QuotePack': """Parse a voice line definition.""" selitem_data = SelitemData.parse(data.info, data.pak_id) chars = { @@ -73,11 +73,10 @@ def parse(cls, data: ParseData) -> 'QuotePack': config = get_config( data.info, - data.fsys, 'voice', pak_id=data.pak_id, prop_name='file', - ) + )() return cls( data.id, @@ -233,4 +232,4 @@ def iter_lines(conf: Property) -> Iterator[Property]: 'Quote Pack "{}" has duplicate ' 'voice ID "{}"!', voice.id, quote_id, ) - used.add(quote_id) \ No newline at end of file + used.add(quote_id) diff --git a/src/packages/signage.py b/src/packages/signage.py index 5602c6862..a7a518f70 100644 --- a/src/packages/signage.py +++ b/src/packages/signage.py @@ -38,7 +38,7 @@ class SignageLegend(PakObject): The background texture if specified is added to the upper-left of the image. It is useful to provide a backing, or to fill in unset signages. If provided, the blank image is inserted instead of unset signage. - + Finally the overlay is composited on top, to allow setting the unwrapped model parts. """ @@ -55,7 +55,7 @@ def __init__( self.blank = blank @classmethod - def parse(cls, data: ParseData) -> 'SignageLegend': + async def parse(cls, data: ParseData) -> 'SignageLegend': if 'blank' in data.info: blank = ImgHandle.parse(data.info, data.pak_id, CELL_SIZE, CELL_SIZE, subkey='blank') else: @@ -98,7 +98,7 @@ def __init__( self.dnd_icon = None @classmethod - def parse(cls, data: ParseData) -> Signage: + async def parse(cls, data: ParseData) -> Signage: styles: dict[str, SignStyle] = {} for prop in data.info.find_children('styles'): sty_id = prop.name.upper() @@ -291,7 +291,16 @@ def build_texture( vtf.get().copy_from(legend.tobytes(), ImageFormats.RGBA8888) vtf.clear_mipmaps() vtf.flags |= vtf.flags.ANISOTROPIC - with BytesIO() as buf: + + buf = BytesIO() + try: + vtf.save(buf) + except NotImplementedError: + LOGGER.warning('No DXT compressor, using BGRA8888.') + # No libsquish, so DXT compression doesn't work. + vtf.format = vtf.low_format = ImageFormats.BGRA4444 + + buf = BytesIO() vtf.save(buf) - return buf.getvalue() + return buf.getvalue() diff --git a/src/packages/skybox.py b/src/packages/skybox.py index 9d90456dc..4e74a6e5a 100644 --- a/src/packages/skybox.py +++ b/src/packages/skybox.py @@ -1,7 +1,7 @@ from srctools import Property from packages import ( PakObject, ExportData, ParseData, SelitemData, - get_config, set_cond_source, + get_config, lazy_conf ) @@ -11,7 +11,7 @@ def __init__( self, sky_id, selitem_data: SelitemData, - config: Property, + config: lazy_conf.LazyConf, fog_opts: Property, mat, ) -> None: @@ -19,25 +19,24 @@ def __init__( self.selitem_data = selitem_data self.material = mat self.config = config - set_cond_source(config, 'Skybox <{}>'.format(sky_id)) self.fog_opts = fog_opts # Extract this for selector windows to easily display self.fog_color = fog_opts.vec('primarycolor', 255, 255, 255) @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData): """Parse a skybox definition.""" selitem_data = SelitemData.parse(data.info, data.pak_id) mat = data.info['material', 'sky_black'] config = get_config( data.info, - data.fsys, 'skybox', pak_id=data.pak_id, + source=f'Skybox <{data.id}>', ) - fog_opts = data.info.find_key("Fog", []) + fog_opts = data.info.find_key("Fog", or_blank=True) return cls( data.id, @@ -50,7 +49,7 @@ def parse(cls, data: ParseData): def add_over(self, override: 'Skybox'): """Add the additional vbsp_config commands to ourselves.""" self.selitem_data += override.selitem_data - self.config += override.config + self.config = lazy_conf.concat(self.config, override.config) self.fog_opts += override.fog_opts.copy() def __repr__(self) -> str: @@ -74,7 +73,7 @@ def export(exp_data: ExportData): skybox.material, ) - exp_data.vbsp_conf.append(skybox.config.copy()) + exp_data.vbsp_conf.extend(skybox.config()) # Styles or other items shouldn't be able to set fog settings.. if 'fog' in exp_data.vbsp_conf: diff --git a/src/packages/style_vpk.py b/src/packages/style_vpk.py index 64eb55cd5..e293d546e 100644 --- a/src/packages/style_vpk.py +++ b/src/packages/style_vpk.py @@ -26,7 +26,7 @@ def __init__(self, vpk_id, filesys: FileSystem, directory: str) -> None: self.dir = directory @classmethod - def parse(cls, data: ParseData): + async def parse(cls, data: ParseData): """Read the VPK file from the package.""" vpk_name = data.info['filename'] diff --git a/src/packages/stylevar.py b/src/packages/stylevar.py index 198874bf1..78020a58a 100644 --- a/src/packages/stylevar.py +++ b/src/packages/stylevar.py @@ -1,37 +1,45 @@ -from typing import List +"""Style specific features which can be enabled or disabled.""" +from __future__ import annotations -import srctools from packages import PakObject, Style, ParseData, ExportData -from srctools import Property +from srctools import Property, bool_as_int class StyleVar(PakObject, allow_mult=True): + """Style specific features which can be enabled or disabled.""" def __init__( self, var_id: str, name: str, - styles: List[str], + styles: list[str], unstyled: bool=False, + inherit: bool=True, default: bool=False, desc: str='', - ): + ) -> None: self.id = var_id self.name = name self.default = default self.enabled = default self.desc = desc - if unstyled: - self.styles = None - else: - self.styles = styles + self.inherit = inherit + self.styles = None if unstyled else styles + + @classmethod + def unstyled(cls, id: str, name: str, default: bool, desc: str) -> StyleVar: + """For builtin variables, define it as fully unstyled.""" + return cls(id, name, [], True, False, default, desc) + + @property + def is_unstyled(self) -> bool: + """check if the variable is unstyled.""" + return self.styles is None @classmethod - def parse(cls, data: 'ParseData') -> 'StyleVar': + async def parse(cls, data: ParseData) -> StyleVar: """Parse StyleVars from configs.""" name = data.info['name', ''] - unstyled = data.info.bool('unstyled') - default = data.info.bool('enabled') styles = [ prop.value for prop in @@ -46,12 +54,13 @@ def parse(cls, data: 'ParseData') -> 'StyleVar': data.id, name, styles, - unstyled=unstyled, - default=default, + unstyled=data.info.bool('unstyled'), + inherit=data.info.bool('inherit', True), + default=data.info.bool('enabled'), desc=desc, ) - def add_over(self, override: 'StyleVar') -> None: + def add_over(self, override: StyleVar) -> None: """Override a stylevar to add more compatible styles.""" # Setting it to be unstyled overrides any other values! if self.styles is None: @@ -75,25 +84,23 @@ def add_over(self, override: 'StyleVar') -> None: self.desc = override.desc def __repr__(self) -> str: - return ':\n{}'.format( - self.id, - self.name, - self.default, - self.styles, - self.desc, + return ( + f':\n{self.desc}' ) def applies_to_style(self, style: Style) -> bool: """Check to see if this will apply for the given style. """ - if self.styles is None: - return True # Unstyled stylevar + if self.is_unstyled: + return True if style.id in self.styles: return True - return any( + return self.inherit and any( base.id in self.styles for base in style.bases @@ -101,7 +108,7 @@ def applies_to_style(self, style: Style) -> bool: def applies_to_all(self) -> bool: """Check if this applies to all styles.""" - if self.styles is None: + if self.is_unstyled: return True for style in Style.all(): @@ -116,9 +123,8 @@ def export(exp_data: ExportData) -> None: The .selected attribute is a dict mapping ids to the boolean value. """ # Add the StyleVars block, containing each style_var. - exp_data.vbsp_conf.append(Property('StyleVars', [ - Property(key, srctools.bool_as_int(val)) + Property(key, bool_as_int(val)) for key, val in exp_data.selected.items() ])) diff --git a/src/packages/template_brush.py b/src/packages/template_brush.py index eef98689c..5e8e283f2 100644 --- a/src/packages/template_brush.py +++ b/src/packages/template_brush.py @@ -1,10 +1,12 @@ """Implements the parsing required for the app to identify all templates.""" from __future__ import annotations + +import trio from atomicwrites import atomic_write import os from srctools import VMF, Property, KeyValError -from srctools.filesys import File, RawFileSystem, ZipFileSystem, VPKFileSystem +from srctools.filesys import File from srctools.dmx import Element as DMXElement, ValueType as DMXValue, Attribute as DMXAttr import srctools.logger @@ -16,16 +18,17 @@ TEMPLATES: dict[str, PackagePath] = {} -def parse_template(pak_id: str, file: File) -> None: +async def parse_template(pak_id: str, file: File) -> None: """Parse the specified template file, extracting its ID.""" path = f'{pak_id}:{file.path}' - temp_id = parse_template_fast(file, path) + temp_id = await trio.to_thread.run_sync(parse_template_fast, file, path, cancellable=True) if not temp_id: LOGGER.warning('Fast-parse failure on {}!', path) with file.open_str() as f: - props = Property.parse(f) - conf_ents = VMF.parse(props).by_class['bee2_template_conf'] + props = await trio.to_thread.run_sync(Property.parse, f, cancellable=True) + vmf = await trio.to_thread.run_sync(VMF.parse, props, cancellable=True) del props + conf_ents = list(vmf.by_class['bee2_template_conf']) if len(conf_ents) > 1: raise KeyValError(f'Multiple configuration entities in template!', path, None) elif not conf_ents: diff --git a/src/packages_sync.py b/src/packages_sync.py index b3b327e0f..56f03e838 100644 --- a/src/packages_sync.py +++ b/src/packages_sync.py @@ -8,7 +8,6 @@ The destination will be 'Portal 2/bee2_dev/' if that exists, or 'Portal 2/bee2/' otherwise. """ - import utils import gettext from srctools.filesys import RawFileSystem @@ -25,12 +24,13 @@ import os import sys import logging +import shutil from pathlib import Path from typing import List, Optional -import shutil +import trio -from BEE2_config import GEN_OPTS +from BEE2_config import GEN_OPTS, get_package_locs from packages import ( packages as PACKAGES, find_packages, @@ -41,9 +41,6 @@ PACKAGE_REPEAT: Optional[RawFileSystem] = None SKIPPED_FILES: List[str] = [] -# If enabled, ignore anything not in packages and that needs prompting. -NO_PROMPT = False - def get_package(file: Path) -> RawFileSystem: """Get the package desired for a file.""" @@ -68,10 +65,14 @@ def get_package(file: Path) -> RawFileSystem: if pack_id == '*' and last_package: try: - PACKAGE_REPEAT = PACKAGES[last_package].fsys + fsys = PACKAGES[last_package].fsys except KeyError: continue - return PACKAGE_REPEAT + if isinstance(fsys, RawFileSystem): + PACKAGE_REPEAT = fsys + return PACKAGE_REPEAT + else: + print('Packages must be folders, not zips!') elif not pack_id and last_package: pack_id = last_package @@ -79,10 +80,12 @@ def get_package(file: Path) -> RawFileSystem: fsys = PACKAGES[pack_id.casefold()].fsys except KeyError: continue - else: + if isinstance(fsys, RawFileSystem): GEN_OPTS['Last_Selected']['Package_sync_id'] = pack_id GEN_OPTS.save_check() return fsys + else: + print('Packages must be folders, not zips!') def check_file(file: Path, portal2: Path, packages: Path) -> None: @@ -101,7 +104,7 @@ def check_file(file: Path, portal2: Path, packages: Path) -> None: part = relative.parts try: res_path = Path(*part[part.index('resources')+1:]) - except IndexError: + except ValueError: LOGGER.warning('File "{!s} not a resource!', file) return @@ -143,9 +146,6 @@ def check_file(file: Path, portal2: Path, packages: Path) -> None: target_systems.append(package.fsys) if not target_systems: - if NO_PROMPT: - EXTRA_FILES.append(rel_loc) - return # This file is totally new. try: target_systems.append(get_package(rel_loc)) @@ -170,7 +170,7 @@ def print_package_ids() -> None: print() -def main(files: List[str]) -> int: +async def main(files: List[str]) -> int: """Run the transfer.""" if not files: LOGGER.error('No files to copy!') @@ -193,7 +193,9 @@ def main(files: List[str]) -> int: # Disable logging of package info. packages_logger.setLevel(logging.ERROR) - find_packages(GEN_OPTS['Directories']['package']) + async with trio.open_nursery() as nursery: + for loc in get_package_locs(): + await find_packages(nursery, loc) packages_logger.setLevel(logging.INFO) LOGGER.info('Done!') @@ -202,12 +204,12 @@ def main(files: List[str]) -> int: package_loc = Path('../', GEN_OPTS['Directories']['package']).resolve() - file_list = [] # type: List[Path] + file_list: list[Path] = [] for file in files: file_path = Path(file) if file_path.is_dir(): - for sub_file in file_path.glob('**/*'): # type: Path + for sub_file in file_path.glob('**/*'): if sub_file.is_file(): file_list.append(sub_file) else: @@ -216,7 +218,7 @@ def main(files: List[str]) -> int: files_to_check = set() for file_path in file_list: - if file_path.suffix.casefold() in ('.vmx', '.log', '.bsp', '.prt', '.lin'): + if file_path.suffix.casefold() in {'.vmx', '.log', '.bsp', '.prt', '.lin'}: # Ignore these file types. continue files_to_check.add(file_path) @@ -241,4 +243,4 @@ def main(files: List[str]) -> int: if __name__ == '__main__': LOGGER.info('BEE{} packages syncer, args={}', utils.BEE_VERSION, sys.argv[1:]) - sys.exit(main(sys.argv[1:])) + sys.exit(trio.run(main, sys.argv[1:])) diff --git a/src/perlin.py b/src/perlin.py index c633e02fb..7a00a1c80 100644 --- a/src/perlin.py +++ b/src/perlin.py @@ -1,4 +1,5 @@ # Copied from https://github.com/caseman/noise (pure-python portions only). +# type: ignore """Perlin noise -- pure python implementation. Copyright (c) 2008, Casey Duncan (casey dot duncan at gmail dot com) @@ -356,7 +357,3 @@ def noise3(self, x, y, z, repeat, base=0.0): grad3(perm[BA + kk], x - 1, y, z - 1)), lerp(fx, grad3(perm[AB + kk], x, y - 1, z - 1), grad3(perm[BB + kk], x - 1, y - 1, z - 1)))) - - - - diff --git a/src/precomp/antlines.py b/src/precomp/antlines.py index 27ea0a8d0..d24136a4a 100644 --- a/src/precomp/antlines.py +++ b/src/precomp/antlines.py @@ -1,14 +1,17 @@ """Manages parsing and regenerating antlines.""" -import random -from collections import namedtuple - -from srctools import Vec, Property, conv_float, VMF, logger -from srctools.vmf import overlay_bounds, make_overlay -import consts +from __future__ import annotations from collections import defaultdict -from typing import List, Dict, Tuple, TYPE_CHECKING, Iterator, Optional, Set - +from collections.abc import Iterator from enum import Enum +import math + +import attr + +from srctools import Vec, Angle, Matrix, Property, conv_float, logger +from srctools.vmf import VMF, overlay_bounds, make_overlay + +from precomp import tiling, rand +import consts LOGGER = logger.get_logger(__name__) @@ -20,8 +23,13 @@ class SegType(Enum): CORNER = 1 -class AntTex(namedtuple('AntTex', ['texture', 'scale', 'static'])): +@attr.define +class AntTex: """Represents a single texture, and the parameters it has.""" + texture: str + scale: float + static: bool + @staticmethod def parse(prop: Property): """Parse from property values. @@ -59,6 +67,7 @@ def parse(prop: Property): return AntTex(tex, scale, static) +@attr.define(eq=False) class AntType: """Defines the style of antline to use. @@ -67,43 +76,43 @@ class AntType: Corners can be omitted, if corner/straight antlines are the same. """ - def __init__( - self, - tex_straight: List[AntTex], - tex_corner: List[AntTex], - broken_straight: List[AntTex], - broken_corner: List[AntTex], - broken_chance: float, - ) -> None: - self.tex_straight = tex_straight - self.tex_corner = tex_corner - - if broken_chance == 0: - broken_corner: List[AntTex] = [] - broken_straight: List[AntTex] = [] - - # Cannot have broken corners if corners/straights are the same. - if not tex_corner: - broken_corner: List[AntTex] = [] + tex_straight: list[AntTex] = attr.ib() + tex_corner: list[AntTex] = attr.ib() - self.broken_corner = broken_corner - self.broken_straight = broken_straight - self.broken_chance = broken_chance + broken_straight: list[AntTex] = attr.ib() + broken_corner: list[AntTex] = attr.ib() + broken_chance: float @classmethod - def parse(cls, prop: Property) -> 'AntType': + def parse(cls, prop: Property) -> AntType: """Parse this from a property block.""" broken_chance = prop.float('broken_chance') - tex_straight: List[AntTex] = [] - tex_corner: List[AntTex] = [] - brok_straight: List[AntTex] = [] - brok_corner: List[AntTex] = [] + tex_straight: list[AntTex] = [] + tex_corner: list[AntTex] = [] + brok_straight: list[AntTex] = [] + brok_corner: list[AntTex] = [] for ant_list, name in zip( [tex_straight, tex_corner, brok_straight, brok_corner], ('straight', 'corner', 'broken_straight', 'broken_corner'), ): for sub_prop in prop.find_all(name): ant_list.append(AntTex.parse(sub_prop)) + + if broken_chance < 0.0: + LOGGER.warning('Antline broken chance must be between 0-100, got "{}"!', prop['broken_chance']) + broken_chance = 0.0 + if broken_chance > 100.0: + LOGGER.warning('Antline broken chance must be between 0-100, got "{}"!', prop['broken_chance']) + broken_chance = 100.0 + + if broken_chance == 0.0: + brok_straight.clear() + brok_corner.clear() + + # Cannot have broken corners if corners/straights are the same. + if not tex_corner: + brok_corner.clear() + return cls( tex_straight, tex_corner, @@ -113,64 +122,56 @@ def parse(cls, prop: Property) -> 'AntType': ) @classmethod - def default(cls) -> 'AntType': + def default(cls) -> AntType: """Make a copy of the original PeTI antline config.""" - return cls( + return AntType( [AntTex(consts.Antlines.STRAIGHT, 0.25, False)], [AntTex(consts.Antlines.CORNER, 1, False)], [], [], 0, ) +@attr.define(eq=False) class Segment: """A single section of an antline - a straight section or corner. For corners, start == end. """ - __slots__ = ['type', 'normal', 'start', 'end', 'tiles'] - - def __init__( - self, - typ: SegType, - normal: Vec, - start: Vec, - end: Vec, - ): - self.type = typ - self.normal = normal - # Note, start is end for corners. - self.start = start - self.end = end - # The brushes this segment is attached to. - self.tiles = set() # type: Set['TileDef'] + type: SegType + normal: Vec + start: Vec + end: Vec + # The brushes this segment is attached to. + tiles: set[tiling.TileDef] = attr.ib(factory=set) @property def on_floor(self) -> bool: """Return if this segment is on the floor/wall.""" - return self.normal.z != 0 + return abs(self.normal.z) > 1e-6 def broken_iter( self, chance: float, - ) -> Iterator[Tuple[Vec, Vec, bool]]: + ) -> Iterator[tuple[Vec, Vec, bool]]: """Iterator to compute positions for straight segments. This produces point pairs which fill the space from 0-dist. Neighbouring sections will be merged when they have the same type. """ + rng = rand.seed(b'ant_broken', self.start, self.end, chance) offset = self.end - self.start dist = offset.mag() // 16 norm = 16 * offset.norm() if dist < 3 or chance == 0: # Short antlines always are either on/off. - yield self.start, self.end, (random.randrange(100) < chance) + yield self.start, self.end, (rng.randrange(100) < chance) else: run_start = self.start - last_type = random.randrange(100) < chance + last_type = rng.randrange(100) < chance for i in range(1, int(dist)): - next_type = random.randrange(100) < chance + next_type = rng.randrange(100) < chance if next_type != last_type: yield run_start, self.start + i * norm, last_type last_type = next_type @@ -178,25 +179,22 @@ def broken_iter( yield run_start, self.end, last_type +@attr.define(eq=False) class Antline: """A complete antline.""" - def __init__( - self, - name: str, - line: List[Segment], - ): - self.line = line - self.name = name + name: str + line: list[Segment] - def export(self, vmf: VMF, wall_conf: AntType, floor_conf: AntType) -> None: + def export(self, vmf: VMF, *, wall_conf: AntType, floor_conf: AntType) -> None: """Add the antlines into the map.""" # First, do some optimisation. If corners aren't defined, try and # optimise those antlines out by merging the straight segment # before/after it into the corners. + collapse_line: list[Segment | None] if not wall_conf.tex_corner or not floor_conf.tex_corner: - collapse_line = list(self.line) # type: List[Optional[Segment]] + collapse_line = list(self.line) for i, seg in enumerate(collapse_line): if seg is None or seg.type is not SegType.STRAIGHT: continue @@ -238,30 +236,50 @@ def export(self, vmf: VMF, wall_conf: AntType, floor_conf: AntType) -> None: for seg in self.line: conf = floor_conf if seg.on_floor else wall_conf - random.seed('ant {} {}'.format(seg.start, seg.end)) + # Check tiledefs in the voxels, and assign just in case. + # antline corner items don't have them defined, and some embedfaces don't work + # properly. But we keep any segments actually defined also. + mins, maxs = Vec.bbox(seg.start, seg.end) + norm_axis = seg.normal.axis() + u_axis, v_axis = Vec.INV_AXIS[norm_axis] + for pos in Vec.iter_line(mins, maxs, 128): + pos[u_axis] = pos[u_axis] // 128 * 128 + 64 + pos[v_axis] = pos[v_axis] // 128 * 128 + 64 + pos -= 64 * seg.normal + try: + tile = tiling.TILES[pos.as_tuple(), seg.normal.as_tuple()] + except KeyError: + pass + else: + seg.tiles.add(tile) + + rng = rand.seed(b'antline', seg.start, seg.end) if seg.type is SegType.CORNER: mat: AntTex - if random.randrange(100) < conf.broken_chance: - mat = random.choice(conf.broken_corner or conf.broken_straight) + if rng.randrange(100) < conf.broken_chance: + mat = rng.choice(conf.broken_corner or conf.broken_straight) else: - mat = random.choice(conf.tex_corner or conf.tex_straight) + mat = rng.choice(conf.tex_corner or conf.tex_straight) - axis_u, axis_v = Vec.INV_AXIS[seg.normal.axis()] + # Because we can, apply a random rotation to mix up the texture. + orient = Matrix.from_angle(seg.normal.to_angle( + rng.choice((0.0, 90.0, 180.0, 270.0)) + )) self._make_overlay( vmf, seg, seg.start, - Vec.with_axes(axis_u, 16), - 16 * seg.normal.cross(Vec.with_axes(axis_u, 1)), + 16.0 * orient.left(), + 16.0 * orient.up(), mat, ) else: # Straight # TODO: Break up these segments. for a, b, is_broken in seg.broken_iter(conf.broken_chance): if is_broken: - mat = random.choice(conf.broken_straight) + mat = rng.choice(conf.broken_straight) else: - mat = random.choice(conf.tex_straight) + mat = rng.choice(conf.tex_straight) self._make_straight( vmf, seg, @@ -324,9 +342,9 @@ def _make_straight( ) -def parse_antlines(vmf: VMF) -> Tuple[ - Dict[str, List[Antline]], - Dict[int, List[Segment]] +def parse_antlines(vmf: VMF) -> tuple[ + dict[str, list[Antline]], + dict[int, list[Segment]] ]: """Convert overlays in the map into Antline objects. @@ -339,26 +357,26 @@ def parse_antlines(vmf: VMF) -> Tuple[ LOGGER.info('Parsing antlines...') # segment -> found neighbours of it. - overlay_joins = defaultdict(set) # type: Dict[Segment, Set[Segment]] + overlay_joins: defaultdict[Segment, set[Segment]] = defaultdict(set) - segment_to_name = {} # type: Dict[Segment, str] + segment_to_name: dict[Segment, str] = {} # Points on antlines where two can connect. For corners that's each side, # for straight it's each end. Combine that with the targetname # so we only join related antlines. - join_points = {} # type: Dict[Tuple[str, float, float, float], Segment] + join_points: dict[tuple[str, float, float, float], Segment] = {} mat_straight = consts.Antlines.STRAIGHT mat_corner = consts.Antlines.CORNER - side_to_seg = {} # type: Dict[int, List[Segment]] - antlines = {} # type: Dict[str, List[Antline]] + side_to_seg: dict[int, list[Segment]] = {} + antlines: dict[str, list[Antline]] = {} for over in vmf.by_class['info_overlay']: mat = over['material'] origin = Vec.from_str(over['basisorigin']) normal = Vec.from_str(over['basisnormal']) - u, v = Vec.INV_AXIS[normal.axis()] + orient = Matrix.from_angle(Angle.from_str(over['angles'])) if mat == mat_corner: seg_type = SegType.CORNER @@ -366,27 +384,27 @@ def parse_antlines(vmf: VMF) -> Tuple[ # One on each side - we know the size. points = [ - origin + Vec.with_axes(u, +8), - origin + Vec.with_axes(u, -8), - origin + Vec.with_axes(v, +8), - origin + Vec.with_axes(v, -8), + origin + orient.left(-8.0), + origin + orient.left(+8.0), + origin + orient.forward(-8.0), + origin + orient.forward(+8.0), ] elif mat == mat_straight: seg_type = SegType.STRAIGHT # We want to determine the length first. - long_axis = Vec(y=1).rotate_by_str(over['angles']).axis() - side_axis = Vec(x=1).rotate_by_str(over['angles']).axis() + long_axis = orient.left() + side_axis = orient.forward() # The order of these isn't correct, but we need the neighbours to # fix that. start, end = overlay_bounds(over) # For whatever reason, Valve sometimes generates antlines which are # shortened by 1 unit. So snap those to grid. - start = round(start / 16) * 16 - end = round(end / 16) * 16 + start = round(start / 16, 0) * 16 + end = round(end / 16, 0) * 16 - if end[long_axis] - start[long_axis] == 16: + if math.isclose(Vec.dot(end - start, long_axis), 16.0): # Special case. # 1-wide antlines don't have the correct # rotation, pointing always in the U axis. @@ -395,7 +413,7 @@ def parse_antlines(vmf: VMF) -> Tuple[ start = end = origin points = [] else: - offset = Vec.with_axes(side_axis, 8) + offset: Vec = round(abs(8 * side_axis), 0) start += offset end -= offset @@ -414,7 +432,7 @@ def parse_antlines(vmf: VMF) -> Tuple[ # Lookup the point to see if we've already checked it. # If not, write us into that spot. neighbour = join_points.setdefault( - (over_name, point.x, point.y, point.z), + (over_name, ) + point.as_tuple(), seg, ) if neighbour is seg: @@ -432,16 +450,16 @@ def parse_antlines(vmf: VMF) -> Tuple[ fix_single_straight(seg, over_name, join_points, overlay_joins) # Now, finally compute each continuous section. - for segment, over_name in segment_to_name.items(): + for start_seg, over_name in segment_to_name.items(): try: - neighbours = overlay_joins[segment] + neighbours = overlay_joins[start_seg] except KeyError: continue # Done already. if len(neighbours) != 1: continue # Found a start point! - segments = [segment] + segments = [start_seg] for segment in segments: neighbours = overlay_joins.pop(segment) @@ -459,25 +477,24 @@ def parse_antlines(vmf: VMF) -> Tuple[ def fix_single_straight( seg: Segment, over_name: str, - join_points: Dict[Tuple[str, float, float, float], Segment], - overlay_joins: Dict[Segment, Set[Segment]], + join_points: dict[tuple[str, float, float, float], Segment], + overlay_joins: dict[Segment, set[Segment]], ) -> None: """Figure out the correct rotation for 1-long straight antlines.""" # Check the U and V axis, to see if there's another antline on both # sides. If there is that's the correct orientation. - axis_u, axis_v = Vec.INV_AXIS[seg.normal.axis()] + orient = Matrix.from_angle(seg.normal.to_angle()) center = seg.start.copy() for off in [ - Vec.with_axes(axis_u, -8), - Vec.with_axes(axis_u, +8), - Vec.with_axes(axis_v, -8), - Vec.with_axes(axis_v, +8), + orient.left(-8.0), + orient.left(+8.0), + orient.up(-8.0), + orient.up(+8.0), ]: - pos = center + off try: - neigh = join_points[over_name, pos.x, pos.y, pos.z] + neigh = join_points[(over_name, ) + (center + off).as_tuple()] except KeyError: continue @@ -497,8 +514,7 @@ def fix_single_straight( elif seg.start != off_min or seg.end != off_max: # The other side is also present. Only override if we are on both # sides. - opposite = center - off - if (over_name, opposite.x, opposite.y, opposite.z) in join_points: + if (over_name, ) + (center - off).as_tuple() in join_points: seg.start = off_min seg.end = off_max # Else: Both equal, we're fine. diff --git a/src/precomp/barriers.py b/src/precomp/barriers.py index 424fdded6..d5709cb5a 100644 --- a/src/precomp/barriers.py +++ b/src/precomp/barriers.py @@ -11,7 +11,7 @@ import srctools.logger from precomp.conditions import make_result from precomp.grid_optim import optimise as grid_optimise -from precomp.instanceLocs import resolve_one +from precomp.instanceLocs import resolve_one, resolve from srctools import VMF, Vec, Solid, Property, Entity, Angle, Matrix @@ -43,14 +43,19 @@ class HoleType(Enum): ] = {} -def get_pos_norm(origin: Vec): +def get_pos_norm(origin: Vec) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: """From the origin, get the grid position and normal.""" grid_pos = origin // 128 * 128 + (64, 64, 64) return grid_pos.as_tuple(), (origin - grid_pos).norm().as_tuple() def parse_map(vmf: VMF, has_attr: Dict[str, bool]) -> None: - """Remove instances from the map, and store off the positions.""" + """Find all glass/grating in the map. + + This removes the per-tile instances, and all original brushwork. + The frames are updated with a fixup var, as appropriate. + """ + frame_inst = resolve('[glass_frames]', silent=True) glass_inst = resolve_one('[glass_128]') pos = None @@ -82,6 +87,14 @@ def parse_map(vmf: VMF, has_attr: Dict[str, bool]) -> None: filename = inst['file'].casefold() if filename == glass_inst: inst.remove() + elif filename in frame_inst: + # Add a fixup to allow distinguishing the type. + pos = Vec.from_str(inst['origin']) // 128 * 128 + (64, 64, 64) + norm = Vec(z=-1) @ Angle.from_str(inst['angles']) + try: + inst.fixup[consts.FixupVars.BEE_GLS_TYPE] = BARRIERS[pos.as_tuple(), norm.as_tuple()].value + except KeyError: + LOGGER.warning('No glass/grating for frame at {}, {}?', pos, norm) if options.get(str, 'glass_pack') and has_attr['glass']: packing.pack_list(vmf, options.get(str, 'glass_pack')) diff --git a/src/precomp/bottomlessPit.py b/src/precomp/bottomlessPit.py index 1bec01f88..2a4026f62 100644 --- a/src/precomp/bottomlessPit.py +++ b/src/precomp/bottomlessPit.py @@ -1,10 +1,8 @@ """Generates Bottomless Pits.""" -import random - from srctools import Vec, Property, VMF, Solid, Side, Output import srctools.logger import utils -from precomp import brushLoc, options +from precomp import brushLoc, options, rand LOGGER = srctools.logger.get_logger(__name__) @@ -283,9 +281,8 @@ def make_bottomless_pit(vmf: VMF, max_height): # Middle of the pit... continue - random.seed('pit_' + str(pos.x) + str(pos.y) + 'sides') - - file = random.choice(side_types[inst_type]) + rng = rand.seed(b'pit', pos.x, pos.y) + file = rng.choice(side_types[inst_type]) if file != '': vmf.create_ent( @@ -298,7 +295,7 @@ def make_bottomless_pit(vmf: VMF, max_height): # Straight uses two side-instances in parallel - "|o|" if inst_type is utils.CONN_TYPES.straight: - file = random.choice(side_types[inst_type]) + file = rng.choice(side_types[inst_type]) if file != '': vmf.create_ent( classname='func_instance', @@ -394,5 +391,3 @@ def make_pit_shell(vmf: VMF): Vec(20 * 128, 20 * 128, -4864), mat='tools/toolstrigger', ).solid] - - diff --git a/src/precomp/brushLoc.py b/src/precomp/brushLoc.py index d54cd4940..f94ce5a31 100644 --- a/src/precomp/brushLoc.py +++ b/src/precomp/brushLoc.py @@ -1,24 +1,18 @@ """Holds data about the contents of each grid position in the map. """ -from collections import deque +from __future__ import annotations -import editoritems -from srctools import Vec, Vec_tuple, VMF +from collections.abc import Iterable, Iterator +from collections import deque +from typing import Union, Any, Tuple, ItemsView, MutableMapping from enum import Enum +from srctools import Vec, Matrix, Angle, VMF + import srctools.logger import utils -from typing import ( - Union, Any, Tuple, - Iterable, Iterator, - Dict, ItemsView, MutableMapping, - List, -) -try: - from typing import Deque -except ImportError: - from typing_extensions import Deque +import editoritems LOGGER = srctools.logger.get_logger(__name__) @@ -134,7 +128,7 @@ def is_bottom(self) -> bool: _grid_keys = Union[Vec, Tuple[float, float, float], slice] -def _conv_key(pos: _grid_keys) -> Tuple[float, float, float]: +def _conv_key(pos: _grid_keys) -> tuple[float, float, float]: """Convert the key given in [] to a grid-position, as a x,y,z tuple.""" # TODO: Slices are assumed to be int by typeshed. # type: ignore @@ -150,21 +144,20 @@ def _conv_key(pos: _grid_keys) -> Tuple[float, float, float]: class _GridItemsView(ItemsView[Vec, Block]): """Implements the Grid.items() view, providing a view over the pos, block pairs.""" - def __init__(self, grid: Dict[Vec_tuple, Block]): - self._grid = grid - - def __len__(self) -> int: - return len(self._grid) + # Initialised by superclass. + _mapping: dict[tuple[float, float, float], Block] + def __init__(self, grid: dict[tuple[float, float, float], Block]): + super().__init__(grid) def __contains__(self, item: Any) -> bool: pos, block = item try: - return block is self._grid[_conv_key(pos)] + return block is self._mapping[_conv_key(pos)] except KeyError: return False - def __iter__(self) -> Iterator[Tuple[Vec, Block]]: - for pos, block in self._grid.items(): + def __iter__(self) -> Iterator[tuple[Vec, Block]]: + for pos, block in self._mapping.items(): yield (Vec(pos), block) @@ -175,7 +168,7 @@ class Grid(MutableMapping[_grid_keys, Block]): as a world position. """ def __init__(self) -> None: - self._grid: Dict[Vec_tuple, Block] = {} + self._grid: dict[tuple[float, float, float], Block] = {} def raycast( self, @@ -246,7 +239,11 @@ def __delitem__(self, pos: _grid_keys) -> None: del self._grid[_conv_key(pos)] def __contains__(self, pos: object) -> bool: - return _conv_key(pos) in self._grid + try: + coords = _conv_key(pos) # type: ignore + except (TypeError, ValueError): + return False + return coords in self._grid def __iter__(self) -> Iterator[Vec]: yield from map(Vec, self._grid) @@ -254,18 +251,19 @@ def __iter__(self) -> Iterator[Vec]: def __len__(self) -> int: return len(self._grid) - def items(self) -> '_GridItemsView': + def items(self) -> _GridItemsView: + """Return a view over the grid items.""" return _GridItemsView(self._grid) - def read_from_map(self, vmf: VMF, has_attr: Dict[str, bool], items: Dict[str, editoritems.Item]) -> None: + def read_from_map(self, vmf: VMF, has_attr: dict[str, bool], items: dict[str, editoritems.Item]) -> None: """Given the map file, set blocks.""" from precomp.instance_traits import get_item_id from precomp import bottomlessPit # Starting points to fill air and goo. # We want to fill goo first... - air_search_locs: List[Tuple[Vec, bool]] = [] - goo_search_locs: List[Tuple[Vec, bool]] = [] + air_search_locs: list[tuple[Vec, bool]] = [] + goo_search_locs: list[tuple[Vec, bool]] = [] for ent in vmf.entities: str_pos = ent['origin', None] @@ -276,23 +274,34 @@ def read_from_map(self, vmf: VMF, has_attr: Dict[str, bool], items: Dict[str, ed # Exclude entities outside the main area - elevators mainly. # The border should never be set to air! - if (0, 0, 0) <= pos <= (25, 25, 25): - air_search_locs.append((Vec(pos.x, pos.y, pos.z), False)) + if not ((0, 0, 0) <= pos <= (25, 25, 25)): + continue # We need to manually set EmbeddedVoxel locations. # These might not be detected for items where there's a block # which is entirely empty - corridors and obs rooms for example. + # We also need to check occupy locations, so that it can seed search + # locs. item_id = get_item_id(ent) + seeded = False if item_id: try: - item = items[item_id] + item = items[item_id.casefold()] except KeyError: - continue - angles = Vec.from_str(ent['angles']) - for local_pos in item.embed_voxels: - world_pos = Vec(local_pos) - (0, 0, 1) - world_pos.localise(pos, angles) - self[world_pos] = Block.EMBED + pass + else: + orient = Matrix.from_angle(Angle.from_str(ent['angles'])) + for local_pos in item.embed_voxels: + # Offset down because 0 0 0 is the floor voxel. + world_pos = (Vec(local_pos) - (0, 0, 1)) @ orient + pos + self[round(world_pos, 0)] = Block.EMBED + for occu in item.occupy_voxels: + world_pos = Vec(occu.pos) @ orient + pos + air_search_locs.append((round(world_pos, 0), False)) + seeded = True + if not seeded: + # Assume origin is its location. + air_search_locs.append((pos.copy(), False)) can_have_pit = bottomlessPit.pits_allowed() @@ -362,7 +371,7 @@ def read_from_map(self, vmf: VMF, has_attr: Dict[str, bool], items: Dict[str, ed self.fill_air(goo_search_locs + air_search_locs) LOGGER.info('Air filled!') - def fill_air(self, search_locs: Iterable[Tuple[Vec, bool]]) -> None: + def fill_air(self, search_locs: Iterable[tuple[Vec, bool]]) -> None: """Flood-fill the area, making all inside spaces air or goo. This assumes the map is sealed. @@ -372,9 +381,9 @@ def fill_air(self, search_locs: Iterable[Tuple[Vec, bool]]) -> None: This will also fill the submerged tunnels with goo. """ - queue: Deque[Tuple[Vec, bool]] = deque(search_locs) + queue: deque[tuple[Vec, bool]] = deque(search_locs) - def iterdel() -> Iterator[Tuple[Vec, bool]]: + def iterdel() -> Iterator[tuple[Vec, bool]]: """Iterate as FIFO queue, deleting as we go.""" try: while True: @@ -458,7 +467,7 @@ def dump_to_map(self, vmf: VMF) -> None: Block.PIT_MID: 'logic_autosave', Block.PIT_BOTTOM: 'logic_autosave', } - for pos, block in self.items(): # type: Vec, Block + for pos, block in self.items(): vmf.create_ent( targetname=block.name.title(), classname=block_icons[block], diff --git a/src/precomp/conditions/__init__.py b/src/precomp/conditions/__init__.py index 545a9e6a3..22ef6bb65 100644 --- a/src/precomp/conditions/__init__.py +++ b/src/precomp/conditions/__init__.py @@ -30,22 +30,20 @@ from __future__ import annotations import inspect import io -import itertools +import importlib import math -import random +import pkgutil import sys import typing import warnings from collections import defaultdict from decimal import Decimal from enum import Enum +from typing import Generic, TypeVar, Any, Callable, TextIO -from typing import ( - Union, Generic, TypeVar, Any, Callable, - Iterable, Optional, Dict, List, Tuple, Set, TextIO, -) +import attr -from precomp import instanceLocs +from precomp import instanceLocs, rand import consts import srctools.logger import utils @@ -61,8 +59,8 @@ LOGGER = srctools.logger.get_logger(__name__, alias='cond.core') # Stuff we get from VBSP in init() -GLOBAL_INSTANCES = set() # type: Set[str] -ALL_INST = set() # type: Set[str] +GLOBAL_INSTANCES: set[str] = set() +ALL_INST: set[str] = set() conditions: list[Condition] = [] FLAG_LOOKUP: dict[str, CondCall[bool]] = {} @@ -72,8 +70,8 @@ RESULT_SETUP: dict[str, Callable[..., Any]] = {} # Used to dump a list of the flags, results, meta-conditions -ALL_FLAGS: list[tuple[str, Iterable[str], CondCall[bool]]] = [] -ALL_RESULTS: list[tuple[str, Iterable[str], CondCall[bool]]] = [] +ALL_FLAGS: list[tuple[str, tuple[str, ...], CondCall[bool]]] = [] +ALL_RESULTS: list[tuple[str, tuple[str, ...], CondCall[bool]]] = [] ALL_META: list[tuple[str, Decimal, CondCall[None]]] = [] @@ -169,42 +167,21 @@ class EndCondition(Exception): RES_EXHAUSTED = object() +@attr.define class Condition: """A single condition which may be evaluated.""" - __slots__ = ['flags', 'results', 'else_results', 'priority', 'source'] - - def __init__( - self, - flags: List[Property]=None, - results: List[Property]=None, - else_results: List[Property]=None, - priority: Decimal=Decimal(), - source: str=None, - ) -> None: - self.flags = flags or [] - self.results = results or [] - self.else_results = else_results or [] - self.priority = priority - self.source = source - - def __repr__(self) -> str: - return ( - 'Condition(flags={!r}, ' - 'results={!r}, else_results={!r}, ' - 'priority={!r}' - ).format( - self.flags, - self.results, - self.else_results, - self.priority, - ) + flags: list[Property] = attr.Factory(list) + results: list[Property] = attr.Factory(list) + else_results: list[Property] = attr.Factory(list) + priority: Decimal = Decimal() + source: str = None @classmethod - def parse(cls, prop_block: Property) -> 'Condition': + def parse(cls, prop_block: Property) -> Condition: """Create a condition from a Property block.""" - flags = [] # type: List[Property] - results = [] # type: List[Property] - else_results = [] # type: List[Property] + flags: list[Property] = [] + results: list[Property] = [] + else_results: list[Property] = [] priority = Decimal() source = None for prop in prop_block: @@ -234,7 +211,7 @@ def parse(cls, prop_block: Property) -> 'Condition': else: flags.append(prop) - return cls( + return Condition( flags, results, else_results, @@ -243,7 +220,7 @@ def parse(cls, prop_block: Property) -> 'Condition': ) @staticmethod - def test_result(inst: Entity, res: Property) -> Union[bool, object]: + def test_result(inst: Entity, res: Property) -> bool | object: """Execute the given result.""" try: cond_call = RESULT_LOOKUP[res.name] @@ -281,7 +258,7 @@ def test(self, inst: Entity) -> None: def annotation_caller( func: Callable[..., AnnCallT], *parms: type, -) -> Tuple[Callable[..., AnnCallT], List[type]]: +) -> tuple[Callable[..., AnnCallT], list[type]]: """Reorders callback arguments to the requirements of the callback. parms should be the unique types of arguments in the order they will be @@ -324,7 +301,7 @@ def annotation_caller( ann_order: list[type] = [] # type -> parameter name. - type_to_parm: dict[type, Optional[str]] = dict.fromkeys(parms, None) + type_to_parm: dict[type, str | None] = dict.fromkeys(parms, None) sig = inspect.signature(func) for parm in sig.parameters.values(): ann = parm.annotation @@ -429,14 +406,11 @@ class CondCall(Generic[CallResultT]): This should be called to execute it. """ __slots__ = ['func', 'group', '_cback', '_setup_data'] - _setup_data: Optional[dict[int, Callable[[Entity], CallResultT]]] + _setup_data: dict[int, Callable[[Entity], CallResultT]] | None def __init__( self, - func: Callable[..., Union[ - CallResultT, - Callable[[Entity], CallResultT], - ]], + func: Callable[..., CallResultT | Callable[[Entity], CallResultT]], group: str, ): self.func = func @@ -475,7 +449,7 @@ def __call__(self, ent: Entity, conf: Property) -> CallResultT: return cback(ent) @property - def __doc__(self) -> Optional[str]: + def __doc__(self) -> str | None: """Description of the callback's behaviour.""" return self.func.__doc__ @@ -490,7 +464,7 @@ def _get_cond_group(func: Any) -> str: return group -def add_meta(func, priority: Union[Decimal, int], only_once=True): +def add_meta(func, priority: Decimal | int, only_once=True): """Add a metacondition, which executes a function at a priority level. Used to allow users to allow adding conditions before or after a @@ -557,9 +531,10 @@ def x(result_func): """Create the result when the function is supplied.""" # Legacy setup func support. try: - setup_func = RESULT_SETUP[orig_name.casefold()] + setup_func = RESULT_SETUP.pop(orig_name.casefold()) except KeyError: func = result_func + setup_func = None else: # Combine the legacy functions into one using a closure. func = conv_setup_pair(setup_func, result_func) @@ -568,6 +543,10 @@ def x(result_func): RESULT_LOOKUP[orig_name.casefold()] = wrapper for name in aliases: RESULT_LOOKUP[name.casefold()] = wrapper + if setup_func is not None: + for name in aliases: + alias_setup = RESULT_SETUP.pop(name.casefold()) + assert alias_setup is setup_func, alias_setup ALL_RESULTS.append((orig_name, aliases, wrapper)) return func return x @@ -595,16 +574,19 @@ def add(prop_block): conditions.append(con) -def init(seed: str, inst_list: Set[str]) -> None: +def init(inst_list: set[str]) -> None: """Initialise the Conditions system.""" - # Get a bunch of values from VBSP - global MAP_RAND_SEED - MAP_RAND_SEED = seed ALL_INST.update(inst_list) # Sort by priority, where higher = done later zero = Decimal(0) conditions.sort(key=lambda cond: getattr(cond, 'priority', zero)) + # Check if any make_result_setup calls were done with no matching result. + if utils.DEV_MODE and RESULT_SETUP: + raise ValueError('Extra result_setup calls:\n' + '\n'.join([ + f' - "{name}": {getattr(func, "__module__", "?")}.{func.__qualname__}()' + for name, func in RESULT_SETUP.items() + ])) def check_all(vmf: VMF) -> None: @@ -682,39 +664,15 @@ def import_conditions() -> None: This ensures everything gets registered. """ - import importlib - import pkgutil # Find the modules in the conditions package. - # PyInstaller messes this up a bit. - - if utils.FROZEN: - # This is the PyInstaller loader injected during bootstrap. - # See PyInstaller/loader/pyimod03_importers.py - # toc is a PyInstaller-specific attribute containing a set of - # all frozen modules. - loader = pkgutil.get_loader('precomp.conditions') - modules = [ - module - for module in loader.toc - if module.startswith('precomp.conditions.') - ] # type: List[str] - else: - # We can grab them properly. - modules = [ - 'precomp.conditions.' + module - for loader, module, is_package in - pkgutil.iter_modules(__path__) - ] - - for module in modules: + for module in pkgutil.iter_modules(__path__, 'precomp.conditions.'): # Import the module, then discard it. The module will run add_flag # or add_result() functions, which save the functions into our dicts. # We don't need a reference to the modules themselves. - LOGGER.debug('Importing {} ...', module) - importlib.import_module(module) + LOGGER.debug('Importing {} ...', module.name) + importlib.import_module(module.name) LOGGER.info('Imported all conditions modules!') - DOC_MARKER = '''''' DOC_META_COND = ''' @@ -767,19 +725,21 @@ def dump_conditions(file: TextIO) -> None: ALL_META.sort(key=lambda i: i[1]) # Sort by priority for flag_key, priority, func in ALL_META: - file.write('#### `{}` ({}):\n\n'.format(flag_key, priority)) + file.write(f'#### `{flag_key}` ({priority}):\n\n') dump_func_docs(file, func) file.write('\n') for lookup, name in [ - (ALL_FLAGS, 'Flags'), - (ALL_RESULTS, 'Results'), - ]: + (ALL_FLAGS, 'Flags'), + (ALL_RESULTS, 'Results'), + ]: print('', file=file) - print('# ' + name, file=file) + print(f'# {name}', file=file) print('', file=file) - lookup_grouped = defaultdict(list) # type: Dict[str, List[Tuple[str, Tuple[str, ...], CondCall]]] + lookup_grouped: dict[str, list[ + tuple[str, tuple[str, ...], CondCall] + ]] = defaultdict(list) for flag_key, aliases, func in lookup: group = getattr(func, 'group', 'ERROR') @@ -807,14 +767,14 @@ def dump_conditions(file: TextIO) -> None: if group == '00special': print(DOC_SPECIAL_GROUP, file=file) else: - print('### ' + group + '\n', file=file) + print(f'### {group}\n', file=file) LOGGER.info('Doing {} group...', group) for flag_key, aliases, func in funcs: - print('#### `{}`:\n'.format(flag_key), file=file) + print(f'#### `{flag_key}`:\n', file=file) if aliases: - print('**Aliases:** `' + '`, `'.join(aliases) + '`' + ' \n', file=file) + print(f'**Aliases:** `{"`, `".join(aliases)}` \n', file=file) dump_func_docs(file, func) file.write('\n') @@ -828,41 +788,6 @@ def dump_func_docs(file: TextIO, func: Callable): print('**No documentation!**', file=file) -def weighted_random(count: int, weights: str) -> List[int]: - """Generate random indexes with weights. - - This produces a list intended to be fed to random.choice(), with - repeated indexes corresponding to the comma-separated weight values. - """ - if weights == '': - # Empty = equal weighting. - return list(range(count)) - if ',' not in weights: - LOGGER.warning('Invalid weight! ({})', weights) - return list(range(count)) - - # Parse the weight - vals = weights.split(',') - weight = [] - if len(vals) == count: - for i, val in enumerate(vals): - val = val.strip() - if val.isdecimal(): - # repeat the index the correct number of times - weight.extend( - [i] * int(val) - ) - else: - # Abandon parsing - break - if len(weight) == 0: - LOGGER.warning('Failed parsing weight! ({!s})',weight) - weight = list(range(count)) - # random.choice(weight) will now give an index with the correct - # probabilities. - return weight - - def add_output(inst: Entity, prop: Property, target: str) -> None: """Add a customisable output to an instance.""" inst.add_out(Output( @@ -882,7 +807,7 @@ def add_suffix(inst: Entity, suff: str) -> None: inst['file'] = ''.join((old_name, suff, dot, ext)) -def local_name(inst: Entity, name: Union[str, Entity]) -> str: +def local_name(inst: Entity, name: str | Entity) -> str: """Fixup the given name for inside an instance. This handles @names, !activator, and obeys the fixup_style option. @@ -914,7 +839,7 @@ def local_name(inst: Entity, name: Union[str, Entity]) -> str: raise ValueError('Unknown fixup style {}!'.format(fixup)) -def widen_fizz_brush(brush: Solid, thickness: float, bounds: Tuple[Vec, Vec]=None): +def widen_fizz_brush(brush: Solid, thickness: float, bounds: tuple[Vec, Vec]=None): """Move the two faces of a fizzler brush outward. This is good to make fizzlers which are thicker than 2 units. @@ -964,9 +889,9 @@ def set_ent_keys( block_name lets you change the 'keys' suffix on the prop_block name. ent can be any mapping. """ - for prop in prop_block.find_key(block_name, []): + for prop in prop_block.find_block(block_name, or_blank=True): ent[prop.real_name] = resolve_value(inst, prop.value) - for prop in prop_block.find_key('Local' + block_name, []): + for prop in prop_block.find_block('Local' + block_name, or_blank=True): if prop.value.startswith('$'): val = inst.fixup[prop.value] else: @@ -979,7 +904,7 @@ def set_ent_keys( T = TypeVar('T') -def resolve_value(inst: Entity, value: Union[str, T]) -> Union[str, T]: +def resolve_value(inst: Entity, value: str | T) -> str | T: """If a value contains '$', lookup the associated var. Non-string values are passed through unchanged. @@ -1037,27 +962,6 @@ def resolve_offset(inst, value: str, scale: float=1, zoff: float=0) -> Vec: return offset -def set_random_seed(inst: Entity, seed: str) -> None: - """Compute and set a random seed for a specific entity.""" - from precomp import instance_traits - - name = inst['targetname'] - # The global instances like elevators always get the same name, or - # none at all so we cannot use those for the seed. Instead use the global - # seed. - if name == '' or 'preplaced' in instance_traits.get(inst): - import vbsp - random.seed('{}{}{}{}'.format( - vbsp.MAP_RAND_SEED, seed, inst['origin'], inst['angles'], - )) - else: - # We still need to use angles and origin, since things like - # fizzlers might not get unique names. - random.seed('{}{}{}{}'.format( - inst['targetname'], seed, inst['origin'], inst['angles'] - )) - - @make_flag('debug') @make_result('debug') def debug_flag(inst: Entity, props: Property): @@ -1095,73 +999,56 @@ def remove_blank_inst(inst: Entity) -> None: inst.remove() -@make_result_setup('timedRelay') -def res_timed_relay_setup(res: Property): - var = res['variable', consts.FixupVars.TIM_DELAY] +@make_result('timedRelay') +def res_timed_relay(vmf: VMF, res: Property) -> Callable[[Entity], None]: + """Generate a logic_relay with outputs delayed by a certain amount. + + This allows triggering outputs based $timer_delay values. + """ + delay_var = res['variable', consts.FixupVars.TIM_DELAY] name = res['targetname'] - disabled = res['disabled', '0'] + disabled_var = res['disabled', '0'] flags = res['spawnflags', '0'] final_outs = [ - Output.parse(subprop) - for prop in res.find_all('FinalOutputs') - for subprop in prop + Output.parse(prop) + for prop in res.find_children('FinalOutputs') ] rep_outs = [ - Output.parse(subprop) - for prop in res.find_all('RepOutputs') - for subprop in prop + Output.parse(prop) + for prop in res.find_children('RepOutputs') ] - # Never use the comma seperator in the final output for consistency. - for out in itertools.chain(rep_outs, final_outs): - out.comma_sep = False - - return var, name, disabled, flags, final_outs, rep_outs - - -@make_result('timedRelay') -def res_timed_relay(vmf: VMF, inst: Entity, res: Property) -> None: - """Generate a logic_relay with outputs delayed by a certain amount. - - This allows triggering outputs based $timer_delay values. - """ - var, name, disabled, flags, final_outs, rep_outs = res.value + def make_relay(inst: Entity) -> None: + """Places the relay.""" + relay = vmf.create_ent( + classname='logic_relay', + spawnflags=flags, + origin=inst['origin'], + targetname=local_name(inst, name), + ) - relay = vmf.create_ent( - classname='logic_relay', - spawnflags=flags, - origin=inst['origin'], - targetname=local_name(inst, name), - ) + relay['StartDisabled'] = inst.fixup.substitute(disabled_var, allow_invert=True) - relay['StartDisabled'] = ( - inst.fixup[disabled] - if disabled.startswith('$') else - disabled - ) + delay = srctools.conv_float(inst.fixup.substitute(delay_var)) - delay = srctools.conv_float( - inst.fixup[var, '0'] - if var.startswith('$') else - var - ) + for off in range(int(math.ceil(delay))): + for out in rep_outs: + new_out = out.copy() + new_out.target = local_name(inst, new_out.target) + new_out.delay += off + new_out.comma_sep = False + relay.add_out(new_out) - for off in range(int(math.ceil(delay))): - for out in rep_outs: - new_out = out.copy() # type: Output + for out in final_outs: + new_out = out.copy() new_out.target = local_name(inst, new_out.target) - new_out.delay += off + new_out.delay += delay new_out.comma_sep = False relay.add_out(new_out) - for out in final_outs: - new_out = out.copy() - new_out.target = local_name(inst, new_out.target) - new_out.delay += delay - new_out.comma_sep = False - relay.add_out(new_out) + return make_relay @make_result('condition') @@ -1189,29 +1076,29 @@ def res_end_condition() -> None: @make_result('switch') -def res_switch_setup(res: Property): +def res_switch(res: Property): """Run the same flag multiple times with different arguments. - 'method' is the way the search is done - first, last, random, or all. - 'flag' is the name of the flag. - 'seed' sets the randomisation seed for this block, for the random mode. + `method` is the way the search is done - `first`, `last`, `random`, or `all`. + `flag` is the name of the flag. + `seed` sets the randomisation seed for this block, for the random mode. Each property group is a case to check - the property name is the flag argument, and the contents are the results to execute in that case. - The special group "" is only run if no other flag is valid. - For 'random' mode, you can omit the flag to choose from all objects. In + The special group `""` is only run if no other flag is valid. + For `random` mode, you can omit the flag to choose from all objects. In this case the flag arguments are ignored. """ flag_name = '' method = SWITCH_TYPE.FIRST - cases = [] + raw_cases = [] default = [] rand_seed = '' for prop in res: if prop.has_children(): if prop.name == '': - default.append(prop) + default.extend(prop) else: - cases.append(prop) + raw_cases.append(prop) else: if prop.name == 'flag': flag_name = prop.value @@ -1225,22 +1112,27 @@ def res_switch_setup(res: Property): rand_seed = prop.value if method is SWITCH_TYPE.LAST: - cases[:] = cases[::-1] + raw_cases.reverse() + + conf_cases: list[tuple[Property, list[Property]]] = [ + (Property(flag_name, case.real_name), list(case)) + for case in raw_cases + ] def apply_switch(inst: Entity) -> None: """Execute a switch.""" if method is SWITCH_TYPE.RANDOM: - set_random_seed(inst, rand_seed) - random.shuffle(cases) + cases = conf_cases.copy() + rand.seed(b'switch', rand_seed, inst).shuffle(cases) + else: # Won't change. + cases = conf_cases run_default = True - - for case in cases: - if flag_name: - flag = Property(flag_name, case.real_name) - if not check_flag(inst.map, flag, inst): - continue - for sub_res in case: + for flag, results in cases: + # If not set, always succeed for the random situation. + if flag.real_name and not check_flag(inst.map, flag, inst): + continue + for sub_res in results: Condition.test_result(inst, sub_res) run_default = False if method is not SWITCH_TYPE.ALL: @@ -1252,9 +1144,20 @@ def apply_switch(inst: Entity) -> None: return apply_switch -@make_result_setup('staticPiston') -def make_static_pist_setup(res: Property): - instances = ( +@make_result('staticPiston') +def make_static_pist(vmf: srctools.VMF, res: Property) -> Callable[[Entity], None]: + """Convert a regular piston into a static version. + + This is done to save entities and improve lighting. + If changed to static pistons, the $bottom and $top level become equal. + Instances: + Bottom_1/2/3: Moving piston with the given $bottom_level + Logic_0/1/2/3: Additional logic instance for the given $bottom_level + Static_0/1/2/3/4: A static piston at the given height. + Alternatively, specify all instances via editoritems, by setting the value + to the item ID optionally followed by a :prefix. + """ + inst_keys = ( 'bottom_0', 'bottom_1', 'bottom_2', 'bottom_3', 'logic_0', 'logic_1', 'logic_2', 'logic_3', 'static_0', 'static_1', 'static_2', 'static_3', 'static_4', @@ -1263,11 +1166,11 @@ def make_static_pist_setup(res: Property): if res.has_children(): # Pull from config - return { + instances = { name: instanceLocs.resolve_one( res[name, ''], error=False, - ) for name in instances + ) for name in inst_keys } else: # Pull from editoritems @@ -1276,77 +1179,65 @@ def make_static_pist_setup(res: Property): else: from_item = res.value prefix = '' - return { + instances = { name: instanceLocs.resolve_one( '<{}:bee2_{}{}>'.format(from_item, prefix, name), error=False, - ) for name in instances + ) for name in inst_keys } - -@make_result('staticPiston') -def make_static_pist(vmf: srctools.VMF, ent: Entity, res: Property): - """Convert a regular piston into a static version. - - This is done to save entities and improve lighting. - If changed to static pistons, the $bottom and $top level become equal. - Instances: - Bottom_1/2/3: Moving piston with the given $bottom_level - Logic_0/1/2/3: Additional logic instance for the given $bottom_level - Static_0/1/2/3/4: A static piston at the given height. - Alternatively, specify all instances via editoritems, by setting the value - to the item ID optionally followed by a :prefix. - """ - - bottom_pos = ent.fixup.int(consts.FixupVars.PIST_BTM, 0) - - if ( - ent.fixup.int(consts.FixupVars.CONN_COUNT) > 0 or - ent.fixup.bool(consts.FixupVars.DIS_AUTO_DROP) - ): # can it move? - ent.fixup[consts.FixupVars.BEE_PIST_IS_STATIC] = True - - # Use instances based on the height of the bottom position. - val = res.value['bottom_' + str(bottom_pos)] - if val: # Only if defined - ent['file'] = val - - logic_file = res.value['logic_' + str(bottom_pos)] - if logic_file: - # Overlay an additional logic file on top of the original - # piston. This allows easily splitting the piston logic - # from the styled components - logic_ent = ent.copy() - logic_ent['file'] = logic_file - vmf.add_ent(logic_ent) - # If no connections are present, set the 'enable' value in - # the logic to True so the piston can function - logic_ent.fixup[consts.FixupVars.BEE_PIST_MANAGER_A] = ( - ent.fixup.int(consts.FixupVars.CONN_COUNT) == 0 - ) - else: # we are static - ent.fixup[consts.FixupVars.BEE_PIST_IS_STATIC] = False - if ent.fixup.bool(consts.FixupVars.PIST_IS_UP): - pos = bottom_pos = ent.fixup.int(consts.FixupVars.PIST_TOP, 1) - else: - pos = bottom_pos - ent.fixup[consts.FixupVars.PIST_TOP] = ent.fixup[consts.FixupVars.PIST_BTM] = pos - - val = res.value['static_' + str(pos)] - if val: - ent['file'] = val - - # Add in the grating for the bottom as an overlay. - # It's low to fit the piston at minimum, or higher if needed. - grate = res.value[ - 'grate_high' - if bottom_pos > 0 else - 'grate_low' - ] - if grate: - grate_ent = ent.copy() - grate_ent['file'] = grate - vmf.add_ent(grate_ent) + def make_static(ent: Entity) -> None: + """Make a piston static.""" + bottom_pos = ent.fixup.int(consts.FixupVars.PIST_BTM, 0) + + if ( + ent.fixup.int(consts.FixupVars.CONN_COUNT) > 0 or + ent.fixup.bool(consts.FixupVars.DIS_AUTO_DROP) + ): # can it move? + ent.fixup[consts.FixupVars.BEE_PIST_IS_STATIC] = True + + # Use instances based on the height of the bottom position. + val = instances['bottom_' + str(bottom_pos)] + if val: # Only if defined + ent['file'] = val + + logic_file = instances['logic_' + str(bottom_pos)] + if logic_file: + # Overlay an additional logic file on top of the original + # piston. This allows easily splitting the piston logic + # from the styled components + logic_ent = ent.copy() + logic_ent['file'] = logic_file + vmf.add_ent(logic_ent) + # If no connections are present, set the 'enable' value in + # the logic to True so the piston can function + logic_ent.fixup[consts.FixupVars.BEE_PIST_MANAGER_A] = ( + ent.fixup.int(consts.FixupVars.CONN_COUNT) == 0 + ) + else: # we are static + ent.fixup[consts.FixupVars.BEE_PIST_IS_STATIC] = False + if ent.fixup.bool(consts.FixupVars.PIST_IS_UP): + pos = bottom_pos = ent.fixup.int(consts.FixupVars.PIST_TOP, 1) + else: + pos = bottom_pos + ent.fixup[consts.FixupVars.PIST_TOP] = ent.fixup[consts.FixupVars.PIST_BTM] = pos + + val = instances['static_' + str(pos)] + if val: + ent['file'] = val + + # Add in the grating for the bottom as an overlay. + # It's low to fit the piston at minimum, or higher if needed. + grate = instances[ + 'grate_high' + if bottom_pos > 0 else + 'grate_low' + ] + if grate: + grate_ent = ent.copy() + grate_ent['file'] = grate + vmf.add_ent(grate_ent) + return make_static @make_result('GooDebris') @@ -1367,13 +1258,14 @@ def res_goo_debris(vmf: VMF, res: Property) -> object: space = res.int('spacing', 1) rand_count = res.int('number', None) + rand_list: list[int] | None if rand_count: - rand_list = weighted_random( + rand_list = rand.parse_weights( rand_count, res['weights', ''], ) else: - rand_list = None # type: Optional[List[int]] + rand_list = None chance = res.int('chance', 30) / 100 file = res['file'] offset = res.int('offset', 0) @@ -1416,24 +1308,25 @@ def res_goo_debris(vmf: VMF, res: Property) -> object: len(goo_top_locs), ) - suff = '' for loc in possible_locs: - random.seed('goo_debris_{}_{}_{}'.format(loc.x, loc.y, loc.z)) - if random.random() > chance: + rng = rand.seed(b'goo_debris', loc) + if rng.random() > chance: continue if rand_list is not None: - suff = '_' + str(random.choice(rand_list) + 1) + rand_fname = f'{file}_{rng.choice(rand_list) + 1}.vmf' + else: + rand_fname = file + '.vmf' if offset > 0: - loc.x += random.randint(-offset, offset) - loc.y += random.randint(-offset, offset) + loc.x += rng.randint(-offset, offset) + loc.y += rng.randint(-offset, offset) loc.z -= 32 # Position the instances in the center of the 128 grid. vmf.create_ent( classname='func_instance', - file=file + suff + '.vmf', + file=rand_fname, origin=loc.join(' '), - angles='0 {} 0'.format(random.randrange(0, 3600)/10) + angles=f'0 {rng.randrange(0, 3600) / 10} 0' ) return RES_EXHAUSTED diff --git a/src/precomp/conditions/scaffold.py b/src/precomp/conditions/_scaffold_compat.py similarity index 89% rename from src/precomp/conditions/scaffold.py rename to src/precomp/conditions/_scaffold_compat.py index 3ef280372..815fd9798 100644 --- a/src/precomp/conditions/scaffold.py +++ b/src/precomp/conditions/_scaffold_compat.py @@ -1,5 +1,5 @@ """The result used to generate unstationary scaffolds.""" -from typing import Tuple, Optional +from typing import Tuple, Optional, Any from enum import Enum import math @@ -19,7 +19,7 @@ class LinkType(Enum): COND_MOD_NAME = None -LOGGER = srctools.logger.get_logger(__name__, alias='cond.scaffold') +LOGGER = srctools.logger.get_logger(__name__, alias='cond._scaffold_compat') def scaff_scan(inst_list, start_ent): @@ -37,25 +37,19 @@ def get_config( ) -> Tuple[str, Vec]: """Compute the config values for a node.""" - orient = ( - 'floor' if - Vec(0, 0, 1).rotate_by_str(node.inst['angles']) == (0, 0, 1) - else 'wall' - ) + orient = ('floor' if abs(node.orient.up().z) > 0.9 else 'wall') # Find the offset used for the platform. - offset = (node.conf['off_' + orient]).copy() # type: Vec + offset: Vec = (node.conf['off_' + orient]).copy() if node.conf['is_piston']: # Adjust based on the piston position offset.z += 128 * srctools.conv_int( node.inst.fixup[ '$top_level' if - node.inst.fixup[ - '$start_up'] == '1' + node.inst.fixup['$start_up'] == '1' else '$bottom_level' ] ) - offset = offset.rotate_by_str(node.inst['angles']) - offset += Vec.from_str(node.inst['origin']) + offset = offset @ node.orient + node.pos return orient, offset @@ -138,28 +132,37 @@ def res_unst_scaffold_setup(res: Property): @make_result('UnstScaffold') def res_unst_scaffold(vmf: VMF, res: Property): - """The condition to generate Unstationary Scaffolds. + """The pre-2.4.40 version of the condition used to generate Unstationary Scaffolds. - This is executed once to modify all instances. + This has since been swapped to use the LinkedItems result, but this is kept for package + compatiblity. """ # The instance types we're modifying if res.value not in SCAFFOLD_CONFIGS: # We've already executed this config group return RES_EXHAUSTED - LOGGER.info( - 'Running Scaffold Generator ({})...', - res.value + LOGGER.warning( + 'Running legacy scaffold generator for "{}"!' + 'Items should now use the generic LinkedItem config, update your packages!', + res.value, ) inst_to_config, LINKS = SCAFFOLD_CONFIGS[res.value] del SCAFFOLD_CONFIGS[res.value] # Don't let this run twice - chains = item_chain.chain(vmf, inst_to_config.keys(), allow_loop=False) + # Don't bother typechecking this dict, legacy code. + nodes: list[item_chain.Node[dict[str, Any]]] = [] + for inst in vmf.by_class['func_instance']: + try: + conf = inst_to_config[inst['file'].casefold()] + except KeyError: + continue + else: + nodes.append(item_chain.Node.from_inst(inst, conf)) # We need to make the link entities unique for each scaffold set, # otherwise the AllVar property won't work. - - for group_counter, node_list in enumerate(chains): + for group_counter, node_list in enumerate(item_chain.chain(nodes, allow_loop=False)): # Set all the instances and properties start_inst = node_list[0].item.inst for vals in LINKS.values(): diff --git a/src/precomp/conditions/addInstance.py b/src/precomp/conditions/addInstance.py index 5a0cb826a..55fd2afb6 100644 --- a/src/precomp/conditions/addInstance.py +++ b/src/precomp/conditions/addInstance.py @@ -1,12 +1,11 @@ """Results for generating additional instances. """ -from typing import Optional +from typing import Optional, Callable from srctools import Vec, Entity, Property, VMF, Angle import srctools.logger -from precomp import instanceLocs, options, conditions -from precomp.conditions import make_result, RES_EXHAUSTED, GLOBAL_INSTANCES +from precomp import instanceLocs, options, conditions, rand COND_MOD_NAME = 'Instance Generation' @@ -14,7 +13,7 @@ LOGGER = srctools.logger.get_logger(__name__, 'cond.addInstance') -@make_result('addGlobal') +@conditions.make_result('addGlobal') def res_add_global_inst(vmf: VMF, res: Property): """Add one instance in a specific location. @@ -35,7 +34,7 @@ def res_add_global_inst(vmf: VMF, res: Property): if not res.has_children(): res = Property('AddGlobal', [Property('File', res.value)]) - if res.bool('allow_multiple') or res['file'] not in GLOBAL_INSTANCES: + if res.bool('allow_multiple') or res['file'] not in conditions.GLOBAL_INSTANCES: # By default we will skip adding the instance # if was already added - this is helpful for # items that add to original items, or to avoid @@ -51,14 +50,14 @@ def res_add_global_inst(vmf: VMF, res: Property): new_inst['origin'] = res['position'] except IndexError: new_inst['origin'] = options.get(Vec, 'global_ents_loc') - GLOBAL_INSTANCES.add(res['file']) + conditions.GLOBAL_INSTANCES.add(res['file']) if new_inst['targetname'] == '': new_inst['targetname'] = "inst_" new_inst.make_unique() - return RES_EXHAUSTED + return conditions.RES_EXHAUSTED -@make_result('addOverlay', 'overlayinst') +@conditions.make_result('addOverlay', 'overlayinst') def res_add_overlay_inst(vmf: VMF, inst: Entity, res: Property) -> Optional[Entity]: """Add another instance on top of this one. @@ -135,7 +134,81 @@ def res_add_overlay_inst(vmf: VMF, inst: Entity, res: Property) -> Optional[Enti return overlay_inst -@make_result('addCavePortrait') +@conditions.make_result('addShuffleGroup') +def res_add_shuffle_group(vmf: VMF, res: Property) -> Callable[[Entity], None]: + """Pick from a pool of instances to randomise decoration. + + For each sub-condition that succeeds, a random instance is placed, with + a fixup set to a value corresponding to the condition. + + Parameters: + - Var: The fixup variable to set on each item. This is used to tweak it + to match the condition. + - Conditions: Each value here is the value to produce if this instance + is required. The contents of the block is then a condition flag to + check. + - Pool: A list of instances to randomly allocate to the conditions. There + should be at least as many pool values as there are conditions. + - Seed: Value to modify the seed with before placing. + """ + conf_variable = res['var'] + conf_seed = 'sg' + res['seed', ''] + conf_pools: dict[str, list[str]] = {} + for prop in res.find_children('pool'): + if prop.has_children(): + raise ValueError('Instances in pool cannot be a property block!') + conf_pools.setdefault(prop.name, []).append(prop.value) + + # (flag, value, pools) + conf_selectors: list[tuple[list[Property], str, frozenset[str]]] = [] + for prop in res.find_all('selector'): + conf_value = prop['value', ''] + conf_flags = list(prop.find_children('conditions')) + try: + picked_pools = prop['pools'].casefold().split() + except LookupError: + picked_pools = frozenset(conf_pools) + else: + for pool_name in picked_pools: + if pool_name not in conf_pools: + raise ValueError(f'Unknown pool name {pool_name}!') + conf_selectors.append((conf_flags, conf_value, frozenset(picked_pools))) + + all_pools = [ + (name, inst) + for name, instances in conf_pools.items() + for inst in instances + ] + all_pools.sort() # Ensure consistent order. + + def add_group(inst: Entity) -> None: + """Place the group.""" + rng = rand.seed(b'shufflegroup', conf_seed, inst) + pools = all_pools.copy() + for (flags, value, potential_pools) in conf_selectors: + for flag in flags: + if not conditions.check_flag(vmf, flag, inst): + break + else: # Succeeded. + allowed_inst = [ + (name, inst) + for (name, inst) in pools + if name in potential_pools + ] + name, filename = rng.choice(allowed_inst) + pools.remove((name, filename)) + vmf.create_ent( + 'func_instance', + targetname=inst['targetname'], + file=filename, + angles=inst['angles'], + origin=inst['origin'], + fixup_style='0', + ).fixup[conf_variable] = value + return add_group + + +@conditions.make_result('addCavePortrait') def res_cave_portrait(vmf: VMF, inst: Entity, res: Property) -> None: """A variant of AddOverlay for adding Cave Portraits. diff --git a/src/precomp/conditions/antlaser.py b/src/precomp/conditions/antlaser.py deleted file mode 100644 index 30913f7a1..000000000 --- a/src/precomp/conditions/antlaser.py +++ /dev/null @@ -1,376 +0,0 @@ -"""Implement Konclan's AntLaser item. -""" -from enum import Enum -from typing import Dict, List, Tuple, Set, FrozenSet, Callable, Union - -from precomp import instanceLocs, connections, conditions -import srctools.logger -from precomp.conditions import make_result -from srctools import VMF, Property, Output, Vec, Entity - - -COND_MOD_NAME = None - -LOGGER = srctools.logger.get_logger(__name__, alias='cond.antlaser') - -AntLaserConn = connections.Config( - '', - input_type=connections.InputType.OR, - output_act=(None, 'OnUser2'), - output_deact=(None, 'OnUser1'), -) - -NAME_SPR = '{}-fx_sp_{}'.format # type: Callable[[str, int], str] -NAME_BEAM_LOW = '{}-fx_b_low_{}'.format # type: Callable[[str, int], str] -NAME_BEAM_CONN = '{}-fx_b_conn_{}'.format # type: Callable[[str, int], str] -NAME_CABLE = '{}-cab_{}'.format # type: Callable[[str, int], str] - - -class RopeState(Enum): - """Used to link up ropes.""" - NONE = 'none' # No rope here. - UNLINKED = 'unlinked' # Rope ent, with no target. - LINKED = 'linked' # Rope ent, with target already. - - @staticmethod - def from_node( - points: Dict[connections.Item, Union[Entity, str]], - node: connections.Item, - ) -> Tuple['RopeState', Union[Entity, str]]: - """Compute the state and ent/name from the points data.""" - try: - ent = points[node] - except KeyError: - return RopeState.NONE, '' - if isinstance(ent, str): - return RopeState.LINKED, ent - else: - return RopeState.UNLINKED, ent - - -class Group: - """Represents a group of markers.""" - def __init__(self, start: connections.Item): - self.nodes: List[connections.Item] = [start] - # We use a frozenset here to ensure we don't double-up the links - - # users might accidentally do that. - self.links: Set[FrozenSet[connections.Item]] = set() - # Create the item for the entire group of markers. - logic_ent = start.inst.map.create_ent( - 'info_target', - origin=start.inst['origin'], - targetname=start.name, - ) - self.item = connections.Item( - logic_ent, - AntLaserConn, - start.ant_floor_style, - start.ant_wall_style, - ) - connections.ITEMS[self.item.name] = self.item - - -def on_floor(node: connections.Item) -> bool: - """Check if this node is on the floor.""" - norm = Vec(z=1).rotate_by_str(node.inst['angles']) - return norm.z > 0 - - -@make_result('AntLaser') -def res_antlaser(vmf: VMF, res: Property): - """The condition to generate AntLasers. - - This is executed once to modify all instances. - """ - conf_inst = instanceLocs.resolve(res['instance']) - conf_glow_height = Vec(z=res.float('GlowHeight', 48) - 64) - conf_las_start = Vec(z=res.float('LasStart') - 64) - conf_rope_off = res.vec('RopePos') - conf_toggle_targ = res['toggleTarg', ''] - - beam_conf = res.find_key('BeamKeys', []) - glow_conf = res.find_key('GlowKeys', []) - cable_conf = res.find_key('CableKeys', []) - - if beam_conf: - # Grab a copy of the beam spawnflags so we can set our own options. - conf_beam_flags = beam_conf.int('spawnflags') - # Mask out certain flags. - conf_beam_flags &= ( - 0 - | 1 # Start On - | 2 # Toggle - | 4 # Random Strike - | 8 # Ring - | 16 # StartSparks - | 32 # EndSparks - | 64 # Decal End - #| 128 # Shade Start - #| 256 # Shade End - #| 512 # Taper Out - ) - else: - conf_beam_flags = 0 - - conf_outputs = [ - Output.parse(prop) - for prop in res - if prop.name in ('onenabled', 'ondisabled') - ] - - # Find all the markers. - nodes: Dict[str, connections.Item] = {} - - for inst in vmf.by_class['func_instance']: - if inst['file'].casefold() not in conf_inst: - continue - name = inst['targetname'] - try: - # Remove the item - it's no longer going to exist after - # we're done. - nodes[name] = connections.ITEMS.pop(name) - except KeyError: - raise ValueError('No item for "{}"?'.format(name)) from None - - if not nodes: - # None at all. - return conditions.RES_EXHAUSTED - - # Now find every connected group, recording inputs, outputs and links. - todo = set(nodes.values()) - - groups = [] # type: List[Group] - - # Node -> is grouped already. - node_pairing = dict.fromkeys(nodes.values(), False) - - while todo: - start = todo.pop() - # Synthesise the Item used for logic. - # We use a random info_target to manage the IO data. - group = Group(start) - groups.append(group) - for node in group.nodes: - # If this node has no non-node outputs, destroy the antlines. - has_output = False - node_pairing[node] = True - - for conn in list(node.outputs): - neighbour = conn.to_item - todo.discard(neighbour) - pair_state = node_pairing.get(neighbour, None) - if pair_state is None: - # Not a node, a target of our logic. - conn.from_item = group.item - has_output = True - continue - elif pair_state is False: - # Another node. - group.nodes.append(neighbour) - # else: True, node already added. - - # For nodes, connect link. - conn.remove() - group.links.add(frozenset({node, neighbour})) - - # If we have a real output, we need to transfer it. - # Otherwise we can just destroy it. - if has_output: - node.transfer_antlines(group.item) - else: - node.delete_antlines() - - # Do the same for inputs, so we can catch that. - for conn in list(node.inputs): - neighbour = conn.from_item - todo.discard(neighbour) - pair_state = node_pairing.get(neighbour, None) - if pair_state is None: - # Not a node, an input to the group. - conn.to_item = group.item - continue - elif pair_state is False: - # Another node. - group.nodes.append(neighbour) - # else: True, node already added. - - # For nodes, connect link. - conn.remove() - group.links.add(frozenset({neighbour, node})) - - # Now every node is in a group. Generate the actual entities. - for group in groups: - # We generate two ent types. For each marker, we add a sprite - # and a beam pointing at it. Then for each connection - # another beam. - - # Choose a random antlaser name to use for our group. - base_name = group.nodes[0].name - - out_enable = [Output('', '', 'FireUser2')] - out_disable = [Output('', '', 'FireUser1')] - for output in conf_outputs: - if output.output.casefold() == 'onenabled': - out_enable.append(output.copy()) - else: - out_disable.append(output.copy()) - - if conf_toggle_targ: - # Make the group info_target into a texturetoggle. - toggle = group.item.inst - toggle['classname'] = 'env_texturetoggle' - toggle['target'] = conditions.local_name(group.nodes[0].inst, conf_toggle_targ) - - group.item.enable_cmd = tuple(out_enable) - group.item.disable_cmd = tuple(out_disable) - - # Node -> index for targetnames. - indexes: Dict[connections.Item, int] = {} - - # For cables, it's a bit trickier than the beams. - # The cable ent itself is the one which decides what it links to, - # so we need to potentially make endpoint cables at locations with - # only "incoming" lines. - # So this dict is either a targetname to indicate cables with an - # outgoing connection, or the entity for endpoints without an outgoing - # connection. - cable_points: Dict[connections.Item, Union[Entity, str]] = {} - - for i, node in enumerate(group.nodes, start=1): - indexes[node] = i - node.name = base_name - - sprite_pos = conf_glow_height.copy() - sprite_pos.localise( - Vec.from_str(node.inst['origin']), - Vec.from_str(node.inst['angles']), - ) - - if glow_conf: - # First add the sprite at the right height. - sprite = vmf.create_ent('env_sprite') - for prop in glow_conf: - sprite[prop.name] = conditions.resolve_value(node.inst, prop.value) - - sprite['origin'] = sprite_pos - sprite['targetname'] = NAME_SPR(base_name, i) - elif beam_conf: - # If beams but not sprites, we need a target. - vmf.create_ent( - 'info_target', - origin=sprite_pos, - targetname=NAME_SPR(base_name, i), - ) - - if beam_conf: - # Now the beam going from below up to the sprite. - beam_pos = conf_las_start.copy() - beam_pos.localise( - Vec.from_str(node.inst['origin']), - Vec.from_str(node.inst['angles']), - ) - beam = vmf.create_ent('env_beam') - for prop in beam_conf: - beam[prop.name] = conditions.resolve_value(node.inst, prop.value) - - beam['origin'] = beam['targetpoint'] = beam_pos - beam['targetname'] = NAME_BEAM_LOW(base_name, i) - beam['LightningStart'] = beam['targetname'] - beam['LightningEnd'] = NAME_SPR(base_name, i) - beam['spawnflags'] = conf_beam_flags | 128 # Shade Start - - if beam_conf: - for i, (node_a, node_b) in enumerate(group.links): - beam = vmf.create_ent('env_beam') - conditions.set_ent_keys(beam, node_a.inst, res, 'BeamKeys') - beam['origin'] = beam['targetpoint'] = node_a.inst['origin'] - beam['targetname'] = NAME_BEAM_CONN(base_name, i) - beam['LightningStart'] = NAME_SPR(base_name, indexes[node_a]) - beam['LightningEnd'] = NAME_SPR(base_name, indexes[node_b]) - beam['spawnflags'] = conf_beam_flags - - # We have a couple different situations to deal with here. - # Either end could Not exist, be Unlinked, or be Linked = 8 combos. - # Always flip so we do A to B. - # AB | - # NN | Make 2 new ones, one is an endpoint. - # NU | Flip, do UN. - # NL | Make A, link A to B. Both are linked. - # UN | Make B, link A to B. B is unlinked. - # UU | Link A to B, A is now linked, B is unlinked. - # UL | Link A to B. Both are linked. - # LN | Flip, do NL. - # LU | Flip, do UL - # LL | Make A, link A to B. Both are linked. - if cable_conf: - rope_ind = 0 # Uniqueness value. - for node_a, node_b in group.links: - state_a, ent_a = RopeState.from_node(cable_points, node_a) - state_b, ent_b = RopeState.from_node(cable_points, node_b) - - if (state_a is RopeState.LINKED - or (state_a is RopeState.NONE and - state_b is RopeState.UNLINKED) - ): - # Flip these, handle the opposite order. - state_a, state_b = state_b, state_a - ent_a, ent_b = ent_b, ent_a - node_a, node_b = node_b, node_a - - pos_a = conf_rope_off.copy() - pos_a.localise( - Vec.from_str(node_a.inst['origin']), - Vec.from_str(node_a.inst['angles']), - ) - - pos_b = conf_rope_off.copy() - pos_b.localise( - Vec.from_str(node_b.inst['origin']), - Vec.from_str(node_b.inst['angles']), - ) - - # Need to make the A rope if we don't have one that's unlinked. - if state_a is not RopeState.UNLINKED: - rope_a = vmf.create_ent('move_rope') - for prop in beam_conf: - rope_a[prop.name] = conditions.resolve_value(node_a.inst, prop.value) - rope_a['origin'] = pos_a - rope_ind += 1 - rope_a['targetname'] = NAME_CABLE(base_name, rope_ind) - else: - # It is unlinked, so it's the rope to use. - rope_a = ent_a - - # Only need to make the B rope if it doesn't have one. - if state_b is RopeState.NONE: - rope_b = vmf.create_ent('move_rope') - for prop in beam_conf: - rope_b[prop.name] = conditions.resolve_value(node_b.inst, prop.value) - rope_b['origin'] = pos_b - rope_ind += 1 - name_b = rope_b['targetname'] = NAME_CABLE(base_name, rope_ind) - - cable_points[node_b] = rope_b # Someone can use this. - elif state_b is RopeState.UNLINKED: - # Both must be unlinked, we aren't using this link though. - name_b = ent_b['targetname'] - else: # Linked, we just have the name. - name_b = ent_b - - # By here, rope_a should be an unlinked rope, - # and name_b should be a name to link to. - rope_a['nextkey'] = name_b - - # Figure out how much slack to give. - # If on floor, we need to be taut to have clearance. - - if on_floor(node_a) or on_floor(node_b): - rope_a['slack'] = 60 - else: - rope_a['slack'] = 125 - - # We're always linking A to B, so A is always linked! - if state_a is not RopeState.LINKED: - cable_points[node_a] = rope_a['targetname'] - - return conditions.RES_EXHAUSTED diff --git a/src/precomp/conditions/antlines.py b/src/precomp/conditions/antlines.py new file mode 100644 index 000000000..9c96c9a96 --- /dev/null +++ b/src/precomp/conditions/antlines.py @@ -0,0 +1,601 @@ +"""Items dealing with antlines - Antline Corners and Antlasers.""" +from __future__ import annotations +from enum import Enum +from typing import Callable, Union +import attr + +from precomp import instanceLocs, connections, conditions, antlines, tiling +import srctools.logger +from precomp.conditions import make_result +from srctools import VMF, Property, Output, Vec, Entity, Angle, Matrix + + +COND_MOD_NAME = None + +LOGGER = srctools.logger.get_logger(__name__, alias='cond.antlines') + +# Antlasers have their own visuals, so they need an item to stay. +CONFIG_ANTLASER = connections.Config( + '', + input_type=connections.InputType.OR, + output_act=(None, 'OnUser2'), + output_deact=(None, 'OnUser1'), +) +# But antline corners just place antlines, and can collapse into other items. +CONFIG_ANTLINE = connections.Config( + '', + input_type=connections.InputType.OR_LOGIC, +) + +NAME_SPR: Callable[[str, int], str] = '{}-fx_sp_{}'.format +NAME_BEAM_LOW: Callable[[str, int], str] = '{}-fx_b_low_{}'.format +NAME_BEAM_CONN: Callable[[str, int], str] = '{}-fx_b_conn_{}'.format +NAME_CABLE: Callable[[str, int], str] = '{}-cab_{}'.format + + +CORNER_POS = [ + Vec(8.0, 56.0, -64.0), + Vec(8.0, 40.0, -64.0), + Vec(8.0, 24.0, -64.0), + Vec(8.0, 8.0, -64.0), + Vec(-8.0, 56.0, -64.0), + Vec(-8.0, 40.0, -64.0), + Vec(-8.0, 24.0, -64.0), + Vec(-8.0, 8.0, -64.0), +] + +class NodeType(Enum): + """Handle our two types of item.""" + CORNER = 'corner' + LASER = 'laser' + + +@attr.define(eq=False) +class Node: + """A node placed in the map.""" + type: NodeType + inst: Entity + item: connections.Item + pos: Vec + orient: Matrix + # Group has been found yet for this node? + is_grouped: bool = False + # Track if an input was set, to force a corner overlay. + had_input: bool = False + + @property + def on_floor(self) -> bool: + """Check if this node is on the floor.""" + return self.orient.up().z > 0.9 + + +class RopeState(Enum): + """Used to link up ropes.""" + NONE = 'none' # No rope here. + UNLINKED = 'unlinked' # Rope ent, with no target. + LINKED = 'linked' # Rope ent, with target already. + + @staticmethod + def from_node( + points: dict[Node, Union[Entity, str]], + node: Node, + ) -> tuple[RopeState, Union[Entity, str]]: + """Compute the state and ent/name from the points data.""" + try: + ent = points[node] + except KeyError: + return RopeState.NONE, '' + if isinstance(ent, str): + return RopeState.LINKED, ent + else: + return RopeState.UNLINKED, ent + + +class Group: + """Represents a group of markers.""" + def __init__(self, start: Node, typ: NodeType) -> None: + self.type = typ # Antlaser or corner? + self.nodes: list[Node] = [start] + # We use a frozenset here to ensure we don't double-up the links - + # users might accidentally do that. + self.links: set[frozenset[Node]] = set() + + # For antline corners, each endpoint + normal -> the segment + self.ant_seg: dict[tuple[ + tuple[float, float, float], + tuple[float, float, float], + ], antlines.Segment] = {} + + # Create a comp_relay to attach I/O to. + # The corners have an origin on the floor whereas lasers are normal. + if typ is NodeType.CORNER: + logic_pos = start.pos + 8 * start.orient.up() + logic_conf = CONFIG_ANTLINE + else: + logic_pos = start.pos - 56 * start.orient.up() + logic_conf = CONFIG_ANTLASER + logic_ent = start.inst.map.create_ent( + 'comp_relay', + origin=logic_pos, + targetname=start.item.name, + ) + + # Create the item for the entire group of markers. + self.item = connections.Item( + logic_ent, logic_conf, + ant_floor_style=start.item.ant_floor_style, + ant_wall_style=start.item.ant_wall_style, + ) + connections.ITEMS[self.item.name] = self.item + + def add_ant_straight(self, normal: Vec, pos1: Vec, pos2: Vec) -> None: + """Add a segment going from point 1 to 2.""" + if pos1 == pos2: + # Zero long, just skip placing. + # This occurs if placed right on the edge as we wrap around a voxel + # corner. + return + + seg = antlines.Segment( + antlines.SegType.STRAIGHT, + round(normal, 3), + pos1, pos2, + ) + norm_key = seg.normal.as_tuple() + k1 = pos1.as_tuple(), norm_key + k2 = pos2.as_tuple(), norm_key + if k1 in self.ant_seg: + LOGGER.warning('Antline segment overlap: {}', k1) + if k2 in self.ant_seg: + LOGGER.warning('Antline segment overlap: {}', k2) + self.ant_seg[k1] = seg + self.ant_seg[k2] = seg + + def rem_ant_straight(self, norm: tuple[float, float, float], endpoint: Vec) -> Vec: + """Remove an antline segment with this enpoint, and return its other. + + This is used for merging corners. We already checked it's valid. + """ + seg = self.ant_seg.pop((endpoint.as_tuple(), norm)) + if seg.start == endpoint: + del self.ant_seg[seg.end.as_tuple(), norm] + return seg.end + elif seg.end == endpoint: + del self.ant_seg[seg.start.as_tuple(), norm] + return seg.start + else: + raise ValueError(f'Antline {seg} has no endpoint {endpoint}!') + + +@make_result('AntLaser') +def res_antlaser(vmf: VMF, res: Property) -> object: + """The condition to generate AntLasers and Antline Corners. + + This is executed once to modify all instances. + """ + conf_inst_corner = instanceLocs.resolve('', silent=True) + conf_inst_laser = instanceLocs.resolve(res['instance']) + conf_glow_height = Vec(z=res.float('GlowHeight', 48) - 64) + conf_las_start = Vec(z=res.float('LasStart') - 64) + conf_rope_off = res.vec('RopePos') + conf_toggle_targ = res['toggleTarg', ''] + + beam_conf = res.find_key('BeamKeys', or_blank=True) + glow_conf = res.find_key('GlowKeys', or_blank=True) + cable_conf = res.find_key('CableKeys', or_blank=True) + + if beam_conf: + # Grab a copy of the beam spawnflags so we can set our own options. + conf_beam_flags = beam_conf.int('spawnflags') + # Mask out certain flags. + conf_beam_flags &= ( + 0 + | 1 # Start On + | 2 # Toggle + | 4 # Random Strike + | 8 # Ring + | 16 # StartSparks + | 32 # EndSparks + | 64 # Decal End + #| 128 # Shade Start + #| 256 # Shade End + #| 512 # Taper Out + ) + else: + conf_beam_flags = 0 + + conf_outputs = [ + Output.parse(prop) + for prop in res + if prop.name in ('onenabled', 'ondisabled') + ] + + # Find all the markers. + nodes: dict[str, Node] = {} + + for inst in vmf.by_class['func_instance']: + filename = inst['file'].casefold() + name = inst['targetname'] + if filename in conf_inst_laser: + node_type = NodeType.LASER + elif filename in conf_inst_corner: + node_type = NodeType.CORNER + else: + continue + + try: + # Remove the item - it's no longer going to exist after + # we're done. + item = connections.ITEMS.pop(name) + except KeyError: + raise ValueError('No item for "{}"?'.format(name)) from None + pos = Vec.from_str(inst['origin']) + orient = Matrix.from_angle(Angle.from_str(inst['angles'])) + if node_type is NodeType.CORNER: + timer_delay = item.inst.fixup.int('$timer_delay') + # We treat inf, 1, 2 and 3 as the same, to get around the 1 and 2 not + # being selectable issue. + pos = CORNER_POS[max(0, timer_delay - 3) % 8] @ orient + pos + nodes[name] = Node(node_type, inst, item, pos, orient) + + if not nodes: + # None at all. + return conditions.RES_EXHAUSTED + + # Now find every connected group, recording inputs, outputs and links. + todo = set(nodes.values()) + + groups: list[Group] = [] + + while todo: + start = todo.pop() + # Synthesise the Item used for logic. + # We use a random info_target to manage the IO data. + group = Group(start, start.type) + groups.append(group) + for node in group.nodes: + # If this node has no non-node outputs, destroy the antlines. + has_output = False + node.is_grouped = True + + for conn in list(node.item.outputs): + neighbour = conn.to_item + neigh_node = nodes.get(neighbour.name, None) + todo.discard(neigh_node) + if neigh_node is None or neigh_node.type is not node.type: + # Not a node or different item type, it must therefore + # be a target of our logic. + conn.from_item = group.item + has_output = True + continue + elif not neigh_node.is_grouped: + # Another node. + group.nodes.append(neigh_node) + # else: True, node already added. + + # For nodes, connect link. + conn.remove() + group.links.add(frozenset({node, neigh_node})) + + # If we have a real output, we need to transfer it. + # Otherwise we can just destroy it. + if has_output: + node.item.transfer_antlines(group.item) + else: + node.item.delete_antlines() + + # Do the same for inputs, so we can catch that. + for conn in list(node.item.inputs): + neighbour = conn.from_item + neigh_node = nodes.get(neighbour.name, None) + todo.discard(neigh_node) + if neigh_node is None or neigh_node.type is not node.type: + # Not a node or different item type, it must therefore + # be a target of our logic. + conn.to_item = group.item + node.had_input = True + continue + elif not neigh_node.is_grouped: + # Another node. + group.nodes.append(neigh_node) + # else: True, node already added. + + # For nodes, connect link. + conn.remove() + group.links.add(frozenset({neigh_node, node})) + + # Now every node is in a group. Generate the actual entities. + for group in groups: + # We generate two ent types. For each marker, we add a sprite + # and a beam pointing at it. Then for each connection + # another beam. + + # Choose a random item name to use for our group. + base_name = group.nodes[0].item.name + + out_enable = [Output('', '', 'FireUser2')] + out_disable = [Output('', '', 'FireUser1')] + if group.type is NodeType.LASER: + for output in conf_outputs: + if output.output.casefold() == 'onenabled': + out_enable.append(output.copy()) + else: + out_disable.append(output.copy()) + + group.item.enable_cmd = tuple(out_enable) + group.item.disable_cmd = tuple(out_disable) + + if group.type is NodeType.LASER and conf_toggle_targ: + # Make the group info_target into a texturetoggle. + toggle = group.item.inst + toggle['classname'] = 'env_texturetoggle' + toggle['target'] = conditions.local_name(group.nodes[0].inst, conf_toggle_targ) + + # Node -> index for targetnames. + indexes: dict[Node, int] = {} + + # For antline corners, the antline segments. + segments: list[antlines.Segment] = [] + + # frozenset[Node] unpacking isn't clear. + node_a: Node + node_b: Node + + if group.type is NodeType.CORNER: + for node_a, node_b in group.links: + # Place a straight antline between each connected node. + # If on the same plane, we only need one. If not, we need to + # do one for each plane it's in. + offset = node_b.pos - node_a.pos + up_a = node_a.orient.up() + up_b = node_b.orient.up() + plane_a = Vec.dot(node_a.pos, up_a) + plane_b = Vec.dot(node_b.pos, up_b) + if Vec.dot(up_a, up_b) > 0.9: + if abs(plane_a - plane_b) > 1e-6: + LOGGER.warning( + 'Antline corners "{}" - "{}" ' + 'are on different planes', + node_a.item.name, node_b.item.name, + ) + continue + u = node_a.orient.left() + v = node_a.orient.forward() + # Which are we aligned to? + if abs(Vec.dot(offset, u)) < 1e-6 or abs(Vec.dot(offset, v)) < 1e-6: + forward = offset.norm() + group.add_ant_straight( + up_a, + node_a.pos + 8.0 * forward, + node_b.pos - 8.0 * forward, + ) + else: + LOGGER.warning( + 'Antline corners "{}" - "{}" ' + 'are not directly aligned', + node_a.item.name, node_b.item.name, + ) + else: + # We expect them be aligned to each other. + side = Vec.cross(up_a, up_b) + if abs(Vec.dot(side, offset)) < 1e-6: + mid1 = node_a.pos + Vec.dot(offset, up_b) * up_b + mid2 = node_b.pos - Vec.dot(offset, up_a) * up_a + if mid1 != mid2: + LOGGER.warning( + 'Midpoint mismatch: {} != {} for "{}" - "{}"', + mid1, mid2, + node_a.item.name, node_b.item.name, + ) + group.add_ant_straight( + up_a, + node_a.pos + 8.0 * (mid1 - node_a.pos).norm(), + mid1, + ) + group.add_ant_straight( + up_b, + node_b.pos + 8.0 * (mid2 - node_b.pos).norm(), + mid2, + ) + + # For cables, it's a bit trickier than the beams. + # The cable ent itself is the one which decides what it links to, + # so we need to potentially make endpoint cables at locations with + # only "incoming" lines. + # So this dict is either a targetname to indicate cables with an + # outgoing connection, or the entity for endpoints without an outgoing + # connection. + cable_points: dict[Node, Union[Entity, str]] = {} + + for i, node in enumerate(group.nodes, start=1): + indexes[node] = i + node.item.name = base_name + + if group.type is NodeType.CORNER: + node.inst.remove() + # Figure out whether we want a corner at this point, or + # just a regular dot. If a non-node input was provided it's + # always a corner. Otherwise it's one if there's an L, T or X + # junction. + use_corner = True + norm = node.orient.up().as_tuple() + if not node.had_input: + neighbors = [ + mag * direction for direction in [ + node.orient.forward(), + node.orient.left(), + ] for mag in [-8.0, 8.0] + if ((node.pos + mag * direction).as_tuple(), norm) in group.ant_seg + ] + if len(neighbors) == 2: + [off1, off2] = neighbors + if Vec.dot(off1, off2) < -0.99: + # ---o---, merge together. The endpoints we want + # are the other ends of the two segments. + group.add_ant_straight( + node.orient.up(), + group.rem_ant_straight(norm, node.pos + off1), + group.rem_ant_straight(norm, node.pos + off2), + ) + use_corner = False + elif len(neighbors) == 1: + # o-----, merge. + [offset] = neighbors + group.add_ant_straight( + node.orient.up(), + group.rem_ant_straight(norm, node.pos + offset), + node.pos - offset, + ) + use_corner = False + if use_corner: + segments.append(antlines.Segment( + antlines.SegType.CORNER, + round(node.orient.up(), 3), + Vec(node.pos), + Vec(node.pos), + )) + elif group.type is NodeType.LASER: + sprite_pos = node.pos + conf_glow_height @ node.orient + + if glow_conf: + # First add the sprite at the right height. + sprite = vmf.create_ent('env_sprite') + for prop in glow_conf: + sprite[prop.name] = conditions.resolve_value(node.inst, prop.value) + + sprite['origin'] = sprite_pos + sprite['targetname'] = NAME_SPR(base_name, i) + elif beam_conf: + # If beams but not sprites, we need a target. + vmf.create_ent( + 'info_target', + origin=sprite_pos, + targetname=NAME_SPR(base_name, i), + ) + + if beam_conf: + # Now the beam going from below up to the sprite. + beam_pos = node.pos + conf_las_start @ node.orient + beam = vmf.create_ent('env_beam') + for prop in beam_conf: + beam[prop.name] = conditions.resolve_value(node.inst, prop.value) + + beam['origin'] = beam['targetpoint'] = beam_pos + beam['targetname'] = NAME_BEAM_LOW(base_name, i) + beam['LightningStart'] = beam['targetname'] + beam['LightningEnd'] = NAME_SPR(base_name, i) + beam['spawnflags'] = conf_beam_flags | 128 # Shade Start + + segments += set(group.ant_seg.values()) + if group.type is NodeType.CORNER and segments: + group.item.antlines.add(antlines.Antline(group.item.name + '_antline', segments)) + + if group.type is NodeType.LASER and beam_conf: + for i, (node_a, node_b) in enumerate(group.links): + beam = vmf.create_ent('env_beam') + conditions.set_ent_keys(beam, node_a.inst, res, 'BeamKeys') + beam['origin'] = beam['targetpoint'] = node_a.pos + beam['targetname'] = NAME_BEAM_CONN(base_name, i) + beam['LightningStart'] = NAME_SPR(base_name, indexes[node_a]) + beam['LightningEnd'] = NAME_SPR(base_name, indexes[node_b]) + beam['spawnflags'] = conf_beam_flags + + if group.type is NodeType.LASER and cable_conf: + build_cables( + vmf, + group, + cable_points, + base_name, + beam_conf, + conf_rope_off, + ) + + return conditions.RES_EXHAUSTED + + +def build_cables( + vmf: VMF, + group: Group, + cable_points: dict[Node, Union[Entity, str]], + base_name: str, + beam_conf: Property, + conf_rope_off: Vec, +) -> None: + """Place Old-Aperture style cabling.""" + # We have a couple different situations to deal with here. + # Either end could Not exist, be Unlinked, or be Linked = 8 combos. + # Always flip so we do A to B. + # AB | + # NN | Make 2 new ones, one is an endpoint. + # NU | Flip, do UN. + # NL | Make A, link A to B. Both are linked. + # UN | Make B, link A to B. B is unlinked. + # UU | Link A to B, A is now linked, B is unlinked. + # UL | Link A to B. Both are linked. + # LN | Flip, do NL. + # LU | Flip, do UL + # LL | Make A, link A to B. Both are linked. + rope_ind = 0 # Uniqueness value. + node_a: Node + node_b: Node + rope_a: Entity + rope_b: Entity + for node_a, node_b in group.links: + state_a, ent_a = RopeState.from_node(cable_points, node_a) + state_b, ent_b = RopeState.from_node(cable_points, node_b) + + if (state_a is RopeState.LINKED + or (state_a is RopeState.NONE and + state_b is RopeState.UNLINKED)): + # Flip these, handle the opposite order. + state_a, state_b = state_b, state_a + ent_a, ent_b = ent_b, ent_a + node_a, node_b = node_b, node_a + + pos_a = node_a.pos + conf_rope_off @ node_a.orient + pos_b = node_b.pos + conf_rope_off @ node_b.orient + + # Need to make the A rope if we don't have one that's unlinked. + if state_a is not RopeState.UNLINKED: + rope_a = vmf.create_ent('move_rope') + for prop in beam_conf: + rope_a[prop.name] = node_a.inst.fixup.substitute(node_a.inst, prop.value) + rope_a['origin'] = pos_a + rope_ind += 1 + rope_a['targetname'] = NAME_CABLE(base_name, rope_ind) + else: + # It is unlinked, so it's the rope to use. + assert isinstance(ent_a, Entity) + rope_a = ent_a + + # Only need to make the B rope if it doesn't have one. + if state_b is RopeState.NONE: + rope_b = vmf.create_ent('move_rope') + for prop in beam_conf: + rope_b[prop.name] = node_b.inst.fixup.substitute(prop.value) + rope_b['origin'] = pos_b + rope_ind += 1 + name_b = rope_b['targetname'] = NAME_CABLE(base_name, rope_ind) + + cable_points[node_b] = rope_b # Someone can use this. + elif state_b is RopeState.UNLINKED: + # Both must be unlinked, we aren't using this link though. + assert isinstance(ent_b, Entity) + name_b = ent_b['targetname'] + else: # Linked, we just have the name. + name_b = ent_b + + # By here, rope_a should be an unlinked rope, + # and name_b should be a name to link to. + rope_a['nextkey'] = name_b + + # Figure out how much slack to give. + # If on floor, we need to be taut to have clearance. + if node_a.on_floor or node_b.on_floor: + rope_a['slack'] = 60 + else: + rope_a['slack'] = 125 + + # We're always linking A to B, so A is always linked! + if state_a is not RopeState.LINKED: + cable_points[node_a] = rope_a['targetname'] diff --git a/src/precomp/conditions/brushes.py b/src/precomp/conditions/brushes.py index 005c44abc..4ecf7c843 100644 --- a/src/precomp/conditions/brushes.py +++ b/src/precomp/conditions/brushes.py @@ -1,15 +1,15 @@ """Results relating to brushwork.""" from __future__ import annotations -import random -from typing import Optional, Callable, Iterable +from typing import Callable, Iterable from collections import defaultdict +from random import Random from srctools import Property, NoKeyError, Output, Entity, VMF from srctools.math import Vec, Angle, Matrix, to_matrix import srctools.logger from precomp import ( - conditions, tiling, texturing, + conditions, tiling, texturing, rand, instance_traits, brushLoc, faithplate, template_brush, ) import vbsp @@ -106,6 +106,7 @@ def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): if axis.x > 0 or axis.y > 0 or axis.z > 0: # If it points forward, we need to reverse the rotating door reverse = not reverse + axis = abs(axis) try: flag_values = FLAG_ROTATING[door_type] @@ -114,9 +115,12 @@ def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): return name = res['ModifyTarget', ''] + door_ent: Entity | None if name: name = conditions.local_name(ent, name) setter_loc = ent['origin'] + door_ent = None + spawnflags = 0 else: # Generate a brush. name = conditions.local_name(ent, res['name', '']) @@ -129,14 +133,14 @@ def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): classname=door_type, targetname=name, origin=pos.join(' '), - # Extra stuff to apply to the flags (USE, toggle, etc) - spawnflags=sum(map( - # Add together multiple values - srctools.conv_int, - res['flags', '0'].split('+') - # Make the door always non-solid! - )) | flag_values.get('solid_flags', 0), ) + # Extra stuff to apply to the flags (USE, toggle, etc) + spawnflags = sum(map( + # Add together multiple values + srctools.conv_int, + res['flags', '0'].split('+') + # Make the door always non-solid! + )) | flag_values.get('solid_flags', 0) conditions.set_ent_keys(door_ent, ent, res) @@ -157,21 +161,29 @@ def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): # Generate brush door_ent.solids = [vmf.make_prism(pos - 1, pos + 1).solid] - # Add or remove flags as needed by creating KV setters. - + # Add or remove flags as needed for flag, value in zip( ('x', 'y', 'z', 'rev'), - [axis.x != 0, axis.y != 0, axis.z != 0, reverse], + [axis.x > 1e-6, axis.y > 1e-6, axis.z > 1e-6, reverse], ): - if flag in flag_values: + if flag not in flag_values: + continue + if door_ent is not None: + if value: + spawnflags |= flag_values[flag] + else: + spawnflags &= ~flag_values[flag] + else: # Place a KV setter to set this. vmf.create_ent( 'comp_kv_setter', origin=setter_loc, target=name, mode='flags', kv_name=flag_values[flag], - kv_value_local=value, + kv_value_global=value, ) + if door_ent is not None: + door_ent['spawnflags'] = spawnflags # This ent uses a keyvalue for reversing... if door_type == 'momentary_rot_button': @@ -181,7 +193,7 @@ def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): target=name, mode='kv', kv_name='StartDirection', - kv_value_local='1' if reverse else '-1', + kv_value_global='1' if reverse else '-1', ) @@ -313,11 +325,6 @@ def res_add_brush(vmf: VMF, inst: Entity, res: Property) -> None: else: tile_grids[axis] = tiling.TileSize.TILE_4x4 - grid_offset = origin // 128 # type: Vec - - # All brushes in each grid have the same textures for each side. - random.seed(grid_offset.join(' ') + '-partial_block') - solids = vmf.make_prism(point1, point2) solids.north.mat = texturing.gen( @@ -361,23 +368,83 @@ def res_add_brush(vmf: VMF, inst: Entity, res: Property) -> None: vmf.add_brush(solids.solid) -@conditions.make_result_setup('TemplateBrush') -def res_import_template_setup(res: Property): +@conditions.make_result('TemplateBrush') +def res_import_template(vmf: VMF, res: Property): + """Import a template VMF file, retexturing it to match orientation. + + It will be placed overlapping the given instance. If no block is used, only + ID can be specified. + Options: + + - `ID`: The ID of the template to be inserted. Add visgroups to additionally + add after a colon, comma-seperated (`temp_id:vis1,vis2`). + Either section, or the whole value can be a `$fixup`. + - `angles`: Override the instance rotation, so it is always rotated this much. + - `rotation`: Apply the specified rotation before the instance's rotation. + - `offset`: Offset the template from the instance's position. + - `force`: a space-seperated list of overrides. If 'white' or 'black' is + present, the colour of tiles will be overridden. If `invert` is + added, white/black tiles will be swapped. If a tile size + (`2x2`, `4x4`, `wall`, `special`) is included, all tiles will + be switched to that size (if not a floor/ceiling). If 'world' or + 'detail' is present, the brush will be forced to that type. + - `replace`: A block of template material -> replacement textures. + This is case insensitive - any texture here will not be altered + otherwise. If the material starts with a `#`, it is instead a + list of face IDs separated by spaces. If the result evaluates + to "", no change occurs. Both can be $fixups (parsed first). + - `bindOverlay`: Bind overlays in this template to the given surface, and + bind overlays on a surface to surfaces in this template. + The value specifies the offset to the surface, where 0 0 0 is the + floor position. It can also be a block of multiple positions. + - `alignBindOverlay`: If set, align the bindOverlay offsets to the grid. + - `keys`/`localkeys`: If set, a brush entity will instead be generated with + these values. This overrides force world/detail. + Specially-handled keys: + - `"origin"`, offset automatically. + - `"movedir"` on func_movelinear - set a normal surrounded by `<>`, + this gets replaced with angles. + - `colorVar`: If this fixup var is set + to `white` or `black`, that colour will be forced. + If the value is ``, the colour will be chosen based on + the color of the surface for ItemButtonFloor, funnels or + entry/exit frames. + - `invertVar`: If this fixup value is true, tile colour will be + swapped to the opposite of the current force option. This applies + after colorVar. + - `visgroup`: Sets how visgrouped parts are handled. Several values are possible: + - A property block: Each name should match a visgroup, and the + value should be a block of flags that if true enables that group. + - 'none' (default): All extra groups are ignored. + - 'choose': One group is chosen randomly. + - a number: The percentage chance for each visgroup to be added. + - `visgroup_force_var`: If set and True, visgroup is ignored and all groups + are added. + - `pickerVars`: + If this is set, the results of colorpickers can be read + out of the template. The key is the name of the picker, the value + is the fixup name to write to. The output is either 'white', + 'black' or ''. + - `outputs`: Add outputs to the brush ent. Syntax is like VMFs, and all names + are local to the instance. + - `senseOffset`: If set, colorpickers and tilesetters will be treated + as being offset by this amount. + """ if res.has_children(): - temp_id = res['id'] + orig_temp_id = res['id'] else: - temp_id = res.value + orig_temp_id = res.value res = Property('TemplateBrush', []) force = res['force', ''].casefold().split() if 'white' in force: - force_colour = texturing.Portalable.white + conf_force_colour = texturing.Portalable.white elif 'black' in force: - force_colour = texturing.Portalable.black + conf_force_colour = texturing.Portalable.black elif 'invert' in force: - force_colour = 'INVERT' + conf_force_colour = 'INVERT' else: - force_colour = None + conf_force_colour = None if 'world' in force: force_type = template_brush.TEMP_TYPES.world @@ -386,7 +453,7 @@ def res_import_template_setup(res: Property): else: force_type = template_brush.TEMP_TYPES.default - force_grid: Optional[texturing.TileSize] + force_grid: texturing.TileSize | None size: texturing.TileSize for size in texturing.TileSize: if size in force: @@ -402,28 +469,29 @@ def res_import_template_setup(res: Property): else: surf_cat = texturing.GenCat.NORMAL - replace_tex = defaultdict(list) - for prop in res.find_key('replace', []): - replace_tex[prop.name].append(prop.value) + replace_tex: dict[str, list[str]] = {} + for prop in res.find_block('replace', or_blank=True): + replace_tex.setdefault(prop.name, []).append(prop.value) if 'replaceBrush' in res: LOGGER.warning( 'replaceBrush command used for template "{}", which is no ' 'longer used.', - temp_id, + orig_temp_id, ) bind_tile_pos = [ # So it's the floor block location. Vec.from_str(value) - (0, 0, 128) for value in - res.find_key('BindOverlay', []).as_array() + res.find_key('BindOverlay', or_blank=True).as_array() ] + align_bind_overlay = res.bool('alignBindOverlay') - key_values = res.find_key("Keys", []) + key_values = res.find_block("Keys", or_blank=True) if key_values: - keys = Property("", [ + key_block = Property("", [ key_values, - res.find_key("LocalKeys", []), + res.find_block("LocalKeys", or_blank=True), ]) # Ensure we have a 'origin' keyvalue - we automatically offset that. if 'origin' not in key_values: @@ -438,36 +506,35 @@ def res_import_template_setup(res: Property): res.find_children('Outputs') ] else: - keys = None + key_block = None outputs = [] - visgroup_func: Callable[[set[str]], Iterable[str]] - - def visgroup_func(groups): - """none = don't add any visgroups.""" - return () + # None = don't add any more. + visgroup_func: Callable[[Random, list[str]], Iterable[str]] | None = None - visgroup_prop = res.find_key('visgroup', 'none') + try: # allow both spellings. + visgroup_prop = res.find_key('visgroups') + except NoKeyError: + visgroup_prop = res.find_key('visgroup', 'none') if visgroup_prop.has_children(): - visgroup_vars = list(visgroup_prop) + visgroup_instvars = list(visgroup_prop) else: - visgroup_vars = [] + visgroup_instvars = [] visgroup_mode = res['visgroup', 'none'].casefold() # Generate the function which picks which visgroups to add to the map. if visgroup_mode == 'none': pass elif visgroup_mode == 'choose': - def visgroup_func(groups): + def visgroup_func(rng: Random, groups: list[str]) -> Iterable[str]: """choose = add one random group.""" - return [random.choice(groups)] + return [rng.choice(groups)] else: percent = srctools.conv_float(visgroup_mode.rstrip('%'), 0.00) if percent > 0.0: - def visgroup_func(groups): + def visgroup_func(rng: Random, groups: list[str]) -> Iterable[str]: """Number = percent chance for each to be added""" - for group in groups: - val = random.uniform(0, 100) - if val <= percent: + for group in sorted(groups): + if rng.uniform(0, 100) <= percent: yield group picker_vars = [ @@ -483,228 +550,140 @@ def visgroup_func(groups): except LookupError: rotation = Matrix() - return ( - temp_id, - dict(replace_tex), - force_colour, - force_grid, - force_type, - surf_cat, - bind_tile_pos, - ang_override, - rotation, - res['offset', '0 0 0'], - res['invertVar', ''], - res['colorVar', ''], - visgroup_func, - # If true, force visgroups to all be used. - res['forceVisVar', ''], - visgroup_vars, - keys, - picker_vars, - outputs, - res.vec('senseOffset'), - ) + offset = res['offset', '0 0 0'] + invert_var = res['invertVar', ''] + color_var = res['colorVar', ''] + if color_var.casefold() == '': + color_var = '' + # If true, force visgroups to all be used. + visgroup_force_var = res['forceVisVar', ''] -@conditions.make_result('TemplateBrush') -def res_import_template(vmf: VMF, inst: Entity, res: Property): - """Import a template VMF file, retexturing it to match orientation. + sense_offset = res.vec('senseOffset') - It will be placed overlapping the given instance. If no block is used, only - ID can be specified. - Options: + def place_template(inst: Entity) -> None: + """Place a template.""" + temp_id = inst.fixup.substitute(orig_temp_id) - - `ID`: The ID of the template to be inserted. Add visgroups to additionally - add after a colon, comma-seperated (`temp_id:vis1,vis2`). - Either section, or the whole value can be a `$fixup`. - - `angles`: Override the instance rotation, so it is always rotated this much. - - `rotate`: Apply the specified rotation before the instance's rotation. - - `offset`: Offset the template from the instance's position. - - `force`: a space-seperated list of overrides. If 'white' or 'black' is - present, the colour of tiles will be overridden. If `invert` is - added, white/black tiles will be swapped. If a tile size - (`2x2`, `4x4`, `wall`, `special`) is included, all tiles will - be switched to that size (if not a floor/ceiling). If 'world' or - 'detail' is present, the brush will be forced to that type. - - `replace`: A block of template material -> replacement textures. - This is case insensitive - any texture here will not be altered - otherwise. If the material starts with a `#`, it is instead a - list of face IDs separated by spaces. If the result evaluates - to "", no change occurs. Both can be $fixups (parsed first). - - `bindOverlay`: Bind overlays in this template to the given surface, and - bind overlays on a surface to surfaces in this template. - The value specifies the offset to the surface, where 0 0 0 is the - floor position. It can also be a block of multiple positions. - - `keys`/`localkeys`: If set, a brush entity will instead be generated with - these values. This overrides force world/detail. - Specially-handled keys: - - `"origin"`, offset automatically. - - `"movedir"` on func_movelinear - set a normal surrounded by `<>`, - this gets replaced with angles. - - `colorVar`: If this fixup var is set - to `white` or `black`, that colour will be forced. - If the value is ``, the colour will be chosen based on - the color of the surface for ItemButtonFloor, funnels or - entry/exit frames. - - `invertVar`: If this fixup value is true, tile colour will be - swapped to the opposite of the current force option. This applies - after colorVar. - - `visgroup`: Sets how visgrouped parts are handled. Several values are possible: - - A property block: Each name should match a visgroup, and the - value should be a block of flags that if true enables that group. - - 'none' (default): All extra groups are ignored. - - 'choose': One group is chosen randomly. - - a number: The percentage chance for each visgroup to be added. - - `visgroup_force_var`: If set and True, visgroup is ignored and all groups - are added. - - `pickerVars`: - If this is set, the results of colorpickers can be read - out of the template. The key is the name of the picker, the value - is the fixup name to write to. The output is either 'white', - 'black' or ''. - - `outputs`: Add outputs to the brush ent. Syntax is like VMFs, and all names - are local to the instance. - - `senseOffset`: If set, colorpickers and tilesetters will be treated - as being offset by this amount. - """ - ( - orig_temp_id, - replace_tex, - force_colour, - force_grid, - force_type, - surf_cat, - bind_tile_pos, - ang_override, - rotation, - offset, - invert_var, - color_var, - visgroup_func, - visgroup_force_var, - visgroup_instvars, - key_block, - picker_vars, - outputs, - sense_offset, - ) = res.value - - temp_id = inst.fixup.substitute(orig_temp_id) - - if srctools.conv_bool(conditions.resolve_value(inst, visgroup_force_var)): - def visgroup_func(group): - """Use all the groups.""" - yield from group - - # Special case - if blank, just do nothing silently. - if not temp_id: - return - - temp_name, visgroups = template_brush.parse_temp_name(temp_id) - try: - template = template_brush.get_template(temp_name) - except template_brush.InvalidTemplateName: - # If we did lookup, display both forms. - if temp_id != orig_temp_id: - LOGGER.warning( - '{} -> "{}" is not a valid template!', - orig_temp_id, - temp_name - ) - else: - LOGGER.warning( - '"{}" is not a valid template!', - temp_name - ) - # We don't want an error, just quit. - return + # Special case - if blank, just do nothing silently. + if not temp_id: + return - for vis_flag_block in visgroup_instvars: - if all(conditions.check_flag(vmf, flag, inst) for flag in vis_flag_block): - visgroups.add(vis_flag_block.real_name) + temp_name, visgroups = template_brush.parse_temp_name(temp_id) + try: + template = template_brush.get_template(temp_name) + except template_brush.InvalidTemplateName: + # If we did lookup, display both forms. + if temp_id != orig_temp_id: + LOGGER.warning( + '{} -> "{}" is not a valid template!', + orig_temp_id, + temp_name + ) + else: + LOGGER.warning( + '"{}" is not a valid template!', + temp_name + ) + # We don't want an error, just quit. + return + + for vis_flag_block in visgroup_instvars: + if all(conditions.check_flag(vmf, flag, inst) for flag in vis_flag_block): + visgroups.add(vis_flag_block.real_name) + + force_colour = conf_force_colour + if color_var == '': + # Check traits for the colour it should be. + traits = instance_traits.get(inst) + if 'white' in traits: + force_colour = texturing.Portalable.white + elif 'black' in traits: + force_colour = texturing.Portalable.black + else: + LOGGER.warning( + '"{}": Instance "{}" ' + "isn't one with inherent color!", + temp_id, + inst['file'], + ) + elif color_var: + color_val = conditions.resolve_value(inst, color_var).casefold() - if color_var.casefold() == '': - # Check traits for the colour it should be. - traits = instance_traits.get(inst) - if 'white' in traits: - force_colour = texturing.Portalable.white - elif 'black' in traits: - force_colour = texturing.Portalable.black - else: - LOGGER.warning( - '"{}": Instance "{}" ' - "isn't one with inherent color!", - temp_id, - inst['file'], - ) - elif color_var: - color_val = conditions.resolve_value(inst, color_var).casefold() + if color_val == 'white': + force_colour = texturing.Portalable.white + elif color_val == 'black': + force_colour = texturing.Portalable.black + # else: no color var - if color_val == 'white': - force_colour = texturing.Portalable.white - elif color_val == 'black': - force_colour = texturing.Portalable.black - # else: no color var + if srctools.conv_bool(conditions.resolve_value(inst, invert_var)): + force_colour = template_brush.TEMP_COLOUR_INVERT[conf_force_colour] + # else: False value, no invert. - if srctools.conv_bool(conditions.resolve_value(inst, invert_var)): - force_colour = template_brush.TEMP_COLOUR_INVERT[force_colour] - # else: False value, no invert. + if ang_override is not None: + orient = ang_override + else: + orient = rotation @ Angle.from_str(inst['angles', '0 0 0']) + origin = conditions.resolve_offset(inst, offset) + + # If this var is set, it forces all to be included. + if srctools.conv_bool(conditions.resolve_value(inst, visgroup_force_var)): + visgroups.update(template.visgroups) + elif visgroup_func is not None: + visgroups.update(visgroup_func( + rand.seed(b'temp', template.id, origin, orient), + list(template.visgroups), + )) - if ang_override is not None: - angles = ang_override - else: - angles = rotation @ Angle.from_str(inst['angles', '0 0 0']) - origin = conditions.resolve_offset(inst, offset) - - temp_data = template_brush.import_template( - vmf, - template, - origin, - angles, - targetname=inst['targetname', ''], - force_type=force_type, - visgroup_choose=visgroup_func, - add_to_map=True, - additional_visgroups=visgroups, - bind_tile_pos=bind_tile_pos, - ) + LOGGER.debug('Placing template "{}" at {} with visgroups {}', template.id, origin, visgroups) + + temp_data = template_brush.import_template( + vmf, + template, + origin, + orient, + targetname=inst['targetname', ''], + force_type=force_type, + add_to_map=True, + additional_visgroups=visgroups, + bind_tile_pos=bind_tile_pos, + align_bind=align_bind_overlay, + ) - if key_block is not None: - conditions.set_ent_keys(temp_data.detail, inst, key_block) - br_origin = Vec.from_str(key_block.find_key('keys')['origin']) - br_origin.localise(origin, angles) - temp_data.detail['origin'] = br_origin - - move_dir = temp_data.detail['movedir', ''] - if move_dir.startswith('<') and move_dir.endswith('>'): - move_dir = Vec.from_str(move_dir) @ angles - temp_data.detail['movedir'] = move_dir.to_angle() - - for out in outputs: # type: Output - out = out.copy() - out.target = conditions.local_name(inst, out.target) - temp_data.detail.add_out(out) - - template_brush.retexture_template( - temp_data, - origin, - inst.fixup, - replace_tex, - force_colour, - force_grid, - surf_cat, - sense_offset, - ) + if key_block is not None: + conditions.set_ent_keys(temp_data.detail, inst, key_block) + br_origin = Vec.from_str(key_block.find_key('keys')['origin']) + br_origin.localise(origin, orient) + temp_data.detail['origin'] = br_origin + + move_dir = temp_data.detail['movedir', ''] + if move_dir.startswith('<') and move_dir.endswith('>'): + move_dir = Vec.from_str(move_dir) @ orient + temp_data.detail['movedir'] = move_dir.to_angle() + + for out in outputs: + out = out.copy() + out.target = conditions.local_name(inst, out.target) + temp_data.detail.add_out(out) + + template_brush.retexture_template( + temp_data, + origin, + inst.fixup, + replace_tex, + force_colour, + force_grid, + surf_cat, + sense_offset, + ) - for picker_name, picker_var in picker_vars: - picker_val = temp_data.picker_results.get( - picker_name, None, - ) # type: Optional[texturing.Portalable] - if picker_val is not None: - inst.fixup[picker_var] = picker_val.value - else: - inst.fixup[picker_var] = '' + for picker_name, picker_var in picker_vars: + picker_val = temp_data.picker_results.get(picker_name, None) + if picker_val is not None: + inst.fixup[picker_var] = picker_val.value + else: + inst.fixup[picker_var] = '' + return place_template @conditions.make_result('MarkAntigel') @@ -852,14 +831,16 @@ def res_set_tile(inst: Entity, res: Property) -> None: chance = srctools.conv_float(res['chance', '100'].rstrip('%'), 100.0) if chance < 100.0: - conditions.set_random_seed(inst, 'tile' + res['seed', '']) + rng = rand.seed(b'tile', inst, res['seed', '']) + else: + rng = None for y, row in enumerate(tiles): for x, val in enumerate(row): if val in '_ ': continue - if chance < 100.0 and random.uniform(0, 100) > chance: + if rng is not None and rng.uniform(0, 100) > chance: continue pos = Vec(32 * x, -32 * y, 0) @ orient + offset @@ -872,7 +853,7 @@ def res_set_tile(inst: Entity, res: Property) -> None: size = None else: try: - new_tile = tiling.TILETYPE_FROM_CHAR[val] # type: tiling.TileType + new_tile = tiling.TILETYPE_FROM_CHAR[val] except KeyError: LOGGER.warning('Unknown tiletype "{}"!', val) else: @@ -922,7 +903,7 @@ def res_add_placement_helper(inst: Entity, res: Property): pos = conditions.resolve_offset(inst, res['offset', '0 0 0'], zoff=-64) normal = res.vec('normal', 0, 0, 1) @ orient - up_dir: Optional[Vec] + up_dir: Vec | None try: up_dir = Vec.from_str(res['upDir']) @ orient except LookupError: @@ -1080,7 +1061,7 @@ def edit_panel(vmf: VMF, inst: Entity, props: Property, create: bool) -> None: return # If bevels is provided, parse out the overall world positions. - bevel_world: Optional[set[tuple[int, int]]] + bevel_world: set[tuple[int, int]] | None try: bevel_prop = props.find_key('bevel') except NoKeyError: @@ -1150,8 +1131,8 @@ def edit_panel(vmf: VMF, inst: Entity, props: Property, create: bool) -> None: panel.bevels.clear() for u, v in bevel_world: # Convert from world points to UV positions. - u = (u - tile.pos[uaxis] + 48) / 32 - v = (v - tile.pos[vaxis] + 48) / 32 + u = (u - tile.pos[uaxis] + 48) // 32 + v = (v - tile.pos[vaxis] + 48) // 32 # Cull outside here, we wont't use them. if -1 <= u <= 4 and -1 <= v <= 4: panel.bevels.add((u, v)) @@ -1175,7 +1156,7 @@ def edit_panel(vmf: VMF, inst: Entity, props: Property, create: bool) -> None: # First grab the existing ent, so we can edit it. # These should all have the same value, unless they were independently # edited with mismatching point sets. In that case destroy all those existing ones. - existing_ents: set[Optional[Entity]] = {panel.brush_ent for panel in panels} + existing_ents: set[Entity | None] = {panel.brush_ent for panel in panels} try: [brush_ent] = existing_ents except ValueError: diff --git a/src/precomp/conditions/conveyorBelt.py b/src/precomp/conditions/conveyorBelt.py index 8330a937d..2826eb666 100644 --- a/src/precomp/conditions/conveyorBelt.py +++ b/src/precomp/conditions/conveyorBelt.py @@ -1,6 +1,6 @@ """Continuously moving belts, like in BTS. """ -from srctools import Property, Vec, Entity, Output, VMF +from srctools import Property, Vec, Entity, Output, VMF, Angle, Matrix import srctools.logger from precomp import instanceLocs, template_brush, conditions @@ -26,7 +26,7 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: outputs in VMFs. `RotateSegments`: If true (default), force segments to face in the direction of movement. - * `BeamKeys`: If set, a list of keyvalues to use to generate an env_beam + * `BeamKeys`: If set, a list of keyvalues to use to generate an env_beam travelling from start to end. The origin is treated specially - X is the distance from walls, y is the distance to the side, and z is the height. @@ -43,8 +43,9 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: inst.remove() return - move_dir = Vec(1, 0, 0).rotate_by_str(inst.fixup['$travel_direction']) - move_dir = move_dir.rotate_by_str(inst['angles']) + orig_orient = Matrix.from_angle(Angle.from_str(inst['angles'])) + move_dir = Vec(1, 0, 0) @ Angle.from_str(inst.fixup['$travel_direction']) + move_dir = move_dir @ orig_orient start_offset = inst.fixup.float('$starting_position') teleport_to_start = res.bool('TrackTeleport', True) segment_inst_file = instanceLocs.resolve_one(res['SegmentInst', '']) @@ -66,12 +67,13 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: start_pos, end_pos = end_pos, start_pos inst['origin'] = start_pos - norm = Vec(z=1).rotate_by_str(inst['angles']) + norm = orig_orient.up() if res.bool('rotateSegments', True): - inst['angles'] = angles = move_dir.to_angle_roll(norm) + orient = Matrix.from_basis(x=move_dir, z=norm) + inst['angles'] = orient.to_angle() else: - angles = Vec.from_str(inst['angles']) + orient = orig_orient # Add the EnableMotion trigger_multiple seen in platform items. # This wakes up cubes when it starts moving. @@ -113,7 +115,7 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: targetname=track_name.format(index), file=segment_inst_file, origin=pos, - angles=angles, + angles=orient.to_angle(), ) seg_inst.fixup.update(inst.fixup) @@ -122,7 +124,7 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: vmf, rail_template, pos, - angles, + orient, force_type=template_brush.TEMP_TYPES.world, add_to_map=False, ) @@ -163,10 +165,10 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: del beam['LightningEnd'] beam['origin'] = start_pos + Vec( -beam_off.x, beam_off.y, beam_off.z, - ).rotate(*angles) + ) @ orient beam['TargetPoint'] = end_pos + Vec( +beam_off.x, beam_off.y, beam_off.z, - ).rotate(*angles) + ) @ orient # Allow adding outputs to the last path_track. for prop in res.find_all('EndOutput'): @@ -189,8 +191,8 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: motion_trig.add_out(Output('OnStartTouch', '!activator', 'ExitDisabledState')) # Match the size of the original... motion_trig.solids.append(vmf.make_prism( - start_pos + Vec(72, -56, 58).rotate(*angles), - end_pos + Vec(-72, 56, 144).rotate(*angles), + start_pos + Vec(72, -56, 58) @ orient, + end_pos + Vec(-72, 56, 144) @ orient, mat=consts.Tools.TRIGGER, ).solid) @@ -201,15 +203,15 @@ def res_conveyor_belt(vmf: VMF, inst: Entity, res: Property) -> None: origin=track_start, ) floor_noportal.solids.append(vmf.make_prism( - start_pos + Vec(-60, -60, -66).rotate(*angles), - end_pos + Vec(60, 60, -60).rotate(*angles), + start_pos + Vec(-60, -60, -66) @ orient, + end_pos + Vec(60, 60, -60) @ orient, mat=consts.Tools.INVISIBLE, ).solid) # A brush covering under the platform. base_trig = vmf.make_prism( - start_pos + Vec(-64, -64, 48).rotate(*angles), - end_pos + Vec(64, 64, 56).rotate(*angles), + start_pos + Vec(-64, -64, 48) @ orient, + end_pos + Vec(64, 64, 56) @ orient, mat=consts.Tools.INVISIBLE, ).solid diff --git a/src/precomp/conditions/custItems.py b/src/precomp/conditions/custItems.py index 9bce287e0..872854ab5 100644 --- a/src/precomp/conditions/custItems.py +++ b/src/precomp/conditions/custItems.py @@ -1,7 +1,8 @@ """Results for customising the behaviour of certain items - antlines, faith plates, """ -from typing import Optional, Tuple +from __future__ import annotations +from typing import Callable from srctools import Property, Entity import srctools.logger @@ -13,31 +14,8 @@ LOGGER = srctools.logger.get_logger(__name__, alias='cond.custItems') -@conditions.make_result_setup('custAntline') -def res_cust_antline_setup(res: Property): - if 'wall' in res: - wall_type = antlines.AntType.parse(res.find_key('wall')) - else: - wall_type = None - - if 'floor' in res: - floor_type = antlines.AntType.parse(res.find_key('floor')) - else: - floor_type = wall_type - - return ( - wall_type, - floor_type, - res.bool('remove_signs'), - res['toggle_var', ''], - ) - -CustAntValue = Tuple[Optional[antlines.AntType], Optional[ - antlines.AntType], bool, str] - - @conditions.make_result('custAntline') -def res_cust_antline(inst: Entity, res: Property): +def res_cust_antline_setup(res: Property) -> Callable[[Entity], None]: """Customise the output antlines. Options: @@ -51,21 +29,37 @@ def res_cust_antline(inst: Entity, res: Property): antlines. This is a fixup var which will be set to the name of the overlays, for user control. """ - wall_style, floor_style, remove_signs, toggle_var = res.value # type: CustAntValue - - item = connections.ITEMS[inst['targetname']] - if wall_style is not None: - item.ant_wall_style = wall_style - if floor_style is not None: - item.ant_floor_style = floor_style - - if remove_signs: - for sign in item.ind_panels: - sign.remove() - item.ind_panels.clear() + wall_style: antlines.AntType | None + floor_type: antlines.AntType | None + if 'wall' in res: + wall_style = antlines.AntType.parse(res.find_key('wall')) + else: + wall_style = None - if toggle_var: - item.ant_toggle_var = toggle_var + if 'floor' in res: + floor_style = antlines.AntType.parse(res.find_key('floor')) + else: + floor_style = wall_style + + remove_signs = res.bool('remove_signs') + toggle_var = res['toggle_var', ''] + + def change_antlines(inst: Entity) -> None: + """Change the antlines of an item.""" + item = connections.ITEMS[inst['targetname']] + if wall_style is not None: + item.ant_wall_style = wall_style + if floor_style is not None: + item.ant_floor_style = floor_style + + if remove_signs: + for sign in item.ind_panels: + sign.remove() + item.ind_panels.clear() + + if toggle_var: + item.ant_toggle_var = toggle_var + return change_antlines @conditions.make_result('changeOutputs') diff --git a/src/precomp/conditions/cutoutTile.py b/src/precomp/conditions/cutoutTile.py index a21ffb004..c1734b2aa 100644 --- a/src/precomp/conditions/cutoutTile.py +++ b/src/precomp/conditions/cutoutTile.py @@ -82,6 +82,12 @@ def res_cutout_tile(vmf: srctools.VMF, res: Property): """ marker_filenames = instanceLocs.resolve(res['markeritem']) + # TODO: Reimplement cutout tiles. + for inst in vmf.by_class['func_instance']: + if inst['file'].casefold() in marker_filenames: + inst.remove() + return + x: float y: float max_x: float @@ -199,8 +205,6 @@ def res_cutout_tile(vmf: srctools.VMF, res: Property): inst.remove() del connections.ITEMS[targ] - return # TODO: Reimplement cutout tiles. - for start_floor, end_floor in FLOOR_IO: box_min = Vec(INST_LOCS[start_floor]) box_min.min(INST_LOCS[end_floor]) @@ -498,14 +502,13 @@ def make_tile(vmf: VMF, p1: Vec, p2: Vec, top_mat, bottom_mat, beam_mat): """ prism = vmf.make_prism(p1, p2) - brush, t, b, n, s, e, w = prism - t.mat = top_mat - b.mat = bottom_mat + prism.top.mat = top_mat + prism.bottom.mat = bottom_mat - n.mat = beam_mat - s.mat = beam_mat - e.mat = beam_mat - w.mat = beam_mat + prism.north.mat = beam_mat + prism.south.mat = beam_mat + prism.east.mat = beam_mat + prism.west.mat = beam_mat thickness = abs(p1.z - p2.z) @@ -524,19 +527,19 @@ def make_tile(vmf: VMF, p1: Vec, p2: Vec, top_mat, bottom_mat, beam_mat): '(expected 1 or 2, got {})'.format(thickness) ) - n.uaxis = UVAxis( + prism.north.uaxis = UVAxis( 0, 0, 1, offset=z_off) - n.vaxis = UVAxis( + prism.north.vaxis = UVAxis( 1, 0, 0, offset=0) - s.uaxis = n.uaxis.copy() - s.vaxis = n.vaxis.copy() + prism.south.uaxis = prism.north.uaxis.copy() + prism.south.vaxis = prism.north.vaxis.copy() - e.uaxis = UVAxis( + prism.east.uaxis = UVAxis( 0, 0, 1, offset=z_off) - e.vaxis = UVAxis( + prism.east.vaxis = UVAxis( 0, 1, 0, offset=0) - w.uaxis = e.uaxis.copy() - w.vaxis = e.vaxis.copy() + prism.west.uaxis = prism.east.uaxis.copy() + prism.west.vaxis = prism.east.vaxis.copy() return prism diff --git a/src/precomp/conditions/entities.py b/src/precomp/conditions/entities.py index a60101dd9..997187eb9 100644 --- a/src/precomp/conditions/entities.py +++ b/src/precomp/conditions/entities.py @@ -1,18 +1,13 @@ """Conditions related to specific kinds of entities.""" -import random -from collections import defaultdict -from typing import List, Dict, Tuple - from srctools import Property, Vec, VMF, Entity, Angle import srctools.logger -from precomp import tiling, texturing, template_brush, conditions +from precomp import tiling, texturing, template_brush, conditions, rand from precomp.brushLoc import POS as BLOCK_POS from precomp.conditions import make_result, make_result_setup from precomp.template_brush import TEMP_TYPES COND_MOD_NAME = 'Entities' - LOGGER = srctools.logger.get_logger(__name__, alias='cond.entities') @@ -34,7 +29,7 @@ def res_insert_overlay(vmf: VMF, res: Property): orig_norm = Vec.from_str(res['normal', '0 0 1']) replace_tex: dict[str, list[str]] = {} - for prop in res.find_key('replace', []): + for prop in res.find_children('replace'): replace_tex.setdefault(prop.name.replace('\\', '/'), []).append(prop.value) offset = Vec.from_str(res['offset', '0 0 0']) @@ -80,20 +75,22 @@ def insert_over(inst: Entity) -> None: force_type=TEMP_TYPES.detail, ) - for over in temp.overlay: # type: Entity - random.seed('TEMP_OVERLAY_' + over['basisorigin']) + for over in temp.overlay: + pos = Vec.from_str(over['basisorigin']) mat = over['material'] try: - mat = random.choice(replace_tex[mat.casefold().replace('\\', '/')]) + replace = replace_tex[mat.casefold().replace('\\', '/')] except KeyError: pass + else: + mat = rand.seed(b'temp_over', temp_id, pos).choice(replace) if mat[:1] == '$': mat = inst.fixup[mat] if mat.startswith('<') or mat.endswith('>'): # Lookup in the texture data. gen, mat = texturing.parse_name(mat[1:-1]) - mat = gen.get(Vec.from_str(over['basisorigin']), mat) + mat = gen.get(pos, mat) over['material'] = mat tiledef.bind_overlay(over) @@ -176,14 +173,13 @@ def res_water_splash(vmf: VMF, inst: Entity, res: Property) -> None: if calc_type == 'track_platform': lin_off = srctools.conv_int(inst.fixup['$travel_distance']) - travel_ang = inst.fixup['$travel_direction'] + travel_ang = Angle.from_str(inst.fixup['$travel_direction']) start_pos = srctools.conv_float(inst.fixup['$starting_position']) if start_pos: start_pos = round(start_pos * lin_off) - pos1 += Vec(x=-start_pos).rotate_by_str(travel_ang) + pos1 += Vec(x=-start_pos) @ travel_ang - pos2 = Vec(x=lin_off).rotate_by_str(travel_ang) - pos2 += pos1 + pos2 = Vec(x=lin_off) @ travel_ang + pos1 elif calc_type.startswith('piston'): # Use piston-platform offsetting. # The number is the highest offset to move to. @@ -200,7 +196,7 @@ def res_water_splash(vmf: VMF, inst: Entity, res: Property) -> None: pos2 = Vec.from_str(conditions.resolve_value(inst, pos2)) origin = Vec.from_str(inst['origin']) - angles = Vec.from_str(inst['angles']) + angles = Angle.from_str(inst['angles']) splash_pos.localise(origin, angles) pos1.localise(origin, angles) pos2.localise(origin, angles) @@ -278,7 +274,7 @@ def res_make_funnel_light(inst: Entity) -> None: need_blue = True loc = Vec(0, 0, -56) - loc.localise(Vec.from_str(inst['origin']), Vec.from_str(inst['angles'])) + loc.localise(Vec.from_str(inst['origin']), Angle.from_str(inst['angles'])) if need_blue: inst.map.create_ent( diff --git a/src/precomp/conditions/fizzler.py b/src/precomp/conditions/fizzler.py index 8a8a4fc3a..12607d998 100644 --- a/src/precomp/conditions/fizzler.py +++ b/src/precomp/conditions/fizzler.py @@ -96,8 +96,8 @@ def res_reshape_fizzler(vmf: VMF, shape_inst: Entity, res: Property): fizz_item = connections.Item( base_inst, connections.ITEM_TYPES['item_barrier_hazard'], - shape_item.ant_floor_style, - shape_item.ant_wall_style, + ant_floor_style=shape_item.ant_floor_style, + ant_wall_style=shape_item.ant_wall_style, ) connections.ITEMS[shape_name] = fizz_item diff --git a/src/precomp/conditions/globals.py b/src/precomp/conditions/globals.py index 8a14f9c6e..b4d50b3e4 100644 --- a/src/precomp/conditions/globals.py +++ b/src/precomp/conditions/globals.py @@ -1,6 +1,8 @@ """Conditions related to global properties - stylevars, music, which game, etc.""" +from __future__ import annotations -from typing import Collection, Set, Dict, Tuple +import re +from typing import Optional, Collection from srctools import Vec, Property, Entity, conv_bool, VMF import srctools.logger @@ -12,8 +14,9 @@ LOGGER = srctools.logger.get_logger(__name__, alias='cond.globals') - COND_MOD_NAME = 'Global Properties' +# Match 'name[24]' +BRACE_RE = re.compile(r'([^[]+)\[([0-9]+)]') @make_flag('styleVar') @@ -131,30 +134,6 @@ def res_set_option(res: Property) -> bool: return RES_EXHAUSTED -@make_flag('ItemConfig') -def res_match_item_config(inst: Entity, res: Property) -> bool: - """Check if an Item Config Panel value matches another value. - - * `ID` is the ID of the group. - * `Name` is the name of the widget. - * If `UseTimer` is true, it uses `$timer_delay` to choose the value to use. - * `Value` is the value to compare to. - """ - group_id = res['ID'] - wid_name = res['Name'].casefold() - desired_value = res['Value'] - if res.bool('UseTimer'): - timer_delay = inst.fixup.int('$timer_delay') - else: - timer_delay = None - - conf = options.get_itemconf((group_id, wid_name), None, timer_delay) - if conf is None: # Doesn't exist - return False - - return conf == desired_value - - @make_result('styleVar') def res_set_style_var(res: Property) -> bool: """Set Style Vars. @@ -185,7 +164,7 @@ def res_set_voice_attr(res: Property) -> object: # The set is the set of skins to use. If empty, all are used. -CACHED_MODELS: Dict[str, Tuple[Set[int], Entity]] = {} +CACHED_MODELS: dict[str, tuple[set[int], Entity]] = {} @make_result('PreCacheModel') @@ -199,7 +178,7 @@ def res_pre_cache_model(vmf: VMF, res: Property) -> None: skins = [int(skin) for skin in res['skinset', ''].split()] else: model = res.value - skins = () + skins = [] precache_model(vmf, model, skins) @@ -238,26 +217,59 @@ def precache_model(vmf: VMF, mdl_name: str, skinset: Collection[int]=()) -> None ent['skinset'] = '' +def get_itemconf(inst: Entity, res: Property) -> Optional[str]: + """Implement ItemConfig and GetItemConfig shared logic.""" + timer_delay: Optional[int] + + group_id = res['ID'] + wid_name = inst.fixup.substitute(res['Name']).casefold() + + match = BRACE_RE.match(wid_name) + if match is not None: # Match name[timer], after $fixup substitution. + wid_name, timer_str = match.groups() + # Should not fail, we matched it above. + timer_delay = int(timer_str) + elif res.bool('UseTimer'): + LOGGER.warning( + 'UseTimer is deprecated, use name = "{}[$timer_delay]".', + wid_name, + ) + timer_delay = inst.fixup.int('$timer_delay') + else: + timer_delay = None + + return options.get_itemconf((group_id, wid_name), None, timer_delay) + + +@make_flag('ItemConfig') +def res_match_item_config(inst: Entity, res: Property) -> bool: + """Check if an Item Config Panel value matches another value. + + * `ID` is the ID of the group. + * `Name` is the name of the widget, or "name[timer]" to pick the value for + timer multi-widgets. + * If `UseTimer` is true, it uses `$timer_delay` to choose the value to use. + * `Value` is the value to compare to. + """ + conf = get_itemconf(inst, res) + desired_value = res['Value'] + if conf is None: # Doesn't exist + return False + + return conf == desired_value + + @make_result('GetItemConfig') def res_item_config_to_fixup(inst: Entity, res: Property) -> None: """Load a config from the item config panel onto a fixup. * `ID` is the ID of the group. - * `Name` is the name of the widget. - * `resultVar` is the location to store the value into. + * `Name` is the name of the widget, or "name[timer]" to pick the value for + timer multi-widgets. * If `UseTimer` is true, it uses `$timer_delay` to choose the value to use. + * `resultVar` is the location to store the value into. * `Default` is the default value, if the config isn't found. """ - group_id = res['ID'] - wid_name = res['Name'] default = res['default'] - if res.bool('UseTimer'): - timer_delay = inst.fixup.int('$timer_delay') - else: - timer_delay = None - - inst.fixup[res['ResultVar']] = options.get_itemconf( - (group_id, wid_name), - default, - timer_delay, - ) + conf = get_itemconf(inst, res) + inst.fixup[res['ResultVar']] = conf if conf is not None else default diff --git a/src/precomp/conditions/instances.py b/src/precomp/conditions/instances.py index 0173fc691..59eedd1e2 100644 --- a/src/precomp/conditions/instances.py +++ b/src/precomp/conditions/instances.py @@ -15,9 +15,10 @@ @make_flag('instance') -def flag_file_equal(inst: Entity, flag: Property) -> bool: +def flag_file_equal(flag: Property) -> Callable[[Entity], bool]: """Evaluates True if the instance matches the given file.""" - return inst['file'].casefold() in instanceLocs.resolve(flag.value) + inst_list = instanceLocs.resolve(flag.value) + return lambda inst: inst['file'].casefold() in inst_list @make_flag('instFlag', 'InstPart') @@ -233,7 +234,7 @@ def res_add_inst_var(inst: Entity, res: Property): res_add_suffix(inst, res) -@make_result('setInstVar', 'assign') +@make_result('setInstVar', 'assign', 'setFixupVar') def res_set_inst_var(inst: Entity, res: Property): """Set an instance variable to the given value. @@ -329,8 +330,7 @@ def res_replace_instance(vmf: VMF, inst: Entity, res: Property): conditions.set_ent_keys(new_ent, inst, res) - origin.localise(Vec.from_str(new_ent['origin']), angles) - new_ent['origin'] = origin + new_ent['origin'] = Vec.from_str(new_ent['origin']) @ angles + origin new_ent['angles'] = angles new_ent['targetname'] = inst['targetname'] @@ -365,8 +365,8 @@ def res_global_input_setup(res: Property) -> tuple[str, Output]: def res_global_input(vmf: VMF, inst: Entity, res: Property) -> None: """Trigger an input either on map spawn, or when a relay is triggered. - Arguments: - + Arguments: + - `Input`: the input to use, either a name or an `instance:` command. - `Target`: If set, a local name to send commands to. Otherwise, the instance itself. - `Delay`: Number of seconds to delay the input. diff --git a/src/precomp/conditions/linked_items.py b/src/precomp/conditions/linked_items.py new file mode 100644 index 000000000..ee4871113 --- /dev/null +++ b/src/precomp/conditions/linked_items.py @@ -0,0 +1,232 @@ +"""Implements a condition which allows linking items into a sequence.""" +from __future__ import annotations +from typing import Optional, Callable +from enum import Enum +import math +import attr + +from srctools import Property, VMF, Entity +import srctools.logger + +from precomp import instanceLocs, item_chain, conditions + + +COND_MOD_NAME = 'Item Linkage' +LOGGER = srctools.logger.get_logger(__name__, alias='cond.linkedItem') + + +class AntlineHandling(Enum): + """How to handle antlines.""" + REMOVE = 'remove' + KEEP = 'keep' + MOVE = 'move' + + +@attr.define +class Config: + """Configuration for linked items.""" + group: str # For reporting. + logic_start: Optional[str] + logic_mid: Optional[str] + logic_end: Optional[str] + logic_loop: Optional[str] + + antline: AntlineHandling + allow_loop: bool + transfer_io: bool + + # Special feature for unstationary scaffolds. This is rotated to face + # the next track! + scaff_endcap: Optional[str] + # If it's allowed to point any direction, not just 90 degrees. + scaff_endcap_free_rot: bool + + +def resolve_optional(prop: Property, key: str) -> str: + """Resolve the given instance, or return '' if not defined.""" + try: + file = prop[key] + except LookupError: + return '' + return instanceLocs.resolve_one(file) or '' + +# Store the nodes for items so we can join them up later. +ITEMS_TO_LINK: dict[str, list[item_chain.Node[Config]]] = {} + + +@conditions.make_result('LinkedItem') +def res_linked_item(res: Property) -> Callable[[Entity], None]: + """Marks the current instance for linkage together into a chain. + + At priority level -300, the sequence of similarly-marked items this links + to is grouped together, and given fixup values to allow linking them. + + Every instance has `$type` set to `loop`, `start`, `mid` or `end` depending on its role. + For each group of linked items, `$group` is set to a unique number. Then for each item, `$ind` + is set to a unique index (starting at 1), and if it is not an endpoint `$next` is set to the + index of the next item. This should be used by naming items `@itemtype_track_$group_$ind`, and + then connecting that to `@itemtype_track_$group_$next`. See the Unstationary Scaffold package + for an example usage. + + Parameters: + * Group: Should be set to a unique name. All calls with this name can be + linked together. If not used, only this specific result call will link. + * AllowLoop: If true, allow constructing a loop of items. In this situation, the + indexes will start at some item at random, and proceed around. The last will then + link to the first. + * TransferIO: If true (default), all inputs and outputs are transferred to the first + item (index = 1). This instance can then forward the results to the other items in the group. + * StartLogic/MidLogic/EndLogic/LoopLogic: These instances will be overlaid on the + instance, depending on whether it starts/ends or is in the middle of the + path. If the item loops, all use LoopLogic. + * Antlines: Controls what happens to antlines linking between the items. + If one of the items outputs to a non-linked item, those antlines must be + kept. Three behaviours are available: + * `remove` (default): Completely remove the antlines. + * `keep`: Leave them untouched. + * `move`: Move them all to the first item. + * EndcapInst: Special instance for Unstationary Scaffolds. If the item is + facing upwards, and is the end for a mostly horizontal beam it is switched + to this instance, and rotated to face towards the previous track. + * endcap_free_rotate: If true, the endcap can point in any angle, otherwise + it points in the nearest 90 degree angle. + """ + try: + group = res['group'].casefold() + except LookupError: + # No group defined, make it specific to this result. + group = format(id(res), '016X') + + try: + group_list = ITEMS_TO_LINK[group] + except KeyError: + group_list = ITEMS_TO_LINK[group] = [] + + antline_str = res['antlines', res['antline', 'remove']] + try: + antline = AntlineHandling(antline_str.casefold()) + except ValueError: + raise ValueError( + f'Unknown antline behaviour "{antline_str}" ' + f'(accepted: {", ".join(AntlineHandling)})' + ) from None + + conf = Config( + group=group, + logic_start=resolve_optional(res, 'startlogic'), + logic_mid=resolve_optional(res, 'midLogic'), + logic_end=resolve_optional(res, 'endLogic'), + logic_loop=resolve_optional(res, 'loopLogic'), + allow_loop=res.bool('allowLoop'), + transfer_io=res.bool('transferIO', True), + antline=antline, + scaff_endcap=resolve_optional(res, 'EndcapInst'), + scaff_endcap_free_rot=res.bool('endcap_free_rotate'), + ) + + def applier(inst: Entity) -> None: + """Store off this instance for later linkage.""" + group_list.append(item_chain.Node.from_inst(inst, conf)) + return applier + + +@conditions.meta_cond(-300) +def link_items(vmf: VMF) -> None: + """Take the defined linked items, and actually link them together.""" + for name, group in ITEMS_TO_LINK.items(): + LOGGER.info('Linking {} items...', name) + link_item(vmf, group) + + +def link_item(vmf: VMF, group: list[item_chain.Node[Config]]) -> None: + """Link together a single group of items.""" + chains = item_chain.chain(group, allow_loop=True) + for group_counter, node_list in enumerate(chains): + is_looped = False + if node_list[0].prev is not None: # It's looped, check if it's allowed. + if not all(node.conf.allow_loop for node in node_list): + LOGGER.warning('- Group is looped, but this is not allowed! Arbitarily breaking.') + node_list[0].prev = node_list[-1].next = None + else: + is_looped = True + for index, node in enumerate(node_list): + conf = node.conf + is_floor = node.orient.up().z > 0.99 + + if node.next is None and node.prev is None: + # No connections in either direction, just skip. + continue + + # We can't touch antlines if the item has regular outputs. + if not node.item.outputs: + if conf.antline is AntlineHandling.REMOVE: + node.item.delete_antlines() + elif conf.antline is AntlineHandling.MOVE: + if index != 0: + node.item.transfer_antlines(node_list[0].item) + elif conf.antline is AntlineHandling.KEEP: + pass + else: + raise AssertionError(conf.antline) + + # Transfer inputs and outputs to the first. + if index != 0 and conf.transfer_io: + for conn in list(node.item.outputs): + conn.from_item = node_list[0].item + for conn in list(node.item.inputs): + conn.to_item = node_list[0].item + + # If start/end, the other node. + other_node: Optional[item_chain.Node[Config]] = None + if is_looped: + node.inst.fixup['$type'] = 'loop' + logic_fname = conf.logic_loop + elif node.prev is None: + node.inst.fixup['$type'] = 'start' + logic_fname = conf.logic_start + other_node = node.next + elif node.next is None: + node.inst.fixup['$type'] = 'end' + logic_fname = conf.logic_end + other_node = node.prev + else: + node.inst.fixup['$type'] = 'mid' + logic_fname = conf.logic_mid + + # Add values indicating the group, position, and next item. + node.inst.fixup['$group'] = group_counter + node.inst.fixup['$ind'] = index + if node.next is not None: + # If looped, it might have to wrap around. + if node.next is node_list[0]: + node.inst.fixup['$next'] = '0' + else: + node.inst.fixup['$next'] = index + 1 + + if logic_fname: + inst_logic = vmf.create_ent( + classname='func_instance', + targetname=node.inst['targetname'], + file=logic_fname, + origin=node.pos, + angles=node.inst['angles'], + ) + inst_logic.fixup.update(node.inst.fixup) + + # Special case for Unstationary Scaffolds - change to an instance + # for the ends, pointing in the direction of the connected track. + if other_node is not None and is_floor and conf.scaff_endcap: + link_dir = other_node.pos - node.pos + + # Compute the horizontal gradient (z / xy dist). + # Don't use endcap if rising more than ~45 degrees, or lowering + # more than ~12 degrees. + horiz_dist = math.sqrt(link_dir.x ** 2 + link_dir.y ** 2) + if horiz_dist != 0 and -0.15 <= (link_dir.z / horiz_dist) <= 1: + link_ang = math.degrees(math.atan2(link_dir.y, link_dir.x)) + if not conf.scaff_endcap_free_rot: + # Round to nearest 90 degrees + # Add 45 so the switchover point is at the diagonals + link_ang = (link_ang + 45) // 90 * 90 + node.inst['file'] = conf.scaff_endcap + node.inst['angles'] = '0 {:.0f} 0'.format(link_ang) diff --git a/src/precomp/conditions/marker.py b/src/precomp/conditions/marker.py new file mode 100644 index 000000000..f043e97f1 --- /dev/null +++ b/src/precomp/conditions/marker.py @@ -0,0 +1,138 @@ +"""Conditions that read/write a set of positional markers.""" +from __future__ import annotations + +import attr + +from precomp.conditions import make_flag, make_result +from srctools import Property, Entity, Vec, Matrix, Angle +import srctools.logger + +# TODO: switch to R-tree etc. +MARKERS: list[Marker] = [] +LOGGER = srctools.logger.get_logger(__name__) + + +@attr.define +class Marker: + """A marker placed in the map.""" + pos: Vec + name: str + inst: Entity + + +@make_result('SetMarker') +def res_set_marker(inst: Entity, res: Property) -> None: + """Set a marker at a specific position. + + Parameters: + * `global`: If true, the position is an absolute position, ignoring this instance. + * `name`: A name to store to identify this marker/item. + * `pos`: The position or offset to use for the marker. + """ + origin = Vec.from_str(inst['origin']) + orient = Matrix.from_angle(Angle.from_str(inst['angles'])) + + try: + is_global = srctools.conv_bool(inst.fixup.substitute(res['global'], allow_invert=True)) + except LookupError: + is_global = False + + name = inst.fixup.substitute(res['name']).casefold() + pos = Vec.from_str(inst.fixup.substitute(res['pos'])) + if not is_global: + pos = pos @ orient + origin + + mark = Marker(pos, name, inst) + MARKERS.append(mark) + LOGGER.debug('Marker added: {}', mark) + + +@make_flag('CheckMarker') +def flag_check_marker(inst: Entity, flag: Property) -> bool: + """Check if markers are present at a position. + + Parameters: + * `name`: The name to look for. This can contain one `*` to match prefixes/suffixes. + * `nameVar`: If found, set this variable to the actual name. + * `pos`: The position to check. + * `pos2`: If specified, the position is a bounding box from 1 to 2. + * `radius`: Check markers within this distance. If this is specified, `pos2` is not permitted. + * `global`: If true, positions are an absolute position, ignoring this instance. + * `removeFound`: If true, remove the found marker. If you don't need it, this will improve + performance. + * `copyto`: Copies fixup vars from the searching instance to the one which set the + marker. The value is in the form `$src $dest`. + * `copyfrom`: Copies fixup vars from the one that set the marker to the searching instance. + The value is in the form `$src $dest`. + """ + origin = Vec.from_str(inst['origin']) + orient = Matrix.from_angle(Angle.from_str(inst['angles'])) + + name = inst.fixup.substitute(flag['name']).casefold() + if '*' in name: + try: + prefix, suffix = name.split('*') + except ValueError: + raise ValueError(f'Name "{name}" must only have 1 *!') + + def match(val: str) -> bool: + """Match a prefix or suffix.""" + val = val.casefold() + return val.startswith(prefix) and val.endswith(suffix) + else: + def match(val: str) -> bool: + """Match an exact name.""" + return val.casefold() == name + + try: + is_global = srctools.conv_bool(inst.fixup.substitute(flag['global'], allow_invert=True)) + except LookupError: + is_global = False + + pos = Vec.from_str(inst.fixup.substitute(flag['pos'])) + if not is_global: + pos = pos @ orient + origin + + radius: float | None + if 'pos2' in flag: + if 'radius' in flag: + raise ValueError('Only one of pos2 or radius must be defined.') + pos2 = Vec.from_str(inst.fixup.substitute(flag['pos2'])) + if not is_global: + pos2 = pos2 @ orient + origin + bb_min, bb_max = Vec.bbox(pos, pos2) + radius = None + LOGGER.debug('Searching for marker "{}" from ({})-({})', name, bb_min, bb_max) + elif 'radius' in flag: + radius = abs(srctools.conv_float(inst.fixup.substitute(flag['radius']))) + bb_min = pos - (radius + 1.0) + bb_max = pos + (radius + 1.0) + LOGGER.debug('Searching for marker "{}" at ({}), radius={}', name, pos, radius) + else: + bb_min = pos - (1.0, 1.0, 1.0) + bb_max = pos + (1.0, 1.0, 1.0) + radius = 1e-6 + LOGGER.debug('Searching for marker "{}" at ({})', name, pos) + + for i, marker in enumerate(MARKERS): + if not marker.pos.in_bbox(bb_min, bb_max): + continue + if radius is not None and (marker.pos - pos).mag() > radius: + continue + if not match(marker.name): + continue + # Matched. + if 'nameVar' in flag: + inst.fixup[flag['namevar']] = marker.name + if srctools.conv_bool(inst.fixup.substitute(flag['removeFound'], allow_invert=True)): + LOGGER.debug('Removing found marker {}', marker) + del MARKERS[i] + + for prop in flag.find_all('copyto'): + src, dest = prop.value.split(' ', 1) + marker.inst.fixup[dest] = inst.fixup[src] + for prop in flag.find_all('copyfrom'): + src, dest = prop.value.split(' ', 1) + inst.fixup[dest] = marker.inst.fixup[src] + return True + return False diff --git a/src/precomp/conditions/positioning.py b/src/precomp/conditions/positioning.py index f860d66d7..a27484517 100644 --- a/src/precomp/conditions/positioning.py +++ b/src/precomp/conditions/positioning.py @@ -1,5 +1,5 @@ import math -from typing import Tuple, Dict, Set +from typing import Tuple, Dict, Set, Callable from precomp.conditions import ( make_flag, make_result, resolve_offset, @@ -29,7 +29,7 @@ 'dir', 'direction', ) -def flag_angles(inst: Entity, flag: Property): +def flag_angles(flag: Property) -> Callable[[Entity], bool]: """Check that a instance is pointed in a direction. The value should be either just the angle to check, or a block of @@ -42,7 +42,6 @@ def flag_angles(inst: Entity, flag: Property): - `Allow_inverse`: If true, this also returns True if the instance is pointed the opposite direction . """ - angle = inst['angles', '0 0 0'] if flag.has_children(): targ_angle = flag['direction', '0 0 0'] @@ -57,20 +56,23 @@ def flag_angles(inst: Entity, flag: Property): from_dir = Vec(0, 0, 1) allow_inverse = False - normal = DIRECTIONS.get(targ_angle.casefold(), None) - if normal is None: - return False # If it's not a special angle, - # so it failed the exact match + try: + normal = DIRECTIONS[targ_angle.casefold()] + except KeyError: + normal = Vec.from_str(targ_angle) - inst_normal = from_dir.rotate_by_str(angle) + def check_orient(inst: Entity) -> bool: + """Check the orientation against the instance.""" + inst_normal = from_dir @ Angle.from_str(inst['angles']) - if normal == 'WALL': - # Special case - it's not on the floor or ceiling - return not (inst_normal == (0, 0, 1) or inst_normal == (0, 0, -1)) - else: - return inst_normal == normal or ( - allow_inverse and -inst_normal == normal - ) + if normal == 'WALL': + # Special case - it's not on the floor or ceiling + return abs(inst_normal.z) < 1e-6 + else: + return inst_normal == normal or ( + allow_inverse and -inst_normal == normal + ) + return check_orient def brush_at_loc( @@ -433,17 +435,40 @@ def res_force_upright(inst: Entity): The result angle will have pitch and roll set to 0. Vertical instances are unaffected. """ - normal = Vec(0, 0, 1).rotate_by_str(inst['angles']) + normal = Vec(0, 0, 1) @ Angle.from_str(inst['angles']) if normal.z != 0: return ang = math.degrees(math.atan2(normal.y, normal.x)) inst['angles'] = '0 {:g} 0'.format(ang % 360) # Don't use negatives +@make_result('switchOrientation') +def res_alt_orientation(res: Property) -> Callable[[Entity], None]: + """Apply an alternate orientation. + + "wall" makes the attaching surface in the -X direction, making obs rooms, + corridors etc easier to build. The Z axis points in the former +X direction. + "ceiling" flips the instance, making items such as droppers easier to build. + The X axis remains unchanged. + """ + val = res.value.casefold() + if val == 'wall': + pose = Matrix.from_angle(-90, 180, 0) + elif val in ('ceil', 'ceiling'): + pose = Matrix.from_roll(180) + else: + raise ValueError(f'Unknown orientation type "{res.value}"!') + + def swap_orient(inst: Entity) -> None: + """Apply the new orientation.""" + inst['angles'] = pose @ Angle.from_str(inst['angles']) + return swap_orient + + @make_result('setAngles') def res_set_angles(inst: Entity, res: Property): """Set the orientation of an instance to a certain angle.""" - inst['angles'] = res.value + inst['angles'] = inst.fixup.substitute(res.value) @make_result('OffsetInst', 'offsetinstance') @@ -506,7 +531,7 @@ def res_calc_opposite_wall_dist(inst: Entity, res: Property): inst.fixup[result_var] = (origin - opposing_pos).mag() + dist_off -@make_result('RotateInst') +@make_result('RotateInst', 'RotateInstance') def res_rotate_inst(inst: Entity, res: Property) -> None: """Rotate the instance around an axis. @@ -514,6 +539,9 @@ def res_rotate_inst(inst: Entity, res: Property) -> None: be rotated `angle` degrees around it. Otherwise, `angle` is a pitch-yaw-roll angle which is applied. `around` can be a point (local, pre-rotation) which is used as the origin. + + Tip: If you want to match angled panels, rotate with an axis of `0 -1 0` + and an around value of `0 -64 -64`. """ angles = Angle.from_str(inst['angles']) if 'axis' in res: @@ -526,7 +554,7 @@ def res_rotate_inst(inst: Entity, res: Property) -> None: try: offset = Vec.from_str(inst.fixup.substitute(res['around'])) - except NoKeyError: + except LookupError: pass else: origin = Vec.from_str(inst['origin']) diff --git a/src/precomp/conditions/randomise.py b/src/precomp/conditions/randomise.py index c7f42775d..4bcd40141 100644 --- a/src/precomp/conditions/randomise.py +++ b/src/precomp/conditions/randomise.py @@ -1,42 +1,48 @@ """Conditions for randomising instances.""" -import random -from typing import List +from typing import Callable -from srctools import Property, Vec, Entity, VMF -from precomp import conditions +from srctools import Property, Vec, Entity, Angle import srctools -from precomp.conditions import ( - Condition, make_flag, make_result, make_result_setup, RES_EXHAUSTED, - set_random_seed, -) +from precomp import conditions, rand +from precomp.conditions import Condition, RES_EXHAUSTED, make_flag, make_result COND_MOD_NAME = 'Randomisation' @make_flag('random') -def flag_random(inst: Entity, res: Property) -> bool: +def flag_random(res: Property) -> Callable[[Entity], bool]: """Randomly is either true or false.""" if res.has_children(): chance = res['chance', '100'] - seed = 'a' + res['seed', ''] + seed = res['seed', ''] else: chance = res.value - seed = 'a' + seed = '' # Allow ending with '%' sign chance = srctools.conv_int(chance.rstrip('%'), 100) - set_random_seed(inst, seed) - return random.randrange(100) < chance + def rand_func(inst: Entity) -> bool: + """Apply the random chance.""" + return rand.seed(b'rand_flag', inst, seed).randrange(100) < chance + return rand_func -@make_result_setup('random') -def res_random_setup(vmf: VMF, res: Property) -> object: - weight = '' +@make_result('random') +def res_random(res: Property) -> Callable[[Entity], None]: + """Randomly choose one of the sub-results to execute. + + The `chance` value defines the percentage chance for any result to be + chosen. `weights` defines the weighting for each result. Both are + comma-separated, matching up with the results following. Wrap a set of + results in a `group` property block to treat them as a single result to be + executed in order. + """ + weight_str = '' results = [] chance = 100 - seed = 'b' + seed = '' for prop in res: if prop.name == 'chance': # Allow ending with '%' sign @@ -45,61 +51,47 @@ def res_random_setup(vmf: VMF, res: Property) -> object: chance, ) elif prop.name == 'weights': - weight = prop.value + weight_str = prop.value elif prop.name == 'seed': seed = 'b' + prop.value else: results.append(prop) if not results: - return None # Invalid! - - weight = conditions.weighted_random(len(results), weight) - - return seed, chance, weight, results + # Does nothing + return lambda e: None + weights_list = rand.parse_weights(len(results), weight_str) -@make_result('random') -def res_random(inst: Entity, res: Property) -> None: - """Randomly choose one of the sub-results to execute. - - The `chance` value defines the percentage chance for any result to be - chosen. `weights` defines the weighting for each result. Both are - comma-separated, matching up with the results following. Wrap a set of - results in a `group` property block to treat them as a single result to be - executed in order. - """ - # Note: 'global' results like "Has" won't delete themselves! - # Instead they're replaced by 'dummy' results that don't execute. + # Note: We can't delete 'global' results, instead replace by 'dummy' + # results that don't execute. # Otherwise the chances would be messed up. - seed, chance, weight, results = res.value # type: str, float, List[int], List[Property] - - set_random_seed(inst, seed) - if random.randrange(100) > chance: - return - - ind = random.choice(weight) - choice = results[ind] - if choice.name == 'nop': - pass - elif choice.name == 'group': - for sub_res in choice: - should_del = Condition.test_result( + def apply_random(inst: Entity) -> None: + """Pick a random result and run it.""" + rng = rand.seed(b'rand_res', inst, seed) + if rng.randrange(100) > chance: + return + + ind = rng.choice(weights_list) + choice = results[ind] + if choice.name == 'nop': + pass + elif choice.name == 'group': + for sub_res in choice: + if Condition.test_result( + inst, + sub_res, + ) is RES_EXHAUSTED: + sub_res.name = 'nop' + sub_res.value = '' + else: + if Condition.test_result( inst, - sub_res, - ) - if should_del is RES_EXHAUSTED: - # This Result doesn't do anything! - sub_res.name = 'nop' - sub_res.value = None - else: - should_del = Condition.test_result( - inst, - choice, - ) - if should_del is RES_EXHAUSTED: - choice.name = 'nop' - choice.value = None + choice, + ) is RES_EXHAUSTED: + choice.name = 'nop' + choice.value = '' + return apply_random @make_result('variant') @@ -107,10 +99,11 @@ def res_add_variant(res: Property): """This allows using a random instance from a weighted group. A suffix will be added in the form `_var4`. - Two properties should be given: + Two or three properties should be given: - `Number`: The number of random instances. - `Weights`: A comma-separated list of weights for each instance. + - `seed`: Optional seed to disambiuate multiple options. Any variant has a chance of weight/sum(weights) of being chosen: A weight of `2, 1, 1` means the first instance has a 2/4 chance of @@ -127,10 +120,8 @@ def res_add_variant(res: Property): count = int(count_val) except (TypeError, ValueError): raise ValueError(f'Invalid variant count {count_val}!') - weighting = conditions.weighted_random( - count, - res['weights', ''], - ) + weighting = rand.parse_weights(count, res['weights', '']) + seed = res['seed', ''] else: try: count = int(res.value) @@ -138,16 +129,17 @@ def res_add_variant(res: Property): raise ValueError(f'Invalid variant count {res.value!r}!') else: weighting = list(range(count)) + seed = res.value def apply_variant(inst: Entity) -> None: """Apply the variant.""" - set_random_seed(inst, 'variant') - conditions.add_suffix(inst, f"_var{str(random.choice(weighting) + 1)}") + rng = rand.seed(b'variant', inst, seed) + conditions.add_suffix(inst, f"_var{rng.choice(weighting) + 1}") return apply_variant @make_result('RandomNum') -def res_rand_num(inst: Entity, res: Property) -> None: +def res_rand_num(res: Property) -> Callable[[Entity], None]: """Generate a random number and save in a fixup value. If 'decimal' is true, the value will contain decimals. 'max' and 'min' are @@ -159,16 +151,16 @@ def res_rand_num(inst: Entity, res: Property) -> None: max_val = srctools.conv_float(res['max', 1.0]) min_val = srctools.conv_float(res['min', 0.0]) var = res['resultvar', '$random'] - seed = 'd' + res['seed', 'random'] - - set_random_seed(inst, seed) + seed = res['seed', ''] - if is_float: - func = random.uniform - else: - func = random.randint - - inst.fixup[var] = str(func(min_val, max_val)) + def randomise(inst: Entity) -> None: + """Apply the random number.""" + rng = rand.seed(b'rand_num', inst, seed) + if is_float: + inst.fixup[var] = rng.uniform(min_val, max_val) + else: + inst.fixup[var] = rng.randint(min_val, max_val) + return randomise @make_result('RandomVec') @@ -182,12 +174,12 @@ def res_rand_vec(inst: Entity, res: Property) -> None: is_float = srctools.conv_bool(res['decimal']) var = res['resultvar', '$random'] - set_random_seed(inst, 'e' + res['seed', 'random']) + rng = rand.seed(b'rand_vec', inst, res['seed', '']) if is_float: - func = random.uniform + func = rng.uniform else: - func = random.randint + func = rng.randint value = Vec() @@ -202,8 +194,12 @@ def res_rand_vec(inst: Entity, res: Property) -> None: inst.fixup[var] = value.join(' ') -@make_result_setup('randomShift') -def res_rand_inst_shift_setup(res: Property) -> tuple: +@make_result('randomShift') +def res_rand_inst_shift(res: Property) -> Callable[[Entity], None]: + """Randomly shift a instance by the given amounts. + + The positions are local to the instance. + """ min_x = res.float('min_x') max_x = res.float('max_x') min_y = res.float('min_y') @@ -211,35 +207,16 @@ def res_rand_inst_shift_setup(res: Property) -> tuple: min_z = res.float('min_z') max_z = res.float('max_z') - return ( - min_x, max_x, - min_y, max_y, - min_z, max_z, - 'f' + res['seed', 'randomshift'] - ) - - -@make_result('randomShift') -def res_rand_inst_shift(inst: Entity, res: Property) -> None: - """Randomly shift a instance by the given amounts. + seed = 'f' + res['seed', 'randomshift'] - The positions are local to the instance. - """ - ( - min_x, max_x, - min_y, max_y, - min_z, max_z, - seed, - ) = res.value # type: float, float, float, float, float, float, str - - set_random_seed(inst, seed) - - offset = Vec( - random.uniform(min_x, max_x), - random.uniform(min_y, max_y), - random.uniform(min_z, max_z), - ).rotate_by_str(inst['angles']) - - origin = Vec.from_str(inst['origin']) - origin += offset - inst['origin'] = origin + def shift_ent(inst: Entity) -> None: + """Randomly shift the instance.""" + rng = rand.seed(b'rand_shift', inst, seed) + pos = Vec( + rng.uniform(min_x, max_x), + rng.uniform(min_y, max_y), + rng.uniform(min_z, max_z), + ) + pos.localise(Vec.from_str(inst['origin']), Angle.from_str(inst['angles'])) + inst['origin'] = pos + return shift_ent diff --git a/src/precomp/conditions/removed.py b/src/precomp/conditions/removed.py index 8e805482d..74b3402e4 100644 --- a/src/precomp/conditions/removed.py +++ b/src/precomp/conditions/removed.py @@ -1,13 +1,14 @@ """Conditions that were present in older versions only.""" +from typing import TypeVar, Callable from precomp.conditions import RES_EXHAUSTED, make_flag, make_result import srctools.logger COND_MOD_NAME = 'Removed' - +T = TypeVar('T') LOGGER = srctools.logger.get_logger(__name__) -def deprecator(func, ret_val): +def deprecator(func: Callable[..., Callable[[Callable], object]], ret_val: T): """Deprecate a flag or result.""" def do_dep(name: str, *aliases: str, msg: str = None): used = False @@ -16,7 +17,7 @@ def do_dep(name: str, *aliases: str, msg: str = None): else: msg = f'{name} is no longer used.' - def deprecated(): + def deprecated() -> T: """This result is no longer used.""" nonlocal used if not used: @@ -33,7 +34,10 @@ def deprecated(): deprecate_flag = deprecator(make_flag, False) -deprecate_result('HollowBrush') +deprecate_result( + 'HollowBrush', + msg='The tiling system means this no longer must be done manually.', +) deprecate_result( 'MarkLocking', msg='Configure locking items in the enhanced editoritems configuration.', diff --git a/src/precomp/conditions/resizableTrigger.py b/src/precomp/conditions/resizableTrigger.py index 53ce06fd0..9b440fb09 100644 --- a/src/precomp/conditions/resizableTrigger.py +++ b/src/precomp/conditions/resizableTrigger.py @@ -21,9 +21,9 @@ @make_result('ResizeableTrigger') def res_resizeable_trigger(vmf: VMF, res: Property): - """Replace two markers with a trigger brush. + """Replace two markers with a trigger brush. - This is run once to affect all of an item. + This is run once to affect all of an item. Options: * `markerInst`: value referencing the marker instances, or a filename. @@ -183,8 +183,8 @@ def res_resizeable_trigger(vmf: VMF, res: Property): item = connections.Item( out_ent, conn_conf_coop, - mark1.ant_floor_style, - mark1.ant_wall_style, + ant_floor_style=mark1.ant_floor_style, + ant_wall_style=mark1.ant_wall_style, ) if coop_only_once: @@ -203,8 +203,8 @@ def res_resizeable_trigger(vmf: VMF, res: Property): item = connections.Item( trig_ent, conn_conf_sp, - mark1.ant_floor_style, - mark1.ant_wall_style, + ant_floor_style=mark1.ant_floor_style, + ant_wall_style=mark1.ant_wall_style, ) # Register, and copy over all the antlines. diff --git a/src/precomp/conditions/sendificator.py b/src/precomp/conditions/sendificator.py index 1fb7881c9..0600edeae 100644 --- a/src/precomp/conditions/sendificator.py +++ b/src/precomp/conditions/sendificator.py @@ -1,29 +1,26 @@ -from typing import Tuple, Dict - +"""Implement special support """ +from __future__ import annotations from precomp import connections, conditions import srctools.logger -from srctools import Property, Entity, VMF, Vec, Output +from srctools import Property, Entity, VMF, Vec, Output, Angle, Matrix -COND_MOD_NAME = None +COND_MOD_NAME = None LOGGER = srctools.logger.get_logger(__name__, alias='cond.sendtor') # Laser name -> offset, normal -SENDTOR_TARGETS = {} # type: Dict[str, Tuple[Vec, Vec]] - - -@conditions.make_result_setup('SendificatorLaser') -def res_sendificator_laser_setup(res: Property): - return ( - res.vec('offset'), - res.vec('direction', 0, 0, 1) - ) +SENDTOR_TARGETS: dict[str, tuple[Vec, Vec]] = {} @conditions.make_result('SendificatorLaser') -def res_sendificator_laser(inst: Entity, res: Property): +def res_sendificator_laser(res: Property): """Record the position of the target for Sendificator Lasers.""" - SENDTOR_TARGETS[inst['targetname']] = res.value + target = res.vec('offset'), res.vec('direction', 0, 0, 1) + + def set_laser(inst: Entity) -> None: + """Store off the target position.""" + SENDTOR_TARGETS[inst['targetname']] = target + return set_laser @conditions.make_result('Sendificator') @@ -37,7 +34,7 @@ def res_sendificator(vmf: VMF, inst: Entity): sendtor.enable_cmd += (Output( '', - '@{}_las_relay_*'.format(sendtor_name), + f'@{sendtor_name}_las_relay_*', 'Trigger', delay=0.01, ), ) @@ -51,17 +48,12 @@ def res_sendificator(vmf: VMF, inst: Entity): LOGGER.warning('"{}" is not a Sendificator target!', las_item.name) continue - angles = Vec.from_str(las_item.inst['angles']) + orient = Matrix.from_angle(Angle.from_str(las_item.inst['angles'])) - targ_offset = targ_offset.copy() - targ_normal = targ_normal.copy().rotate(*angles) - - targ_offset.localise( - Vec.from_str(las_item.inst['origin']), - angles, - ) + targ_offset = Vec.from_str(las_item.inst['origin']) + targ_offset @ orient + targ_normal = targ_normal @ orient - relay_name = '@{}_las_relay_{}'.format(sendtor_name, ind) + relay_name = f'@{sendtor_name}_las_relay_{ind}' relay = vmf.create_ent( 'logic_relay', @@ -84,4 +76,3 @@ def res_sendificator(vmf: VMF, inst: Entity): relay['StartDisabled'] = not is_on las_item.enable_cmd += (Output('', relay_name, 'Enable'),) las_item.disable_cmd += (Output('', relay_name, 'Disable'),) - LOGGER.info('Relay: {}', relay) diff --git a/src/precomp/conditions/signage.py b/src/precomp/conditions/signage.py index 8d67c7ab9..e4723c7ed 100644 --- a/src/precomp/conditions/signage.py +++ b/src/precomp/conditions/signage.py @@ -5,7 +5,7 @@ import srctools.logger from precomp import tiling, texturing, template_brush, conditions import consts -from srctools import Property, Entity, VMF, Vec, NoKeyError +from srctools import Property, Entity, VMF, Vec, NoKeyError, Matrix, Angle from srctools.vmf import make_overlay, Side import vbsp @@ -107,6 +107,7 @@ def res_signage(vmf: VMF, inst: Entity, res: Property): sign = None has_arrow = inst.fixup.bool(consts.FixupVars.ST_ENABLED) + make_4x4 = res.bool('set4x4tile') sign_prim: Optional[Sign] sign_sec: Optional[Sign] @@ -123,16 +124,13 @@ def res_signage(vmf: VMF, inst: Entity, res: Property): return origin = Vec.from_str(inst['origin']) - angles = Vec.from_str(inst['angles']) + orient = Matrix.from_angle(Angle.from_str(inst['angles'])) - normal = Vec(z=-1).rotate(*angles) - forward = Vec(x=-1).rotate(*angles) + normal = -orient.up() + forward = -orient.forward() - prim_pos = Vec(0, -16, -64) - sec_pos = Vec(0, 16, -64) - - prim_pos.localise(origin, angles) - sec_pos.localise(origin, angles) + prim_pos = Vec(0, -16, -64) @ orient + origin + sec_pos = Vec(0, +16, -64) @ orient + origin template_id = res['template_id', ''] @@ -167,7 +165,7 @@ def res_signage(vmf: VMF, inst: Entity, res: Property): vmf, template_id, origin, - angles, + orient, force_type=template_brush.TEMP_TYPES.detail, additional_visgroups=visgroup, ) @@ -203,6 +201,13 @@ def res_signage(vmf: VMF, inst: Entity, res: Property): if tiledef is not None: tiledef.bind_overlay(over) + if make_4x4: + try: + tile, u, v = tiling.find_tile(prim_pos, -normal) + except KeyError: + pass + else: + tile[u, v] = tile[u, v].as_4x4 if sign_sec is not None: if has_arrow and res.bool('arrowDown'): @@ -221,6 +226,13 @@ def res_signage(vmf: VMF, inst: Entity, res: Property): if tiledef is not None: tiledef.bind_overlay(over) + if make_4x4: + try: + tile, u, v = tiling.find_tile(sec_pos, -normal) + except KeyError: + pass + else: + tile[u, v] = tile[u, v].as_4x4 def place_sign( diff --git a/src/precomp/conditions/vactubes.py b/src/precomp/conditions/vactubes.py index 7659dfac0..5af56ddbb 100644 --- a/src/precomp/conditions/vactubes.py +++ b/src/precomp/conditions/vactubes.py @@ -1,6 +1,7 @@ """Implements the cutomisable vactube items. """ -from typing import Optional, Dict, Tuple, List, Iterator, Iterable +from __future__ import annotations +from collections.abc import Iterator, Iterable import attr @@ -10,6 +11,7 @@ from precomp import tiling, instanceLocs, connections, template_brush from precomp.brushLoc import POS as BLOCK_POS from precomp.conditions import make_result, meta_cond, RES_EXHAUSTED +import utils COND_MOD_NAME = None @@ -19,23 +21,32 @@ UP_PUSH_SPEED = 900 # Make it slightly faster when up to counteract gravity DN_PUSH_SPEED = 400 # Slow down when going down since gravity also applies.. -PUSH_TRIGS: Dict[Tuple[float, float, float], Entity] = {} -VAC_TRACKS: List[Tuple['Marker', Dict[str, 'Marker']]] = [] # Tuples of (start, group) +PUSH_TRIGS: dict[tuple[float, float, float], Entity] = {} +VAC_TRACKS: list[tuple[Marker, dict[str, Marker]]] = [] # Tuples of (start, group) @attr.define class Config: """Configuration for a vactube item set.""" - inst_corner: List[str] - temp_corner: List[Tuple[Optional[template_brush.Template], Iterable[str]]] - inst_straight: str - inst_support: str + inst_corner: list[str] + temp_corner: list[tuple[template_brush.Template | None, Iterable[str]]] + trig_radius: int + inst_support: str # Placed on each side with an adjacent wall. + inst_support_ring: str # If any support is placed, this is placed. inst_exit: str inst_entry_floor: str inst_entry_wall: str inst_entry_ceil: str + # For straight instances, a size (multiple of 128) -> instance. + inst_straight: dict[int, str] + # And those sizes from large to small. + inst_straight_sizes: list[int] = attr.ib(init=False) + @inst_straight_sizes.default + def _straight_size(self) -> list[int]: + return sorted(self.inst_straight.keys(), reverse=True) + @attr.define class Marker: @@ -44,7 +55,7 @@ class Marker: conf: Config size: int no_prev: bool = True - next: Optional[str] = None + next: str | None = None orient: Matrix = attr.ib(init=False, on_setattr=attr.setters.frozen) # noinspection PyUnresolvedReferences @@ -54,7 +65,7 @@ def _init_orient(self) -> Matrix: rot = Matrix.from_angle(Angle.from_str(self.ent['angles'])) return Matrix.from_yaw(180) @ rot - def follow_path(self, vac_list: Dict[str, 'Marker']) -> Iterator[Tuple['Marker', 'Marker']]: + def follow_path(self, vac_list: dict[str, Marker]) -> Iterator[tuple[Marker, Marker]]: """Follow the provided vactube path, yielding each pair of nodes.""" vac_node = self while True: @@ -69,7 +80,7 @@ def follow_path(self, vac_list: Dict[str, 'Marker']) -> Iterator[Tuple['Marker', # Store the configs for vactube items so we can # join them together - multiple item types can participate in the same # vactube track. -VAC_CONFIGS: Dict[str, Dict[str, Tuple[Config, int]]] = {} +VAC_CONFIGS: dict[str, dict[str, tuple[Config, int]]] = {} @make_result('CustVactube') @@ -88,7 +99,7 @@ def res_vactubes(vmf: VMF, res: Property): # Grab the already-filled values, and add to them inst_config = VAC_CONFIGS[group] - def get_temp(key: str) -> Tuple[Optional[template_brush.Template], Iterable[str]]: + def get_temp(key: str) -> tuple[template_brush.Template | None, Iterable[str]]: """Read the template, handling errors.""" try: temp_name = block['temp_' + key] @@ -103,6 +114,14 @@ def get_temp(key: str) -> Tuple[Optional[template_brush.Template], Iterable[str] for block in res.find_all("Instance"): # Configuration info for each instance set.. + straight_block = block.find_key('straight_inst', '') + if straight_block.has_children(): + straight = { + int(prop.name): prop.value + for prop in straight_block + } + else: + straight = {128: straight_block.value} conf = Config( # The three sizes of corner instance inst_corner=[ @@ -115,11 +134,13 @@ def get_temp(key: str) -> Tuple[Optional[template_brush.Template], Iterable[str] get_temp('corner_medium'), get_temp('corner_large'), ], - # Straight instances connected to the next part - inst_straight=block['straight_inst', ''], + trig_radius=block.float('trig_size', 64.0) / 2.0, + inst_straight=straight, # Supports attach to the 4 sides of the straight part, # if there's a brush there. inst_support=block['support_inst', ''], + # If a support is placed, this is also placed once. + inst_support_ring=block['support_ring_inst', ''], inst_entry_floor=block['entry_floor_inst'], inst_entry_wall=block['entry_inst'], inst_entry_ceil=block['entry_ceil_inst'], @@ -146,7 +167,7 @@ def result(_: Entity) -> None: del VAC_CONFIGS[group] # Don't let this run twice - markers: Dict[str, Marker] = {} + markers: dict[str, Marker] = {} # Find all our markers, so we can look them up by targetname. for inst in vmf.by_class['func_instance']: @@ -238,13 +259,13 @@ def vactube_gen(vmf: VMF) -> None: # If the end is placed in goo, don't add logic - it isn't visible, and # the object is on a one-way trip anyway. - if BLOCK_POS['world': end_loc].is_goo and end_norm.z < 0: + if not (BLOCK_POS['world': end_loc].is_goo and end_norm.z < -1e-6): end_logic = end.ent.copy() vmf.add_ent(end_logic) end_logic['file'] = end.conf.inst_exit -def push_trigger(vmf: VMF, loc: Vec, normal: Vec, solids: List[Solid]) -> None: +def push_trigger(vmf: VMF, loc: Vec, normal: Vec, solids: list[Solid]) -> None: """Generate the push trigger for these solids.""" # We only need one trigger per direction, for now. try: @@ -256,8 +277,8 @@ def push_trigger(vmf: VMF, loc: Vec, normal: Vec, solids: List[Solid]) -> None: # The z-direction is reversed.. pushdir=normal.to_angle(), speed=( - UP_PUSH_SPEED if normal.z > 0 else - DN_PUSH_SPEED if normal.z < 0 else + UP_PUSH_SPEED if normal.z > 1e-6 else + DN_PUSH_SPEED if normal.z < -1e-6 else PUSH_SPEED ), spawnflags='1103', # Clients, Physics, Everything @@ -293,55 +314,64 @@ def make_straight( is_start=False, ) -> None: """Make a straight line of instances from one point to another.""" + angles = round(normal, 6).to_angle() + orient = Matrix.from_angle(angles) - # 32 added to the other directions, plus extended dist in the direction - # of the normal - 1 - p1 = origin + (normal * ((dist // 128 * 128) - 96)) - # The starting brush needs to - # stick out a bit further, to cover the + # The starting brush needs to stick out a bit further, to cover the # point_push entity. - p2 = origin - (normal * (96 if is_start else 32)) + start_off = -96 if is_start else -64 - # bbox before +- 32 to ensure the above doesn't wipe it out - p1, p2 = Vec.bbox(p1, p2) + p1, p2 = Vec.bbox( + origin + Vec(start_off, -config.trig_radius, -config.trig_radius) @ orient, + origin + Vec(dist - 64, config.trig_radius, config.trig_radius) @ orient, + ) - solid = vmf.make_prism( - # Expand to 64x64 in the other two directions - p1 - 32, p2 + 32, - mat='tools/toolstrigger', - ).solid + solid = vmf.make_prism(p1, p2, mat='tools/toolstrigger').solid motion_trigger(vmf, solid.copy()) push_trigger(vmf, origin, normal, [solid]) - angles = normal.to_angle() - orient = Matrix.from_angle(angles) - - for off in range(0, int(dist), 128): - position = origin + off * normal + off = 0 + for seg_dist in utils.fit(dist, config.inst_straight_sizes): vmf.create_ent( classname='func_instance', - origin=position, - angles=orient.to_angle(), - file=config.inst_straight, + origin=origin + off * orient.forward(), + angles=angles, + file=config.inst_straight[seg_dist], ) - - for supp_dir in [orient.up(), orient.left(), -orient.left(), -orient.up()]: - try: - tile = tiling.TILES[ - (position - 128 * supp_dir).as_tuple(), - supp_dir.norm().as_tuple() - ] - except KeyError: - continue - # Check all 4 center tiles are present. - if all(tile[u, v].is_tile for u in (1, 2) for v in (1, 2)): + off += seg_dist + # Supports. + if config.inst_support: + for off in range(0, int(dist), 128): + position = origin + off * normal + placed_support = False + for supp_dir in [ + orient.up(), orient.left(), + -orient.left(), -orient.up() + ]: + try: + tile = tiling.TILES[ + (position - 128 * supp_dir).as_tuple(), + supp_dir.norm().as_tuple() + ] + except KeyError: + continue + # Check all 4 center tiles are present. + if all(tile[u, v].is_tile for u in (1, 2) for v in (1, 2)): + vmf.create_ent( + classname='func_instance', + origin=position, + angles=Matrix.from_basis(x=normal, z=supp_dir).to_angle(), + file=config.inst_support, + ) + placed_support = True + if placed_support and config.inst_support_ring: vmf.create_ent( classname='func_instance', origin=position, - angles=Matrix.from_basis(x=normal, z=supp_dir).to_angle(), - file=config.inst_support, + angles=angles, + file=config.inst_support_ring, ) @@ -396,7 +426,7 @@ def make_bend( # limited by the user's setting and the distance we have in each direction corner_size = int(min( first_movement // 128, sec_movement // 128, - 3, max_size, + 3, max_size + 1, )) straight_a = first_movement - (corner_size - 1) * 128 @@ -594,7 +624,7 @@ def join_markers(vmf: VMF, mark_a: Marker, mark_b: Marker, is_start: bool=False) if norm_a == norm_b: # Either straight-line, or s-bend. - dist = (origin_a - origin_b).mag() + dist = round((origin_a - origin_b).mag()) if origin_a + (norm_a * dist) == origin_b: make_straight( diff --git a/src/precomp/connections.py b/src/precomp/connections.py index 79094b411..2d099476a 100644 --- a/src/precomp/connections.py +++ b/src/precomp/connections.py @@ -7,7 +7,7 @@ from collections import defaultdict from connections import InputType, FeatureMode, Config, ConnType, OutNames -from srctools import VMF, Entity, Output, Property, conv_bool, Vec +from srctools import VMF, Entity, Output, Property, conv_bool, Vec, Angle from precomp.antlines import Antline, AntType from precomp import ( instance_traits, instanceLocs, @@ -26,15 +26,15 @@ LOGGER = srctools.logger.get_logger(__name__) -ITEM_TYPES = {} # type: Dict[str, Optional[Config]] +ITEM_TYPES: Dict[str, Optional[Config]] = {} # Targetname -> item -ITEMS = {} # type: Dict[str, Item] +ITEMS: Dict[str, 'Item'] = {} # We need different names for each kind of input type, so they don't # interfere with each other. We use the 'inst_local' pattern not 'inst-local' # deliberately so the actual item can't affect the IO input. -COUNTER_NAME = { +COUNTER_NAME: Dict[str, str] = { consts.FixupVars.CONN_COUNT: '_counter', consts.FixupVars.CONN_COUNT_TBEAM: '_counter_polarity', consts.FixupVars.BEE_CONN_COUNT_A: '_counter_a', @@ -151,6 +151,7 @@ def __init__( self, inst: Entity, item_type: Config, + *, # Don't mix up antlines! ant_floor_style: AntType, ant_wall_style: AntType, panels: Iterable[Entity]=(), @@ -445,6 +446,7 @@ def calc_connections( antlines: Dict[str, List[Antline]], shape_frame_tex: List[str], enable_shape_frame: bool, + *, # Don't mix up antlines! antline_wall: AntType, antline_floor: AntType, ) -> None: @@ -648,33 +650,32 @@ def calc_connections( frame['renderorder'] = 1 # On top -@conditions.make_result_setup('ChangeIOType') -def res_change_io_type_parse(props: Property): - """Pre-parse all item types into an anonymous block.""" - return Config.parse(''.format(id(props)), props) - - @conditions.make_result('ChangeIOType') -def res_change_io_type(inst: Entity, res: Property) -> None: +def res_change_io_type_parse(props: Property): """Switch an item to use different inputs or outputs. Must be done before priority level -250. The contents are the same as that allowed in the input BEE2 block in editoritems. """ - try: - item = ITEMS[inst['targetname']] - except KeyError: - raise ValueError('No item with name "{}"!'.format(inst['targetname'])) + conf = Config.parse(''.format(id(props)), props) + + def change_item(inst: Entity) -> None: + try: + item = ITEMS[inst['targetname']] + except KeyError: + raise ValueError('No item with name "{}"!'.format(inst['targetname'])) + + item.config = conf - item.config = res.value + # Overwrite these as well. + item.enable_cmd = conf.enable_cmd + item.disable_cmd = conf.disable_cmd - # Overwrite these as well. - item.enable_cmd = res.value.enable_cmd - item.disable_cmd = res.value.disable_cmd + item.sec_enable_cmd = conf.sec_enable_cmd + item.sec_disable_cmd = conf.sec_disable_cmd - item.sec_enable_cmd = res.value.sec_enable_cmd - item.sec_disable_cmd = res.value.sec_disable_cmd + return change_item def do_item_optimisation(vmf: VMF) -> None: @@ -736,7 +737,8 @@ def gen_item_outputs(vmf: VMF) -> None: pan_check_type = ITEM_TYPES['item_indicator_panel'] pan_timer_type = ITEM_TYPES['item_indicator_panel_timer'] - auto_logic = [] + # For logic items without inputs, collect the instances to fix up later. + dummy_logic_ents: list[Entity] = [] # Apply input A/B types to connections. # After here, all connections are primary or secondary only. @@ -782,44 +784,6 @@ def gen_item_outputs(vmf: VMF) -> None: else: add_item_indicators(item, pan_switching_timer, pan_timer_type) - # Special case - spawnfire items with no inputs need to fire - # off the outputs. There's no way to control those, so we can just - # fire it off. - if not item.inputs and item.config.spawn_fire is FeatureMode.ALWAYS: - if item.is_logic: - # Logic gates need to trigger their outputs. - # Make a logic_auto temporarily for this to collect the - # outputs we need. - - item.inst.clear_keys() - item.inst['classname'] = 'logic_auto' - - auto_logic.append(item.inst) - else: - is_inverted = conv_bool(conditions.resolve_value( - item.inst, - item.config.invert_var, - )) - logic_auto = vmf.create_ent( - 'logic_auto', - origin=item.inst['origin'], - spawnflags=1, - ) - for cmd in (item.enable_cmd if is_inverted else item.disable_cmd): - logic_auto.add_out( - Output( - 'OnMapSpawn', - conditions.local_name( - item.inst, - conditions.resolve_value(item.inst, cmd.target), - ) or item.inst, - conditions.resolve_value(item.inst, cmd.input), - conditions.resolve_value(item.inst, cmd.params), - delay=cmd.delay, - only_once=True, - ) - ) - if item.config.input_type is InputType.DUAL: prim_inputs = [ conn @@ -832,6 +796,7 @@ def gen_item_outputs(vmf: VMF) -> None: if conn.type is ConnType.SECONDARY or conn.type is ConnType.BOTH ] add_item_inputs( + dummy_logic_ents, item, InputType.AND, prim_inputs, @@ -839,8 +804,11 @@ def gen_item_outputs(vmf: VMF) -> None: item.enable_cmd, item.disable_cmd, item.config.invert_var, + item.config.spawn_fire, + '_prim_inv_rl', ) add_item_inputs( + dummy_logic_ents, item, InputType.AND, sec_inputs, @@ -848,9 +816,12 @@ def gen_item_outputs(vmf: VMF) -> None: item.sec_enable_cmd, item.sec_disable_cmd, item.config.sec_invert_var, + item.config.sec_spawn_fire, + '_sec_inv_rl', ) else: add_item_inputs( + dummy_logic_ents, item, item.config.input_type, list(item.inputs), @@ -858,6 +829,8 @@ def gen_item_outputs(vmf: VMF) -> None: item.enable_cmd, item.disable_cmd, item.config.invert_var, + item.config.spawn_fire, + '_inv_rl', ) # Check/cross instances sometimes don't match the kind of timer delay. @@ -878,7 +851,7 @@ def gen_item_outputs(vmf: VMF) -> None: origin=options.get(Vec, 'global_ents_loc') ) - for ent in auto_logic: + for ent in dummy_logic_ents: # Condense all these together now. # User2 is the one that enables the target. ent.remove() @@ -970,7 +943,7 @@ def add_timer_relay(item: Item, has_sounds: bool) -> None: relay_loc = item.config.timer_sound_pos.copy() relay_loc.localise( Vec.from_str(item.inst['origin']), - Vec.from_str(item.inst['angles']), + Angle.from_str(item.inst['angles']), ) relay['origin'] = relay_loc else: @@ -1040,6 +1013,7 @@ def add_timer_relay(item: Item, has_sounds: bool) -> None: def add_item_inputs( + dummy_logic_ents: List[Entity], item: Item, logic_type: InputType, inputs: List[Connection], @@ -1047,11 +1021,48 @@ def add_item_inputs( enable_cmd: Iterable[Output], disable_cmd: Iterable[Output], invert_var: str, + spawn_fire: FeatureMode, + inv_relay_name: str, ) -> None: """Handle either the primary or secondary inputs to an item.""" item.inst.fixup[count_var] = len(inputs) if len(inputs) == 0: + # Special case - spawnfire items with no inputs need to fire + # off the outputs. There's no way to control those, so we can just + # fire it off. + if spawn_fire is FeatureMode.ALWAYS: + if item.is_logic: + # Logic gates need to trigger their outputs. + # Make this item a logic_auto temporarily, then we'll fix them + # them up into an OnMapSpawn output properly at the end. + item.inst.clear_keys() + item.inst['classname'] = 'logic_auto' + dummy_logic_ents.append(item.inst) + else: + is_inverted = conv_bool(conditions.resolve_value( + item.inst, + invert_var, + )) + logic_auto = item.inst.map.create_ent( + 'logic_auto', + origin=item.inst['origin'], + spawnflags=1, + ) + for cmd in (enable_cmd if is_inverted else disable_cmd): + logic_auto.add_out( + Output( + 'OnMapSpawn', + conditions.local_name( + item.inst, + conditions.resolve_value(item.inst, cmd.target), + ) or item.inst, + conditions.resolve_value(item.inst, cmd.input), + conditions.resolve_value(item.inst, cmd.params), + delay=cmd.delay, + only_once=True, + ) + ) return # The rest of this function requires at least one input. if logic_type is InputType.DEFAULT: @@ -1166,7 +1177,7 @@ def add_item_inputs( # The relay allows cancelling the 'disable' output that fires shortly after # spawning. - if item.config.spawn_fire is not FeatureMode.NEVER: + if spawn_fire is not FeatureMode.NEVER: if logic_type.is_logic: # We have to handle gates specially, and make us the instance # so future evaluation applies to this. @@ -1183,7 +1194,7 @@ def add_item_inputs( # name in enable/disable_cmd. relay_cmd_name = '' else: - relay_cmd_name = '@' + item.name + '_inv_rl' + relay_cmd_name = f'@{item.name}{inv_relay_name}' spawn_relay = item.inst.map.create_ent( classname='logic_relay', targetname=relay_cmd_name, @@ -1196,7 +1207,7 @@ def add_item_inputs( else: enable_user = 'User2' disable_user = 'User1' - + spawn_relay['spawnflags'] = '0' spawn_relay['startdisabled'] = '0' diff --git a/src/precomp/cubes.py b/src/precomp/cubes.py index a806c2063..486e8898a 100644 --- a/src/precomp/cubes.py +++ b/src/precomp/cubes.py @@ -1,25 +1,19 @@ """Implement cubes and droppers.""" +from __future__ import annotations + import itertools from contextlib import suppress -from collections import namedtuple from weakref import WeakKeyDictionary from enum import Enum -from typing import ( - Optional, Union, Tuple, NamedTuple, - Dict, List, Set, FrozenSet, Iterable, MutableMapping -) +from typing import NamedTuple, MutableMapping from precomp import brushLoc, options, packing, conditions -from precomp.conditions import meta_cond, make_result, make_flag, RES_EXHAUSTED from precomp.conditions.globals import precache_model from precomp.instanceLocs import resolve as resolve_inst -from srctools import ( - Property, VMF, Entity, Vec, Output, - EmptyMapping, Matrix, Angle, -) +from srctools.vmf import VMF, Entity, EntityFixup, Output +from srctools import EmptyMapping, Property, Vec, Matrix, Angle import srctools.logger -from srctools.vmf import EntityFixup LOGGER = srctools.logger.get_logger(__name__) @@ -27,13 +21,13 @@ COND_MOD_NAME = 'Cubes/Droppers' # All the types we have loaded -CUBE_TYPES: Dict[str, 'CubeType'] = {} -DROPPER_TYPES: Dict[str, 'DropperType'] = {} -ADDON_TYPES: Dict[str, 'CubeAddon'] = {} +CUBE_TYPES: dict[str, CubeType] = {} +DROPPER_TYPES: dict[str, DropperType] = {} +ADDON_TYPES: dict[str, CubeAddon] = {} # All the cubes/droppers -PAIRS: List['CubePair'] = [] -INST_TO_PAIR: MutableMapping[Entity, 'CubePair'] = WeakKeyDictionary() +PAIRS: list[CubePair] = [] +INST_TO_PAIR: MutableMapping[Entity, CubePair] = WeakKeyDictionary() # Distance from the floor to the bottom of dropperless cubes. # That's needed for light bridges and things like that. @@ -42,13 +36,11 @@ # By position. # These won't overlap - droppers occupy space, and dropperless cubes # also do. Dropper+cube items only give the dropper. -CUBE_POS: Dict[Tuple[float, float, float], 'CubePair'] = {} +CUBE_POS: dict[tuple[float, float, float], CubePair] = {} -# Prevents duplicating different filter entities. -# It's either a frozenset of filter names, or a single model. -CUBE_FILTERS: Dict[Union[str, FrozenSet[str]], str] = {} -# Multi-filters are sequentially named. -CUBE_FILTER_MULTI_IND = 0 +# Prevents duplicating different filter entities. A number of different keys are used depending on +# exactly which kind of filter. +CUBE_FILTERS: dict[object, str] = {} # Max number of ents in a multi filter. MULTI_FILTER_COUNT = 10 @@ -105,7 +97,7 @@ class CubeVoiceEvents(Enum): # Pickup any type PICKUP_ANY = '@voice_anycube_pickup' - def __call__(self, ent: Entity, output: str): + def add_out(self, ent: Entity, output: str) -> None: """Add the output to this cube.""" ent.add_out(Output( output, @@ -175,12 +167,12 @@ class CubeSkins(NamedTuple): no rusty version. For each, the first is the off skin, the second is the on skin. """ - clean: Tuple[int, int] - rusty: Optional[Tuple[int, int]] - bounce: Tuple[int, int] - speed: Tuple[int, int] + clean: tuple[int, int] + rusty: tuple[int, int] | None + bounce: tuple[int, int] + speed: tuple[int, int] - def spawn_skin(self, paint: Optional[CubePaintType]) -> int: + def spawn_skin(self, paint: CubePaintType | None) -> int: """Return the skin this paint would spawn with.""" if paint is None: return self.clean[0] @@ -192,7 +184,7 @@ def spawn_skin(self, paint: Optional[CubePaintType]) -> int: # (paint, type and rusty) -> off, on skins. -CUBE_SKINS: Dict[CubeEntType, CubeSkins] = { +CUBE_SKINS: dict[CubeEntType, CubeSkins] = { CubeEntType.norm: CubeSkins( clean=(0, 2), rusty=(3, 5), @@ -235,18 +227,18 @@ class CubeAddon: """A thing that can be attached to a cube.""" def __init__( self, - id: str, + addon_id: str, inst: str='', pack: str='', vscript: str='', - outputs: Dict[CubeOutputs, List[Output]]=EmptyMapping, - fixups: Optional[List[Tuple[str, Union[str, AddonFixups]]]]=None, + outputs: MutableMapping[CubeOutputs, list[Output]]=EmptyMapping, + fixups: list[tuple[str, str | AddonFixups]] | None = None, ): - self.id = id + self.id = addon_id self.inst = inst self.pack = pack self.vscript = vscript # Entity script(s)s to add to the cube. - self.outputs = {} # type: Dict[CubeOutputs, List[Output]] + self.outputs: dict[CubeOutputs, list[Output]] = {} # None means not defined at all, so fallback to copying everything. # "fixups" {} on the other hand would not copy any fixups. self.fixups = fixups @@ -254,9 +246,8 @@ def __init__( for out_type in CubeOutputs: self.outputs[out_type] = list(outputs.get(out_type, ())) - @classmethod - def parse(cls, props: Property): + def parse(cls, props: Property) -> 'CubeAddon': addon = cls( props['id'], props['instance', ''], @@ -281,7 +272,7 @@ def base_parse(cls, cube_id: str, props: Property): return None @staticmethod - def _parse_outputs(props: Property) -> Dict[CubeOutputs, List[Output]]: + def _parse_outputs(props: Property) -> dict[CubeOutputs, list[Output]]: outputs = {} for out_type in CubeOutputs: @@ -292,7 +283,7 @@ def _parse_outputs(props: Property) -> Dict[CubeOutputs, List[Output]]: return outputs @staticmethod - def _parse_fixups(props: Property) -> Optional[List[Tuple[str, Union[str, AddonFixups]]]]: + def _parse_fixups(props: Property) -> list[tuple[str, str | AddonFixups]] | None: fixups = [] found = False for parent in props.find_all('Fixups'): @@ -310,16 +301,16 @@ class DropperType: """A type of dropper that makes cubes.""" def __init__( self, - id: str, + drop_id: str, item_id: str, cube_pos: Vec, cube_orient: Angle, - out_start_drop: Tuple[Optional[str], str], - out_finish_drop: Tuple[Optional[str], str], - in_respawn: Tuple[Optional[str], str], + out_start_drop: tuple[str | None, str], + out_finish_drop: tuple[str | None, str], + in_respawn: tuple[str | None, str], bounce_paint_file: str, - ): - self.id = id + ) -> None: + self.id = drop_id self.instances = resolve_inst(item_id) self.cube_pos = cube_pos # Orientation of the cube. @@ -339,7 +330,7 @@ def __init__( self.bounce_paint_file = bounce_paint_file @classmethod - def parse(cls, conf: Property): + def parse(cls, conf: Property) -> DropperType: """Parse from vbsp_config.""" if 'cube_ang' in conf: cube_orient = Angle.from_str(conf['cube_ang']) @@ -376,24 +367,24 @@ class CubeType: """A type of cube that can be spawned from droppers.""" def __init__( self, - id: str, + cube_id: str, cube_type: CubeEntType, has_name: str, cube_item_id: str, is_companion: bool, try_rusty: bool, - model: Optional[str], - model_color: Optional[str], + model: str | None, + model_color: str | None, model_swap_meth: ModelSwapMeth, - pack: Union[str, List[str]], - pack_color: Union[str, List[str]], + pack: str | list[str], + pack_color: str | list[str], base_offset: float, base_tint: Vec, - outputs: Dict[CubeOutputs, List[Output]], - overlay_addon: Optional[CubeAddon], - overlay_think: Optional[str], + outputs: dict[CubeOutputs, list[Output]], + overlay_addon: CubeAddon | None, + overlay_think: str | None, ): - self.id = id + self.id = cube_id self.instances = resolve_inst(cube_item_id) # Suffix for voice attributes. @@ -402,7 +393,7 @@ def __init__( self.type = cube_type # Special cased, these don't link upwards. - self.is_valve_cube = id in VALVE_CUBE_IDS.values() + self.is_valve_cube = cube_id in VALVE_CUBE_IDS.values() # Models for normal and colorized versions. # If set it swaps to that model. @@ -415,11 +406,11 @@ def __init__( self.pack = pack self.pack_color = pack_color - # Tint rendercolour by this value. + # Tint rendercolor by this value. # This is applied before colour tints, if any. self.base_tint = base_tint - # Conceptually - is it 'companion'-like -> voiceline + # Conceptually - is it 'companion'-like for voicelines self.is_companion = is_companion # If true, use the original model and rusty skin type if no gels are # present. @@ -489,7 +480,7 @@ def parse(cls, conf: Property): cust_model = conf['model', None] cust_model_color = conf['modelColor', None] - outputs = {} # type: Dict[CubeOutputs, List[Output]] + outputs: dict[CubeOutputs, list[Output]] = {} for out_type in CubeOutputs: outputs[out_type] = out_list = [] @@ -515,7 +506,7 @@ def parse(cls, conf: Property): conf['thinkFunc', None], ) - def add_models(self, models: Dict[str, str]): + def add_models(self, models: dict[str, str]) -> None: """Get the models used for a cube type. These are stored as keys of the models dict, with the value a name to @@ -565,7 +556,7 @@ def __init__( self.cube_fixup = cube_fixup # If set, the cube has this paint type. - self.paint_type = None # type: Optional[CubePaintType] + self.paint_type: CubePaintType | None = None self.tint = tint # If set, Colorizer color to use. @@ -577,16 +568,16 @@ def __init__( # Addons to attach to the cubes. # Use a set to ensure it doesn't have two copies. - self.addons = set() # type: Set[CubeAddon] + self.addons: set[CubeAddon] = set() if cube_type.overlay_addon is not None: self.addons.add(cube_type.overlay_addon) # Outputs to fire on the cubes. - self.outputs = outputs = {} # type: Dict[CubeOutputs, List[Output]] + self.outputs: dict[CubeOutputs, list[Output]] = {} # Copy the initial outputs the base cube type needs. for out_type in CubeOutputs: - outputs[out_type] = [ + self.outputs[out_type] = [ out.copy() for out in cube_type.base_outputs[out_type] ] @@ -600,7 +591,7 @@ def __init__( CUBE_POS[Vec.from_str(cube['origin']).as_tuple()] = self # Cache of comp_kv_setters adding outputs to dropper ents. - self._kv_setters: Dict[str, Entity] = {} + self._kv_setters: dict[str, Entity] = {} def __repr__(self) -> str: drop_id = drop = cube = '' @@ -609,10 +600,8 @@ def __repr__(self) -> str: if self.cube: cube = self.cube['targetname'] if self.drop_type: - drop_id = '"{}"'.format(self.drop_type.id) - return ' "{}": {!r} -> {!r}, {!s}>'.format( - drop_id, self.cube_type.id, drop, cube, self.tint, - ) + drop_id = f' "{self.drop_type.id}"' + return f' "{self.cube_type.id}": {drop!r} -> {cube!r}, {self.tint!s}>' def use_rusty_version(self, has_gel: bool) -> bool: """Check if we can can use the rusty version. @@ -622,10 +611,11 @@ def use_rusty_version(self, has_gel: bool) -> bool: In this case, we ignore the custom model. """ return ( + not has_gel and self.cube_type.try_rusty and self.paint_type is None and self.tint is None and - CUBE_SKINS[self.cube_type].rusty is not None + CUBE_SKINS[self.cube_type.type].rusty is not None ) def get_kv_setter(self, name: str) -> Entity: @@ -648,7 +638,7 @@ def parse_conf(conf: Property): cube = CubeType.parse(cube_conf) if cube.id in CUBE_TYPES: - raise ValueError('Duplicate cube ID "{}"'.format(cube.id)) + raise ValueError(f'Duplicate cube ID "{cube.id}"') CUBE_TYPES[cube.id] = cube @@ -656,7 +646,7 @@ def parse_conf(conf: Property): dropp = DropperType.parse(dropper_conf) if dropp.id in DROPPER_TYPES: - raise ValueError('Duplicate dropper ID "{}"'.format(dropp.id)) + raise ValueError(f'Duplicate dropper ID "{dropp.id}"') DROPPER_TYPES[dropp.id] = dropp @@ -664,7 +654,7 @@ def parse_conf(conf: Property): addon = CubeAddon.parse(addon_conf) if addon.id in ADDON_TYPES: - raise ValueError('Duplicate cube addon ID "{}"'.format(addon.id)) + raise ValueError(f'Duplicate cube addon ID "{addon.id}"') ADDON_TYPES[addon.id] = addon @@ -684,8 +674,8 @@ def parse_conf(conf: Property): def parse_filter_types( - cubes: List[str] -) -> Tuple[Set[CubeType], Set[CubeType], Set[CubeType]]: + cubes: list[str] +) -> tuple[set[CubeType], set[CubeType], set[CubeType]]: """Parse a list of cube IDs to a list of included/excluded types. Each cube should be the name of an ID, with '!' before to exclude it. @@ -700,14 +690,14 @@ def parse_filter_types( This returns 3 sets of CubeTypes - all cubes, ones to include, and ones to exclude. """ - inclusions = set() # type: Set[CubeType] - exclusions = set() # type: Set[CubeType] + inclusions: set[CubeType] = set() + exclusions: set[CubeType] = set() - all_cubes = { + all_cubes: set[CubeType] = { cube for cube in CUBE_TYPES.values() if cube.in_map or cube.color_in_map - } # type: Set[CubeType] + } for cube_id in cubes: if cube_id[:1] == '!': @@ -745,7 +735,7 @@ def parse_filter_types( try: cube = CUBE_TYPES[cube_id] except KeyError: - raise KeyError('Unknown cube type "{}"!'.format(cube_id)) + raise KeyError(f'Unknown cube type "{cube_id}"!') targ_set.add(cube) if not inclusions and exclusions: @@ -759,7 +749,7 @@ def parse_filter_types( return all_cubes, inclusions, exclusions -def cube_filter(vmf: VMF, pos: Vec, cubes: List[str]) -> str: +def cube_filter(vmf: VMF, pos: Vec, cubes: list[str]) -> str: """Given a set of cube-type IDs, generate a filter for them. The filter will be made if needed, and the targetname to use returned. @@ -797,14 +787,14 @@ def cube_filter(vmf: VMF, pos: Vec, cubes: List[str]) -> str: children = inclusions # Models we need to include in the multi-filter -> name to use. - models = {} # type: Dict[str, str] + models: dict[str, str] = {} # Names to use in the final filter - names = set() + names: set[str] = set() # Check if we have the two class types. has_cube_cls = has_monst_cls = False - for cube_type in children: # type: CubeType + for cube_type in children: # Special case - no model, just by class. # FrankenTurrets don't have one model. if cube_type.type is CubeEntType.franken: @@ -851,7 +841,7 @@ def cube_filter(vmf: VMF, pos: Vec, cubes: List[str]) -> str: def _make_multi_filter( vmf: VMF, pos: Vec, - names: List[str], + names: list[str], invert: bool, has_cube_cls: bool, has_monst_cls: bool, @@ -860,8 +850,6 @@ def _make_multi_filter( This reuses ents for duplicate calls, and recurses if needed. """ - global CUBE_FILTER_MULTI_IND - # Check for existing ents of the same type. key = frozenset(names), invert try: @@ -877,18 +865,16 @@ def _make_multi_filter( # Names must now be 5 or less. - CUBE_FILTER_MULTI_IND += 1 filter_ent = vmf.create_ent( classname='filter_multi', origin=pos, - targetname='@filter_multi_{:02}'.format(CUBE_FILTER_MULTI_IND), negated=invert, # If not inverted - OR (1), if inverted AND (0). filtertype=not invert, - ) + ).make_unique('@filter_multi_') for ind, name in enumerate(names, start=1): - filter_ent['Filter{:02}'.format(ind)] = name + filter_ent[f'Filter{ind:02}'] = name CUBE_FILTERS[key] = filter_ent['targetname'] @@ -901,14 +887,12 @@ def _make_multi_filter( except KeyError: pass - CUBE_FILTER_MULTI_IND += 1 filter_ent = vmf.create_ent( - targetname='@filter_multi_{:02}'.format(CUBE_FILTER_MULTI_IND), classname='filter_multi', origin=pos, filtertype=0, # AND filter01=inv_name, - ) + ).make_unique('@filter_multi_') if has_cube_cls: filter_ent['filter02'] = FILTER_CUBE_CLS if has_monst_cls: @@ -919,8 +903,8 @@ def _make_multi_filter( return filter_ent['targetname'] -@make_flag('CubeType') -def flag_cube_type(inst: Entity, res: Property): +@conditions.make_flag('CubeType') +def flag_cube_type(inst: Entity, res: Property) -> bool: """Check if an instance is/should be a cube. This is only valid on `ITEM_BOX_DROPPER`, `ITEM_CUBE`, and items marked as @@ -967,13 +951,13 @@ def flag_cube_type(inst: Entity, res: Property): elif cube_type == 'cube': return inst is pair.cube else: - raise ValueError('Unrecognised value ' + repr(res.value)) + raise ValueError(f'Unrecognised value {res.value!r}') return pair.cube_type.id == cube_type.upper() -@make_flag('DropperColor') -def flag_dropper_color(inst: Entity, res: Property): +@conditions.make_flag('DropperColor') +def flag_dropper_color(inst: Entity, res: Property) -> bool: """Detect the color of a cube on droppers. This is `True` if the cube is coloured. The value should be a `$fixup` @@ -990,13 +974,13 @@ def flag_dropper_color(inst: Entity, res: Property): return data.tint is not None -@make_result('CubeAddon', 'DropperAddon') +@conditions.make_result('CubeAddon', 'DropperAddon') def res_dropper_addon(inst: Entity, res: Property): """Attach an addon to an item.""" try: addon = ADDON_TYPES[res.value] except KeyError: - raise ValueError('Invalid Cube Addon: {}'.format(res.value)) + raise ValueError(f'Invalid Cube Addon: {res.value!r}') try: pair = INST_TO_PAIR[inst] @@ -1007,7 +991,7 @@ def res_dropper_addon(inst: Entity, res: Property): pair.addons.add(addon) -@make_result('SetDropperOffset') +@conditions.make_result('SetDropperOffset') def res_set_dropper_off(inst: Entity, res: Property) -> None: """Update the position cubes will be spawned at for a dropper.""" try: @@ -1019,7 +1003,7 @@ def res_set_dropper_off(inst: Entity, res: Property) -> None: conditions.resolve_value(inst, res.value)) -@make_result('ChangeCubeType', 'SetCubeType') +@conditions.make_result('ChangeCubeType', 'SetCubeType') def flag_cube_type(inst: Entity, res: Property): """Change the cube type of a cube item @@ -1035,10 +1019,10 @@ def flag_cube_type(inst: Entity, res: Property): try: pair.cube_type = CUBE_TYPES[res.value] except KeyError: - raise ValueError('Unknown cube type "{}"!'.format(res.value)) + raise ValueError(f'Unknown cube type "{res.value}"!') -@make_result('CubeFilter') +@conditions.make_result('CubeFilter') def res_cube_filter(vmf: VMF, inst: Entity, res: Property): """Given a set of cube-type IDs, generate a filter for them. @@ -1061,7 +1045,7 @@ def res_cube_filter(vmf: VMF, inst: Entity, res: Property): ) -@make_result('VScriptCubePredicate') +@conditions.make_result('VScriptCubePredicate') def res_script_cube_predicate(vmf: VMF, ent: Entity, res: Property) -> None: """Given a set of cube-type IDs, generate VScript code to identify them. @@ -1099,7 +1083,7 @@ def res_script_cube_predicate(vmf: VMF, ent: Entity, res: Property) -> None: # We don't actually care about exclusions anymore. - models = {} # type: Dict[str, str] + models: dict[str, str] = {} for cube_type in inclusions: cube_type.add_models(models) @@ -1118,31 +1102,31 @@ def res_script_cube_predicate(vmf: VMF, ent: Entity, res: Property) -> None: for i, model in enumerate(model_names, 1): conf_ent[f'mdl{i:02}'] = model - return RES_EXHAUSTED + return conditions.RES_EXHAUSTED -@meta_cond(priority=-750, only_once=True) +@conditions.meta_cond(priority=-750, only_once=True) def link_cubes(vmf: VMF): """Determine the cubes set based on instance settings. This sets data, but doesn't implement the changes. """ # cube or dropper -> cubetype or droppertype value. - inst_to_type = {} # type: Dict[str, Union[CubeType, DropperType]] + inst_to_type: dict[str, CubeType | DropperType] = {} for obj_type in itertools.chain(CUBE_TYPES.values(), DROPPER_TYPES.values()): for inst in obj_type.instances: inst_to_type[inst] = obj_type # Origin -> instances - dropper_pos = {} # type: Dict[Tuple[float, float, float], Tuple[Entity, DropperType]] + dropper_pos: dict[tuple[float, float, float], tuple[Entity, DropperType]] = {} # Timer value -> instances if not 0. - dropper_timer = {} # type: Dict[int, Tuple[Entity, DropperType]] + dropper_timer: dict[int, tuple[Entity, DropperType] | tuple[None, None]] = {} # Instance -> has a cube linked to this yet? - used_droppers = {} # type: Dict[Entity, bool] + used_droppers: dict[Entity, bool] = {} # Cube items. - cubes = [] # type: List[Tuple[Entity, CubeType]] + cubes: list[tuple[Entity, CubeType]] = [] for inst in vmf.by_class['func_instance']: try: @@ -1159,11 +1143,14 @@ def link_cubes(vmf: VMF): # Infinite and 3 (default) are treated as off. if 3 < timer <= 30: if timer in dropper_timer: - raise ValueError( - 'Two droppers with the same ' - 'timer value: ' + str(timer) + LOGGER.warning( + 'Two droppers with the same timer value: {}', + timer, ) - dropper_timer[timer] = inst, inst_type + # Disable this. + dropper_timer[timer] = None, None + else: + dropper_timer[timer] = inst, inst_type # For setup later. dropper_pos[Vec.from_str(inst['origin']).as_tuple()] = inst, inst_type used_droppers[inst] = False @@ -1186,16 +1173,21 @@ def link_cubes(vmf: VMF): try: dropper, drop_type = dropper_timer[timer] except KeyError: - raise ValueError( + LOGGER.warning( 'Unknown cube "linkage" value ({}) in cube!\n' - 'A cube has a timer set which doesn\'t match ' - 'any droppers.'.format(timer) - ) from None + "A cube has a timer set which doesn\'t match any droppers.", + timer, + ) + continue + if dropper is None or drop_type is None: + # Two of these, it's ambiguous. Already logged above. + continue if used_droppers[dropper]: - raise ValueError( - 'Dropper tried to link to two cubes! (timer={})'.format( - timer - )) from None + LOGGER.warning( + 'Dropper tried to link to two cubes! (timer={})', + timer, + ) + continue used_droppers[dropper] = True # Autodrop on the dropper shouldn't be on - that makes @@ -1203,9 +1195,7 @@ def link_cubes(vmf: VMF): # Valve's dropper inverts the value, so it needs to be 1 to disable. # Custom items need 0 to disable. - dropper.fixup['$disable_autodrop'] = ( - drop_type.id == VALVE_DROPPER_ID - ) + dropper.fixup['$disable_autodrop'] = (drop_type.id == VALVE_DROPPER_ID) PAIRS.append(CubePair(cube_type, drop_type, dropper, cube)) continue @@ -1249,9 +1239,7 @@ def link_cubes(vmf: VMF): try: cube_type_id = VALVE_CUBE_IDS[cube_type_num] except KeyError: - raise ValueError('Bad cube type "{}"!'.format( - dropper.fixup['$cube_type'] - )) from None + raise ValueError(f'Bad cube type "{dropper.fixup["$cube_type"]}"!') from None try: cube_type = CUBE_TYPES[cube_type_id] except KeyError: @@ -1265,8 +1253,8 @@ def link_cubes(vmf: VMF): dropper=dropper, )) - # Check for colorizers and gel splats in the map, and apply those. - colorizer_inst = resolve_inst('', silent=True) + # Check for colorisers and gel splats in the map, and apply those. + coloriser_inst = resolve_inst('', silent=True) splat_inst = resolve_inst('', silent=True) LOGGER.info('SPLAT File: {}', splat_inst) @@ -1274,15 +1262,15 @@ def link_cubes(vmf: VMF): for inst in vmf.by_class['func_instance']: file = inst['file'].casefold() - if file in colorizer_inst: - file = colorizer_inst + if file in coloriser_inst: + file = coloriser_inst elif file in splat_inst: file = splat_inst else: # Not one we care about. continue - pairs: List[CubePair] = [] + pairs: list[CubePair] = [] origin = Vec.from_str(inst['origin']) orient = Matrix.from_angle(Angle.from_str(inst['angles'])) @@ -1301,7 +1289,7 @@ def link_cubes(vmf: VMF): with suppress(KeyError): pairs.append(CUBE_POS[pos.as_tuple()]) - if file is colorizer_inst: + if file is coloriser_inst: # The instance is useless now we know about it. inst.remove() @@ -1334,7 +1322,7 @@ def link_cubes(vmf: VMF): # and set Voice 'Has' attrs. from vbsp import settings - voice_attr = settings['has_attr'] # type: Dict[str, bool] + voice_attr: dict[str, bool] = settings['has_attr'] if PAIRS: voice_attr['cube'] = True @@ -1399,7 +1387,7 @@ def make_cube( in_dropper: bool, bounce_in_map: bool, speed_in_map: bool, -) -> Tuple[bool, Entity]: +) -> tuple[bool, Entity]: """Place a cube on the specified floor location. floor_pos is the location of the bottom of the cube. @@ -1534,7 +1522,7 @@ def make_cube( spawn_paint = None cust_model = cube_type.model - pack: Optional[Union[str, List[str]]] = cube_type.pack + pack: str | list[str] | None = cube_type.pack has_addon_inst = False vscripts = [] @@ -1597,7 +1585,7 @@ def make_cube( ent['NewSkins'] = '1' skin = CUBE_SKINS[pair.cube_type.type] - skinset: Set[int] = set() + skinset: set[int] = set() if pair.use_rusty_version(bounce_in_map or speed_in_map): ent['SkinType'] = '1' @@ -1622,7 +1610,7 @@ def make_cube( if cust_model: ent['model'] = cust_model - + if cube_type.model_swap_meth is ModelSwapMeth.CUBE_TYPE: ent['CubeType'] = CUBE_ID_CUSTOM_MODEL_HACK elif cube_type.model_swap_meth is ModelSwapMeth.SETMODEL: @@ -1656,11 +1644,11 @@ def make_cube( return has_addon_inst, ent -@meta_cond(priority=750, only_once=True) +@conditions.meta_cond(priority=750, only_once=True) def generate_cubes(vmf: VMF): """After other conditions are run, generate cubes.""" from vbsp import settings - voice_attr = settings['has_attr'] # type: Dict[str, bool] + voice_attr: dict[str, bool] = settings['has_attr'] bounce_in_map = voice_attr['bouncegel'] speed_in_map = voice_attr['speedgel'] @@ -1677,7 +1665,8 @@ def generate_cubes(vmf: VMF): # Add the custom model logic. But skip if we use the rusty version. # That overrides it to be using the normal model. - if (pair.cube_type.model_swap_meth is ModelSwapMeth.SETMODEL + if ( + pair.cube_type.model_swap_meth is ModelSwapMeth.SETMODEL and not pair.use_rusty_version(bounce_in_map or speed_in_map) ): cust_model = ( @@ -1709,7 +1698,7 @@ def generate_cubes(vmf: VMF): drop_cube = cube = should_respawn = None # One or both of the cube ents we make. - cubes = [] # type: List[Entity] + cubes: list[Entity] = [] # Transfer addon outputs to the pair data. for addon in pair.addons: @@ -1824,9 +1813,9 @@ def generate_cubes(vmf: VMF): # Voice outputs for when cubes are to be replaced. if pair.cube_type.is_companion: - CubeVoiceEvents.RESPAWN_CCUBE(drop_cube, 'OnFizzled') + CubeVoiceEvents.RESPAWN_CCUBE.add_out(drop_cube, 'OnFizzled') else: - CubeVoiceEvents.RESPAWN_NORM(drop_cube, 'OnFizzled') + CubeVoiceEvents.RESPAWN_NORM.add_out(drop_cube, 'OnFizzled') if pair.cube: pos = Vec.from_str(pair.cube['origin']) @@ -1901,9 +1890,9 @@ def generate_cubes(vmf: VMF): # it won't be replaced. if pair.dropper is None: if pair.cube_type.is_companion: - CubeVoiceEvents.DESTROY_CCUBE(cube, 'OnFizzled') + CubeVoiceEvents.DESTROY_CCUBE.add_out(cube, 'OnFizzled') else: - CubeVoiceEvents.DESTROY_NORM(cube, 'OnFizzled') + CubeVoiceEvents.DESTROY_NORM.add_out(cube, 'OnFizzled') if drop_cube is not None and cube is not None: # We have both - it's a linked cube and dropper. @@ -1920,9 +1909,9 @@ def generate_cubes(vmf: VMF): # It is getting replaced. if pair.cube_type.is_companion: - CubeVoiceEvents.RESPAWN_CCUBE(cube, 'OnFizzled') + CubeVoiceEvents.RESPAWN_CCUBE.add_out(cube, 'OnFizzled') else: - CubeVoiceEvents.RESPAWN_NORM(cube, 'OnFizzled') + CubeVoiceEvents.RESPAWN_NORM.add_out(cube, 'OnFizzled') # Fizzle the cube when triggering the dropper. drop_fizzle_name, drop_fizzle_command = pair.drop_type.out_start_drop @@ -1936,5 +1925,5 @@ def generate_cubes(vmf: VMF): # Voice events to add to all cubes. for cube in cubes: if pair.cube_type.type is CubeEntType.franken: - CubeVoiceEvents.PICKUP_FRANKEN(cube, 'OnPlayerPickup') - CubeVoiceEvents.PICKUP_ANY(cube, 'OnPlayerPickup') + CubeVoiceEvents.PICKUP_FRANKEN.add_out(cube, 'OnPlayerPickup') + CubeVoiceEvents.PICKUP_ANY.add_out(cube, 'OnPlayerPickup') diff --git a/src/precomp/fizzler.py b/src/precomp/fizzler.py index fc7c57006..dc7c0438d 100644 --- a/src/precomp/fizzler.py +++ b/src/precomp/fizzler.py @@ -1,16 +1,18 @@ """Implements fizzler/laserfield generation and customisation.""" -import random -from collections import defaultdict, namedtuple -from typing import Dict, List, Optional, Tuple, Iterator, Set, Callable +from __future__ import annotations +from collections import defaultdict +from typing import Iterator, Callable import itertools from enum import Enum +import attr -import utils import srctools.logger import srctools.vmf from srctools.vmf import VMF, Solid, Entity, Side, Output from srctools import Property, NoKeyError, Vec, Matrix, Angle + +import utils from precomp import ( instance_traits, tiling, instanceLocs, texturing, @@ -18,16 +20,15 @@ options, packing, template_brush, - conditions, + conditions, rand, ) import consts LOGGER = srctools.logger.get_logger(__name__) -FIZZ_TYPES = {} # type: Dict[str, FizzlerType] - -FIZZLERS = {} # type: Dict[str, Fizzler] +FIZZ_TYPES: dict[str, FizzlerType] = {} +FIZZLERS: dict[str, Fizzler] = {} # Fizzler textures are higher-res than laserfields. FIZZLER_TEX_SIZE = 1024 @@ -85,8 +86,21 @@ class FizzInst(Enum): BASE = 'base_inst' # If set, swap the instance to this. -MatModify = namedtuple('MatModify', 'name mat_var') -FizzBeam = namedtuple('FizzBeam', 'offset keys speed_min speed_max') + +@attr.frozen +class MatModify: + """Data for injected material modify controls.""" + name: str + mat_var: str + + +@attr.frozen +class FizzBeam: + """Configuration for env_beams added across fizzlers.""" + offset: list[Vec] + keys: Property + speed_min: int + speed_max: int def read_configs(conf: Property) -> None: @@ -106,6 +120,8 @@ def read_configs(conf: Property) -> None: # In Aperture Tag, we don't have portals. For fizzler types which block # portals (trigger_portal_cleanser), additionally fizzle paint. for fizz in FIZZ_TYPES.values(): + if not fizz.blocks_portals: + continue for brush in fizz.brushes: if brush.keys['classname'].casefold() == 'trigger_portal_cleanser': brush_name = brush.name @@ -144,21 +160,21 @@ class FizzlerType: def __init__( self, fizz_id: str, - item_ids: List[str], - voice_attrs: List[str], - pack_lists: Set[str], - pack_lists_static: Set[str], + item_ids: list[str], + voice_attrs: list[str], + pack_lists: set[str], + pack_lists_static: set[str], model_local_name: str, model_name_type: ModelName, nodraw_behind: bool, - brushes: List['FizzlerBrush'], - beams: List['FizzBeam'], - inst: Dict[Tuple[FizzInst, bool], List[str]], + brushes: list[FizzlerBrush], + beams: list[FizzBeam], + inst: dict[tuple[FizzInst, bool], list[str]], temp_brush_keys: Property, - temp_min: Optional[str], - temp_max: Optional[str], - temp_single: Optional[str], + temp_min: str | None, + temp_max: str | None, + temp_single: str | None, ): self.id = fizz_id @@ -195,6 +211,17 @@ def __init__( self.temp_min = temp_min self.temp_brush_keys = temp_brush_keys + self.blocks_portals = False + self.fizzles_portals = False + # We want to know which fizzlers block or fizzle portals. + for br in brushes: + if br.keys['classname'].casefold() == 'trigger_portal_cleanser': + # Fizzlers always block. + self.blocks_portals = True + if srctools.conv_int(br.keys.get('spawnflags', 0)) & 1: + self.fizzles_portals = True + LOGGER.debug('{}: blocks={}, fizzles={}', fizz_id, self.blocks_portals, self.fizzles_portals) + @classmethod def parse(cls, conf: Property): """Read in a fizzler from a config.""" @@ -216,7 +243,7 @@ def parse(cls, conf: Property): # We can't rename without a local name. model_name_type = ModelName.SAME - inst: Dict[Tuple[FizzInst, bool], List[str]] = {} + inst: dict[tuple[FizzInst, bool], list[str]] = {} for inst_type, is_static in itertools.product(FizzInst, (False, True)): inst_type_name = inst_type.value + ('_static' if is_static else '') inst[inst_type, is_static] = instances = [ @@ -229,10 +256,10 @@ def parse(cls, conf: Property): if weights: # Produce the weights, then process through the original # list to build a new one with repeated elements. - inst[inst_type, is_static] = instances = [ - instances[i] - for i in conditions.weighted_random(len(instances), weights) - ] + inst[inst_type, is_static] = instances = list(map( + instances.__getitem__, + rand.parse_weights(len(instances), weights) + )) # If static versions aren't given, reuse non-static ones. # We do False, True so it's already been calculated. if not instances and is_static: @@ -263,7 +290,7 @@ def parse(cls, conf: Property): conf.find_all('Brush') ] - beams = [] # type: List[FizzBeam] + beams: list[FizzBeam] = [] for beam_prop in conf.find_all('Beam'): offsets = [ Vec.from_str(off.value) @@ -271,8 +298,8 @@ def parse(cls, conf: Property): beam_prop.find_all('pos') ] keys = Property('', [ - beam_prop.find_key('Keys', []), - beam_prop.find_key('LocalKeys', []) + beam_prop.find_key('Keys', or_blank=True), + beam_prop.find_key('LocalKeys', or_blank=True) ]) beams.append(FizzBeam( offsets, @@ -288,7 +315,7 @@ def parse(cls, conf: Property): else: temp_brush_keys = Property('--', [ temp_conf.find_key('Keys'), - temp_conf.find_key('LocalKeys', []), + temp_conf.find_key('LocalKeys', or_blank=True), ]) # Find and load the templates. @@ -322,7 +349,7 @@ def __init__( fizz_type: FizzlerType, up_axis: Vec, base_inst: Entity, - emitters: List[Tuple[Vec, Vec]] + emitters: list[tuple[Vec, Vec]] ) -> None: self.fizz_type = fizz_type self.base_inst = base_inst @@ -353,10 +380,10 @@ def gen_flinch_trigs(self, vmf: VMF, name: str, start_disabled: str) -> None: back instead when walking into the field. Only applies to vertical triggers. """ - normal = abs(self.normal()) # type: Vec + normal = self.normal() # Horizontal fizzlers would just have you fall through. - if normal.z: + if abs(normal.z) > 1e-6: return # Disabled. @@ -412,7 +439,7 @@ def gen_flinch_trigs(self, vmf: VMF, name: str, start_disabled: str) -> None: mat=consts.Tools.TRIGGER, ).solid) - def set_tiles_behind_models(self, origin: Vec, normal: Vec, to_nodraw: bool): + def set_tiles_behind_models(self, origin: Vec, normal: Vec, to_nodraw: bool) -> None: """Set the tile surface behind a model to specific values. position is the center-point on the wall. @@ -433,13 +460,13 @@ def set_tiles_behind_models(self, origin: Vec, normal: Vec, to_nodraw: bool): tile = tiling.TILES[ (origin - 64 * normal).as_tuple(), normal.as_tuple() - ] # type: tiling.TileDef + ] # Reversed? if up_axis == u_axis: tile.set_fizz_orient('v') elif up_axis == v_axis: - tile.set_fizz_orient('u') # type: ignore + tile.set_fizz_orient('u') else: LOGGER.error( 'Not U or V?: {} @ {} ("{}")', @@ -472,14 +499,18 @@ def set_tiles_behind_models(self, origin: Vec, normal: Vec, to_nodraw: bool): orig.color, ) - def _gen_fizz_border(self, vmf: VMF, seg_min: Vec, seg_max: Vec): - """Generate borders above/below fizzlers.""" + def _edit_border_tiles(self, vmf: VMF, seg_min: Vec, seg_max: Vec, border: bool, blacken: bool) -> None: + """Modify tiles above/below fizzlers. + + If the border is enabled, this adds those overlays. + If tile blackening is enabled, it makes the tiles black also. + """ up = abs(self.up_axis) forward = (seg_max - seg_min).norm() norm_dir = self.normal().axis() - tiledefs_up: List[tiling.TileDef] = [] - tiledefs_dn: List[tiling.TileDef] = [] + tiledefs_up: list[tiling.TileDef] = [] + tiledefs_dn: list[tiling.TileDef] = [] overlay_len = int((seg_max - seg_min).mag()) @@ -488,9 +519,32 @@ def _gen_fizz_border(self, vmf: VMF, seg_min: Vec, seg_max: Vec): min_pos = seg_min.copy() min_pos[norm_dir] = min_pos[norm_dir] // 128 * 128 + 64 + u_ax, v_ax = Vec.INV_AXIS[up.axis()] + side_dir = Vec.dot(abs(Vec.cross(up, forward)), seg_min - min_pos) + side_ind = round((side_dir + 48) / 32, 2) # 0/1/2/3 for the center of tiles. + # 4.5 -> [4, 5] and 4 -> [4]. + pos_iter = sorted({round(side_ind - 0.25), round(side_ind + 0.25)}) + if u_ax == forward.axis(): + uv_pos = [ + (u, v) + for u in range(4) + for v in pos_iter + ] + elif v_ax == forward.axis(): + uv_pos = [ + (u, v) + for u in pos_iter + for v in range(4) + ] + else: # Should be impossible? + uv_pos = [] + for offset in range(64, overlay_len, 128): - # Each position on top or bottom, inset 64 from each end + # Each position on top or bottom, inset 64 from each end. + # First check if the tiles themselves are present, then check if any of the + # subtiles are present - blackening on the way if required. pos = min_pos + offset * forward + tile_cat = [] try: top_tile = tiling.TILES[ (pos + 128 * up).as_tuple(), @@ -499,8 +553,7 @@ def _gen_fizz_border(self, vmf: VMF, seg_min: Vec, seg_max: Vec): except KeyError: pass else: - if any(tile.is_tile for u, v, tile in top_tile): - tiledefs_up.append(top_tile) + tile_cat.append((tiledefs_up, top_tile)) try: btm_tile = tiling.TILES[ (pos - 128 * up).as_tuple(), @@ -509,10 +562,19 @@ def _gen_fizz_border(self, vmf: VMF, seg_min: Vec, seg_max: Vec): except KeyError: pass else: - if any(tile.is_tile for u, v, tile in btm_tile): - tiledefs_dn.append(btm_tile) - - if not tiledefs_up and not tiledefs_dn: + tile_cat.append((tiledefs_dn, btm_tile)) + for tiledefs, tile in tile_cat: + found = False + for u, v in uv_pos: + subtile = tile[u, v] + if subtile.is_tile: + found = True + if blacken: + tile[u, v] = subtile.as_black + if found: + tiledefs.append(tile) + + if not border or (not tiledefs_up and not tiledefs_dn): return overlay_thickness = options.get(int, 'fizz_border_thickness') @@ -566,10 +628,10 @@ class FizzlerBrush: def __init__( self, name: str, - textures: Dict[TexGroup, Optional[str]], - keys: Dict[str, str], - local_keys: Dict[str, str], - outputs: List[Output], + textures: dict[TexGroup, str | None], + keys: dict[str, str], + local_keys: dict[str, str], + outputs: list[Output], thickness: float=2.0, stretch_center: bool=True, side_color: Vec=None, @@ -608,12 +670,12 @@ def __init__( self.mat_mod_var = mat_mod_var self.mat_mod_name = mat_mod_name - self.textures = {} # type: Dict[TexGroup, Optional[str]] + self.textures: dict[TexGroup, str | None] = {} for group in TexGroup: self.textures[group] = textures.get(group, None) @classmethod - def parse(cls, conf: Property) -> 'FizzlerBrush': + def parse(cls, conf: Property) -> FizzlerBrush: """Parse from a config file.""" if 'side_color' in conf: side_color = conf.vec('side_color') @@ -626,7 +688,7 @@ def parse(cls, conf: Property) -> 'FizzlerBrush': conf.find_children('Outputs') ] - textures = {} + textures: dict[TexGroup, str | None] = {} for group in TexGroup: textures[group] = conf['tex_' + group.value, None] @@ -643,11 +705,7 @@ def parse(cls, conf: Property) -> 'FizzlerBrush': } if 'classname' not in keys: - raise ValueError( - 'Fizzler Brush "{}" does not have a classname!'.format( - conf['name'], - ) - ) + raise ValueError(f'Fizzler Brush "{conf["name"]}" does not have a classname!') return FizzlerBrush( name=conf['name'], @@ -733,18 +791,16 @@ def generate( # Treat 127.9999 as 128, etc. if (round(field_length) == 128 and short_tex) or trigger_tex or fitted_tex: # We need only one brush. - brush = vmf.make_prism( - p1=(origin - + (self.thickness/2) * normal - + 64 * fizz.up_axis - + (field_length/2) * field_axis - ), - p2=(origin - - (self.thickness / 2) * normal - - 64 * fizz.up_axis - - (field_length / 2) * field_axis - ), - ).solid # type: Solid + brush = vmf.make_prism(( + origin + + (self.thickness/2) * normal + + 64 * fizz.up_axis + + (field_length/2) * field_axis + ), (origin + - (self.thickness / 2) * normal + - 64 * fizz.up_axis + - (field_length / 2) * field_axis + )).solid yield brush if trigger_tex: for side in brush.sides: @@ -793,47 +849,41 @@ def generate( side_len = 63 center_len = field_length - 126 - brush_left = vmf.make_prism( - p1=(origin - - (self.thickness / 2) * normal - - 64 * fizz.up_axis - - (side_len - field_length/2) * field_axis - ), - p2=(origin - + (self.thickness / 2) * normal - + 64 * fizz.up_axis - + (field_length / 2) * field_axis - ), - ).solid # type: Solid + brush_left = vmf.make_prism(( + origin + - (self.thickness / 2) * normal + - 64 * fizz.up_axis + - (side_len - field_length/2) * field_axis + ), (origin + + (self.thickness / 2) * normal + + 64 * fizz.up_axis + + (field_length / 2) * field_axis + )).solid yield brush_left - brush_right = vmf.make_prism( - p1=(origin + brush_right = vmf.make_prism(( + origin + - (self.thickness / 2) * normal + - 64 * fizz.up_axis + - (field_length / 2) * field_axis + ), (origin + + (self.thickness / 2) * normal + + 64 * fizz.up_axis + + (side_len - field_length/2) * field_axis + )).solid + yield brush_right + + if center_len: + brush_center = vmf.make_prism(( + origin - (self.thickness / 2) * normal - 64 * fizz.up_axis - - (field_length / 2) * field_axis - ), - p2=(origin + - (center_len / 2) * field_axis + ), (origin + (self.thickness / 2) * normal + 64 * fizz.up_axis - + (side_len - field_length/2) * field_axis - ), - ).solid # type: Solid - yield brush_right - - if center_len: - brush_center = vmf.make_prism( - p1=(origin - - (self.thickness / 2) * normal - - 64 * fizz.up_axis - - (center_len / 2) * field_axis - ), - p2=(origin - + (self.thickness / 2) * normal - + 64 * fizz.up_axis - + (center_len/2) * field_axis - ), - ).solid # type: Solid + + (center_len/2) * field_axis + )).solid yield brush_center brushes = [ @@ -907,7 +957,7 @@ def _texture_fit( fizz: Fizzler, neg: Vec, pos: Vec, - is_laserfield=False, + is_laserfield: bool = False, ) -> None: """Calculate the texture offsets required for fitting a texture.""" # Compute the orientations that are up and along the fizzler. @@ -929,7 +979,7 @@ def _texture_fit( side.vaxis.offset %= tex_size -def parse_map(vmf: VMF, voice_attrs: Dict[str, bool]) -> None: +def parse_map(vmf: VMF, voice_attrs: dict[str, bool]) -> None: """Analyse fizzler instances to assign fizzler types. Instance traits are required. @@ -938,7 +988,7 @@ def parse_map(vmf: VMF, voice_attrs: Dict[str, bool]) -> None: """ # Item ID and model skin -> fizzler type - fizz_types = {} # type: Dict[Tuple[str, int], FizzlerType] + fizz_types: dict[tuple[str, int], FizzlerType] = {} for fizz_type in FIZZ_TYPES.values(): for item_id in fizz_type.item_ids: @@ -958,11 +1008,11 @@ def parse_map(vmf: VMF, voice_attrs: Dict[str, bool]) -> None: fizz_types[item_id, 0] = fizz_type fizz_types[item_id, 2] = fizz_type - fizz_bases = {} # type: Dict[str, Entity] - fizz_models = defaultdict(list) # type: Dict[str, List[Entity]] + fizz_bases: dict[str, Entity] = {} + fizz_models: dict[str, list[Entity]] = defaultdict(list) # Position and normal -> name, for output relays. - fizz_pos = {} # type: Dict[Tuple[Tuple[float, float, float], Tuple[float, float, float]], str] + fizz_pos: dict[tuple[tuple[float, float, float], tuple[float, float, float]], str] = {} # First use traits to gather up all the instances. for inst in vmf.by_class['func_instance']: @@ -1002,10 +1052,8 @@ def parse_map(vmf: VMF, voice_attrs: Dict[str, bool]) -> None: # We don't care about the instances after this, so don't keep track. length_axis = orient.up().axis() - emitters: List[Tuple[Vec, Vec]] = [] - - model_pairs: Dict[Tuple[float, float], Vec] = {} - + emitters: list[tuple[Vec, Vec]] = [] + model_pairs: dict[tuple[float, float], Vec] = {} model_skin = models[0].fixup.int('$skin') try: @@ -1105,15 +1153,14 @@ def parse_map(vmf: VMF, voice_attrs: Dict[str, bool]) -> None: @conditions.meta_cond(priority=500, only_once=True) -def generate_fizzlers(vmf: VMF): +def generate_fizzlers(vmf: VMF) -> None: """Generates fizzler models and the brushes according to their set types. After this is done, fizzler-related conditions will not function correctly. However the model instances are now available for modification. """ - from vbsp import MAP_RAND_SEED - has_fizz_border = 'fizz_border' in texturing.SPECIAL + conf_tile_blacken = options.get_itemconf(('VALVE_FIZZLER', 'BlackenTiles'), False) for fizz in FIZZLERS.values(): if fizz.base_inst not in vmf.entities: @@ -1130,6 +1177,7 @@ def generate_fizzlers(vmf: VMF): fizz.base_inst.fixup.int('$connectioncount', 0) == 0 and fizz.base_inst.fixup.bool('$start_enabled', 1) ) + tile_blacken = conf_tile_blacken and fizz.fizz_type.blocks_portals pack_list = ( fizz.fizz_type.pack_lists_static @@ -1140,8 +1188,9 @@ def generate_fizzlers(vmf: VMF): packing.pack_list(vmf, pack) if fizz_type.inst[FizzInst.BASE, is_static]: - random.seed('{}_fizz_base_{}'.format(MAP_RAND_SEED, fizz_name)) - fizz.base_inst['file'] = random.choice(fizz_type.inst[FizzInst.BASE, is_static]) + rng = rand.seed(b'fizz_base', fizz_name) + fizz.base_inst['file'] = base_file = rng.choice(fizz_type.inst[FizzInst.BASE, is_static]) + conditions.ALL_INST.add(base_file.casefold()) if not fizz.emitters: LOGGER.warning('No emitters for fizzler "{}"!', fizz_name) @@ -1149,7 +1198,7 @@ def generate_fizzlers(vmf: VMF): # Brush index -> entity for ones that need to merge. # template_brush is used for the templated one. - single_brushes = {} # type: Dict[FizzlerBrush, Entity] + single_brushes: dict[FizzlerBrush, Entity] = {} if fizz_type.temp_max or fizz_type.temp_min: template_brush_ent = vmf.create_ent( @@ -1188,31 +1237,23 @@ def generate_fizzlers(vmf: VMF): # Define a function to do the model names. model_index = 0 if fizz_type.model_naming is ModelName.SAME: - def get_model_name(ind): + def get_model_name(ind: int) -> str: """Give every emitter the base's name.""" return fizz_name elif fizz_type.model_naming is ModelName.LOCAL: - def get_model_name(ind): + def get_model_name(ind: int) -> str: """Give every emitter a name local to the base.""" - return fizz_name + '-' + fizz_type.model_name + return f'{fizz_name}-{fizz_type.model_name}' elif fizz_type.model_naming is ModelName.PAIRED: - def get_model_name(ind): + def get_model_name(ind: int) -> str: """Give each pair of emitters the same unique name.""" - return '{}-{}{:02}'.format( - fizz_name, - fizz_type.model_name, - ind, - ) + return f'{fizz_name}-{fizz_type.model_name}{ind:02}' elif fizz_type.model_naming is ModelName.UNIQUE: - def get_model_name(ind): + def get_model_name(ind: int) -> str: """Give every model a unique name.""" nonlocal model_index model_index += 1 - return '{}-{}{:02}'.format( - fizz_name, - fizz_type.model_name, - model_index, - ) + return f'{fizz_name}-{fizz_type.model_name}{model_index:02}' else: raise ValueError('Bad ModelName?') @@ -1226,7 +1267,7 @@ def get_model_name(ind): counter = 1 for seg_min, seg_max in fizz.emitters: - for offset in beam.offset: # type: Vec + for offset in beam.offset: min_off = offset.copy() max_off = offset.copy() min_off.localise(seg_min, min_orient) @@ -1236,9 +1277,9 @@ def get_model_name(ind): # Allow randomising speed and direction. if 0 < beam.speed_min < beam.speed_max: - random.seed('{}{}{}'.format(MAP_RAND_SEED, min_off, max_off)) - beam_ent['TextureScroll'] = random.randint(beam.speed_min, beam.speed_max) - if random.choice((False, True)): + rng = rand.seed(b'fizz_beam', min_off, max_off) + beam_ent['TextureScroll'] = rng.randint(beam.speed_min, beam.speed_max) + if rng.choice((False, True)): # Flip to reverse direction. min_off, max_off = max_off, min_off @@ -1256,7 +1297,7 @@ def get_model_name(ind): if fizz.has_cust_position: fizz_traits.add('cust_shape') - mat_mod_tex = {} # type: Dict[FizzlerBrush, Set[str]] + mat_mod_tex: dict[FizzlerBrush, set[str]] = {} for brush_type in fizz_type.brushes: if brush_type.mat_mod_var is not None: mat_mod_tex[brush_type] = set() @@ -1267,39 +1308,39 @@ def get_model_name(ind): for seg_ind, (seg_min, seg_max) in enumerate(fizz.emitters, start=1): length = (seg_max - seg_min).mag() - random.seed('{}_fizz_{}'.format(MAP_RAND_SEED, seg_min)) + rng = rand.seed(b'fizz_seg', seg_min, seg_max) if length == 128 and fizz_type.inst[FizzInst.PAIR_SINGLE, is_static]: min_inst = vmf.create_ent( targetname=get_model_name(seg_ind), classname='func_instance', - file=random.choice(fizz_type.inst[FizzInst.PAIR_SINGLE, is_static]), + file=rng.choice(fizz_type.inst[FizzInst.PAIR_SINGLE, is_static]), origin=(seg_min + seg_max)/2, - angles=min_orient.to_angle(), + angles=min_orient, ) else: # Both side models. min_inst = vmf.create_ent( targetname=get_model_name(seg_ind), classname='func_instance', - file=random.choice(model_min), + file=rng.choice(model_min), origin=seg_min, - angles=min_orient.to_angle(), + angles=min_orient, ) - random.seed('{}_fizz_{}'.format(MAP_RAND_SEED, seg_max)) max_inst = vmf.create_ent( targetname=get_model_name(seg_ind), classname='func_instance', - file=random.choice(model_max), + file=rng.choice(model_max), origin=seg_max, - angles=max_orient.to_angle(), + angles=max_orient, ) max_inst.fixup.update(fizz.base_inst.fixup) instance_traits.get(max_inst).update(fizz_traits) min_inst.fixup.update(fizz.base_inst.fixup) instance_traits.get(min_inst).update(fizz_traits) - if has_fizz_border: - fizz._gen_fizz_border(vmf, seg_min, seg_max) + if has_fizz_border or tile_blacken: + # noinspection PyProtectedMember + fizz._edit_border_tiles(vmf, seg_min, seg_max, has_fizz_border, tile_blacken) if fizz.embedded: fizz.set_tiles_behind_models(seg_min, forward, fizz_type.nodraw_behind) @@ -1310,14 +1351,14 @@ def get_model_name(ind): # Go 64 from each side, and always have at least 1 section # A 128 gap will have length = 0 - for ind, dist in enumerate(range(64, round(length) - 63, 128)): + rng = rand.seed(b'fizz_mid', seg_min, seg_max) + for dist in range(64, round(length) - 63, 128): mid_pos = seg_min + forward * dist - random.seed('{}_fizz_mid_{}'.format(MAP_RAND_SEED, mid_pos)) mid_inst = vmf.create_ent( classname='func_instance', targetname=fizz_name, angles=min_orient.to_angle(), - file=random.choice(fizz_type.inst[FizzInst.GRID, is_static]), + file=rng.choice(fizz_type.inst[FizzInst.GRID, is_static]), origin=mid_pos, ) mid_inst.fixup.update(fizz.base_inst.fixup) diff --git a/src/precomp/item_chain.py b/src/precomp/item_chain.py index bd7147ea3..24d068828 100644 --- a/src/precomp/item_chain.py +++ b/src/precomp/item_chain.py @@ -2,61 +2,75 @@ This includes Unstationary Scaffolds and Vactubes. """ -from typing import Any, Dict, Container, List, Optional, Iterator +from __future__ import annotations +from typing import Optional, Iterator, TypeVar, Generic, Iterable + +import attr from precomp import connections -from srctools import Entity, VMF +from srctools import Entity, VMF, Matrix, Angle, Vec from precomp.connections import Item __all__ = ['Node', 'chain'] +ConfT = TypeVar('ConfT') -class Node: +@attr.define(eq=False) +class Node(Generic[ConfT]): """Represents a single node in the chain.""" - __slots__ = ['item', 'prev', 'next', 'conf'] - def __init__(self, item: Item): - self.item = item - self.conf = None # type: Any - self.prev = None # type: Optional[Node] - self.next = None # type: Optional[Node] + item: Item = attr.ib(init=True) + conf: ConfT = attr.ib(init=True) + + # Origin and angles of the instance. + pos = attr.ib(init=False, default=attr.Factory( + lambda self: Vec.from_str(self.item.inst['origin']), takes_self=True, + )) + orient = attr.ib(init=False, default=attr.Factory( + lambda self: Matrix.from_angle(Angle.from_str(self.item.inst['angles'])), + takes_self=True, + )) + + # The links between nodes + prev: Optional[Node[ConfT]] = attr.ib(default=None, init=False) + next: Optional[Node[ConfT]] = attr.ib(default=None, init=False) @property def inst(self) -> Entity: + """Return the relevant instance.""" return self.item.inst + @classmethod + def from_inst(cls, inst: Entity, conf: ConfT) -> Node[ConfT]: + """Find the item for this instance, and return the node.""" + name = inst['targetname'] + try: + return Node(connections.ITEMS[name], conf) + except KeyError: + raise ValueError('No item for "{}"?'.format(name)) from None + def chain( - vmf: VMF, - inst_files: Container[str], + node_list: Iterable[Node[ConfT]], allow_loop: bool, -) -> Iterator[List[Node]]: +) -> Iterator[list[Node[ConfT]]]: """Evaluate the chain of items. - inst is the instances that are part of this chain. + inst_files maps an instance to the configuration to store. Lists of nodes are yielded, for each separate track. """ # Name -> node - nodes = {} # type: Dict[str, Node] - - for inst in vmf.by_class['func_instance']: - if inst['file'].casefold() not in inst_files: - continue - name = inst['targetname'] - try: - nodes[name] = Node(connections.ITEMS[name]) - except KeyError: - raise ValueError('No item for "{}"?'.format(name)) from None + nodes: dict[str, Node[ConfT]] = { + node.item.name: node + for node in node_list + } # Now compute the links, and check for double-links. - for name, node in nodes.items(): - has_other_io = False + for node in nodes.values(): for conn in list(node.item.outputs): try: next_node = nodes[conn.to_item.name] except KeyError: - # Not one of our instances - fine, it's just actual - # IO. - has_other_io = True + # Not one of our instances - fine, it's just actual IO. continue conn.remove() if node.next is not None: @@ -66,10 +80,6 @@ def chain( node.next = next_node next_node.prev = node - # If we don't have real IO, we can delete the antlines automatically. - if not has_other_io: - node.item.delete_antlines() - todo = set(nodes.values()) while todo: # Grab a random node, then go backwards until we find the start. diff --git a/src/precomp/music.py b/src/precomp/music.py index edfeb0294..14fcebd23 100644 --- a/src/precomp/music.py +++ b/src/precomp/music.py @@ -116,19 +116,18 @@ def add( # Add the ents for the config itself. # If the items aren't in the map, we can skip adding them. # Speed-gel sounds also play when flinging, so keep it always. - - funnel = conf.find_key('tbeam', []) - bounce = conf.find_key('bouncegel', []) + funnel = conf.find_key('tbeam', or_blank=True) + bounce = conf.find_key('bouncegel', or_blank=True) make_channel_conf( vmf, loc, Channel.BASE, - conf.find_key('base', []).as_array(), + conf.find_key('base', or_blank=True).as_array(), ) make_channel_conf( vmf, loc, Channel.SPEED, - conf.find_key('speedgel', []).as_array(), + conf.find_key('speedgel', or_blank=True).as_array(), ) if 'funnel' in voice_attr or 'excursionfunnel' in voice_attr: make_channel_conf( @@ -145,6 +144,12 @@ def add( bounce.as_array(), ) + packfiles = conf.find_key('pack', or_blank=True).as_array() + if packfiles: + packer = vmf.create_ent('comp_pack', origin=loc) + for i, fname in enumerate(packfiles, 1): + packer[f'generic{i:02}'] = fname + if inst: # We assume the instance is setup correct. vmf.create_ent( diff --git a/src/precomp/options.py b/src/precomp/options.py index b59147ef9..f6898e5e1 100644 --- a/src/precomp/options.py +++ b/src/precomp/options.py @@ -29,7 +29,7 @@ class TYPE(Enum): def convert(self, value: str) -> Any: """Convert a string to the desired argument type.""" return self.value(value) - + TYPE_NAMES = { TYPE.STR: 'Text', TYPE.INT: 'Whole Number', @@ -150,7 +150,7 @@ def set_opt(opt_name: str, value: str) -> None: def get(expected_type: Type[OptionT], name: str) -> Optional[OptionT]: - """Get the given option. + """Get the given option. expected_type should be the class of the value that's expected. The value can be None if unset. @@ -171,7 +171,7 @@ def get(expected_type: Type[OptionT], name: str) -> Optional[OptionT]: expected_type = str else: enum_type = None - + # Don't allow subclasses (bool/int) if type(val) is not expected_type: raise ValueError('Option "{}" is {} (expected {})'.format( @@ -267,7 +267,7 @@ def get_itemconf( def dump_info(file: TextIO) -> None: """Create the wiki page for item options, given a file to write to.""" print(DOC_HEADER, file=file) - + for opt in DEFAULTS: if opt.default is None: default = '' @@ -276,7 +276,7 @@ def dump_info(file: TextIO) -> None: else: default = ' = `' + repr(opt.default) + '`' file.write(INFO_DUMP_FORMAT.format( - id=opt.name, + id=opt.name, default=default, type=TYPE_NAMES[opt.type], desc='\n'.join(opt.doc), @@ -293,9 +293,6 @@ def dump_info(file: TextIO) -> None: """Remove the glass/grating info_lighting entities. This should be used when the border is made of brushes. """), - Opt('remove_exit_signs', False, - """Remove the exit sign overlays for singleplayer. - """), Opt('_tiling_template_', '__TILING_TEMPLATE__', """Change the template used for generating brushwork. @@ -433,6 +430,16 @@ def dump_info(file: TextIO) -> None: - $arrow is set to "north", "south", "east" or "west" to indicate the direction the arrow should point relative to the sign. """), + Opt('remove_exit_signs', False, + """Remove the exit sign overlays for singleplayer. + + This does not apply if signExitInst is set and the overlays are next to + each other. + """), + Opt('remove_exit_signs_dual', True, + """Remove the exit sign overlays if signExitInst is set and they're + next to each other. + """), Opt('broken_antline_chance', 0.0, """The chance an antline will be 'broken'. @@ -483,6 +490,8 @@ def dump_info(file: TextIO) -> None: """), Opt('generate_tidelines', False, """Generate tideline overlays around the outside of goo pits. + + The material used is configured by `overlays.tideline`. """), Opt('glass_floorbeam_temp', TYPE.STR, @@ -539,7 +548,7 @@ def dump_info(file: TextIO) -> None: This shouldn't need to be changed. """), - + Opt('global_pti_ents_loc', Vec(-2400, -2800, 0), """Location of global_pti_ents. diff --git a/src/precomp/rand.py b/src/precomp/rand.py new file mode 100644 index 000000000..cd50c0a03 --- /dev/null +++ b/src/precomp/rand.py @@ -0,0 +1,110 @@ +"""Handles randomising values in a repeatable way.""" +from __future__ import annotations +from random import Random +from struct import Struct +import hashlib + +from precomp import instanceLocs +from srctools import VMF, Vec, Angle, Entity, logger, Matrix + + +# A hash object which we seed using the map layout, so it is somewhat unique. +# This should be copied to use for specific purposes, never modified. +MAP_HASH = hashlib.sha256() +ONE_FLOAT = Struct('f') +THREE_FLOATS = Struct('<3f') +NINE_FLOATS = Struct('<9e') # Half-precision float, don't need the accuracy. +THREE_INTS = Struct('<3i') +LOGGER = logger.get_logger(__name__) + + +def parse_weights(count: int, weights: str) -> list[int]: + """Generate random indexes with weights. + + This produces a list intended to be fed to random.choice(), with + repeated indexes corresponding to the comma-separated weight values. + """ + if weights == '': + # Empty = equal weighting. + return list(range(count)) + if ',' not in weights: + LOGGER.warning('Invalid weight! ({})', weights) + return list(range(count)) + + # Parse the weight + vals = weights.split(',') + weight = [] + if len(vals) == count: + for i, val in enumerate(vals): + val = val.strip() + if val.isdecimal(): + # repeat the index the correct number of times + weight.extend( + [i] * int(val) + ) + else: + # Abandon parsing + break + if len(weight) == 0: + LOGGER.warning('Failed parsing weight! ({!s})',weight) + weight = list(range(count)) + # random.choice(weight) will now give an index with the correct + # probabilities. + return weight + + +def init_seed(vmf: VMF) -> str: + """Seed with the map layout. + + We use the position of the ambient light instances, which is unique to any + given layout, but small changes won't change since only every 4th grid pos + is relevant. + """ + amb_light = instanceLocs.resolve_one('', error=True) + light_names = [] + for inst in vmf.by_class['func_instance']: + if inst['file'].casefold() == amb_light: + pos = Vec.from_str(inst['origin']) / 64 + light_names.append(THREE_INTS.pack(round(pos.x), round(pos.y), round(pos.z))) + light_names.sort() # Ensure consistent order! + for name in light_names: + MAP_HASH.update(name) + LOGGER.debug('Map random seed: {}', MAP_HASH.hexdigest()) + + return b'|'.join(light_names).decode() # TODO Remove + + +def seed(name: bytes, *values: str | Entity | Vec | Angle | float | bytes | bytearray) -> Random: + """Initialise a random number generator with these starting arguments. + + The name is used to make this unique among other calls, then the arguments + are hashed in. + """ + algo = MAP_HASH.copy() + algo.update(name) + for val in values: + if isinstance(val, str): + algo.update(val.encode('utf8')) + elif isinstance(val, (Vec, Angle)): + a, b, c = val + algo.update(THREE_FLOATS.pack(round(a, 6), round(b, 6), round(c, 6))) + elif isinstance(val, float): + algo.update(ONE_FLOAT.pack(val)) + elif isinstance(val, Matrix): + algo.update(NINE_FLOATS.pack( + val[0, 0], val[0, 1], val[0, 2], + val[1, 0], val[1, 1], val[1, 2], + val[2, 0], val[2, 1], val[2, 2], + )) + elif isinstance(val, Entity): + algo.update(val['targetname'].encode('ascii', 'replace')) + x, y, z = round(Vec.from_str(val['origin']), 6) + algo.update(THREE_FLOATS.pack(x, y, z)) + p, y, r = Vec.from_str(val['origin']) + algo.update(THREE_FLOATS.pack(round(p, 6), round(y, 6), round(r, 6))) + else: + try: + algo.update(val) + except TypeError: + raise TypeError(values) + return Random(int.from_bytes(algo.digest(), 'little')) diff --git a/src/precomp/template_brush.py b/src/precomp/template_brush.py index ac527e5bb..5cd6f05cd 100644 --- a/src/precomp/template_brush.py +++ b/src/precomp/template_brush.py @@ -3,9 +3,8 @@ import itertools import os -import random from collections import defaultdict -from typing import Union, Callable, Optional, Tuple, Mapping, Iterable, Iterator +from typing import Union, Optional, Tuple, Mapping, Iterable, Iterator from decimal import Decimal from enum import Enum @@ -21,7 +20,7 @@ from .texturing import Portalable, GenCat, TileSize from .tiling import TileType -from . import tiling, texturing, options +from . import tiling, texturing, options, rand import consts @@ -151,7 +150,7 @@ class TileSetter(VoxelSetter): 'tile/white_wall_tile003f': (GenCat.NORMAL, TileSize.TILE_4x4, W), 'tile/white_wall_tile004j': (GenCat.PANEL, TileSize.TILE_1x1, W), - + # No black portal-placement texture, so use the bullseye instead 'metal/black_floor_metal_bullseye_001': (GenCat.PANEL, TileSize.TILE_1x1, B), 'tile/white_wall_tile_bullseye': (GenCat.PANEL, TileSize.TILE_1x1, W), @@ -228,6 +227,7 @@ class Template: def __init__( self, temp_id: str, + visgroup_names: set[str], world: dict[str, list[Solid]], detail: dict[str, list[Solid]], overlays: dict[str, list[Entity]], @@ -243,14 +243,14 @@ def __init__( self._data = {} # We ensure the '' group is always present. - all_groups = {''} - all_groups.update(world) - all_groups.update(detail) - all_groups.update(overlays) + visgroup_names.add('') + visgroup_names.update(world) + visgroup_names.update(detail) + visgroup_names.update(overlays) for ent in itertools.chain(color_pickers, tile_setters, voxel_setters): - all_groups.update(ent.visgroups) + visgroup_names.update(ent.visgroups) - for group in all_groups: + for group in visgroup_names: self._data[group] = ( world.get(group, []), detail.get(group, []), @@ -444,10 +444,10 @@ def _parse_template(loc: UnparsedTemplate) -> Template: else: raise ValueError(f'Unknown filesystem type for "{loc.pak_path}"!') - with filesys, filesys[loc.path].open_str() as f: + with filesys[loc.path].open_str() as f: props = Property.parse(f, f'{loc.pak_path}:{loc.path}') vmf = srctools.VMF.parse(props, preserve_ids=True) - del props, filesys, f + del props, filesys, f # Discard all this data. # visgroup -> list of brushes/overlays detail_ents: dict[str, list[Solid]] = defaultdict(list) @@ -607,6 +607,7 @@ def yield_world_detail() -> Iterator[tuple[list[Solid], bool, set[str]]]: return Template( loc.id, + set(visgroup_names.values()), world_ents, detail_ents, overlay_ents, @@ -643,8 +644,8 @@ def import_template( force_type: TEMP_TYPES=TEMP_TYPES.default, add_to_map: bool=True, additional_visgroups: Iterable[str]=(), - visgroup_choose: Callable[[Iterable[str]], Iterable[str]]=lambda x: (), bind_tile_pos: Iterable[Vec]=(), + align_bind: bool=False, ) -> ExportedTemplate: """Import the given template at a location. @@ -658,11 +659,10 @@ def import_template( If targetname is set, it will be used to localise overlay names. add_to_map sets whether to add the brushes and func_detail to the map. - visgroup_choose is a callback used to determine if visgroups should be - added - it's passed a list of names, and should return a list of ones to use. If any bound_tile_pos are provided, these are offsets to tiledefs which should have all the overlays in this template bound to them, and vice versa. + If align_bind is set, these will be first aligned to grid. """ import vbsp if isinstance(temp_name, Template): @@ -673,7 +673,6 @@ def import_template( template = get_template(temp_name) chosen_groups.update(additional_visgroups) - chosen_groups.update(visgroup_choose(template.visgroups)) chosen_groups.add('') orig_world, orig_detail, orig_over = template.visgrouped(chosen_groups) @@ -762,6 +761,11 @@ def import_template( for tile_off in bind_tile_pos: tile_off = tile_off.copy() tile_off.localise(origin, orient) + for axis in ('xyz' if align_bind else ''): + # Don't realign things in the normal's axis - + # those are already fine. + if abs(tile_norm[axis]) < 1e-6: + tile_off[axis] = tile_off[axis] // 128 * 128 + 64 try: tile = tiling.TILES[tile_off.as_tuple(), tile_norm.as_tuple()] except KeyError: @@ -1107,7 +1111,6 @@ def retexture_template( folded_mat = face.mat.casefold() norm = face.normal() - random.seed(rand_prefix + norm.join('_')) if orig_id in template.realign_faces: try: @@ -1154,7 +1157,7 @@ def retexture_template( if override_mat is not None: # Replace_tex overrides everything. - mat = random.choice(override_mat) + mat = rand.seed(b'template', norm, face.get_origin()).choice(override_mat) if mat[:1] == '$' and fixup is not None: mat = fixup[mat] if mat.startswith('<') and mat.endswith('>'): @@ -1229,7 +1232,8 @@ def retexture_template( mat = over['material'].casefold() if mat in replace_tex: - mat = random.choice(replace_tex[mat]) + rng = rand.seed(b'temp', template_data.template.id, over_pos, mat) + mat = rng.choice(replace_tex[mat]) if mat[:1] == '$' and fixup is not None: mat = fixup[mat] if mat.startswith('<') or mat.endswith('>'): diff --git a/src/precomp/texturing.py b/src/precomp/texturing.py index de1b85cbf..827c48b5b 100644 --- a/src/precomp/texturing.py +++ b/src/precomp/texturing.py @@ -1,13 +1,13 @@ """Manages the list of textures used for brushes, and how they are applied.""" import itertools -from collections import namedtuple +import abc from enum import Enum from pathlib import Path -import random -import abc +import attr import srctools.logger +from precomp import rand from srctools import Property, Vec, conv_bool from srctools.game import Game from srctools.tokenizer import TokenSyntaxError @@ -39,8 +39,6 @@ SPECIAL: 'Generator' OVERLAYS: 'Generator' -Clump = namedtuple('Clump', 'x1 y1 z1 x2 y2 z2 seed') - class GenCat(Enum): """Categories of textures, each with a generator.""" @@ -198,17 +196,17 @@ def size(self) -> Tuple[int, int]: ] = { # Signage overlays. GenCat.OVERLAYS: { - 'exit': consts.Signage.EXIT, - 'arrow': consts.Signage.ARROW, - 'dot': consts.Signage.SHAPE_DOT, - 'moon': consts.Signage.SHAPE_MOON, - 'triangle': consts.Signage.SHAPE_TRIANGLE, - 'cross': consts.Signage.SHAPE_CROSS, - 'square': consts.Signage.SHAPE_SQUARE, - 'circle': consts.Signage.SHAPE_CIRCLE, - 'sine': consts.Signage.SHAPE_SINE, - 'slash': consts.Signage.SHAPE_SLASH, - 'star': consts.Signage.SHAPE_STAR, + 'exit': consts.Signage.EXIT, + 'arrow': consts.Signage.ARROW, + 'dot': consts.Signage.SHAPE_DOT, + 'moon': consts.Signage.SHAPE_MOON, + 'triangle': consts.Signage.SHAPE_TRIANGLE, + 'cross': consts.Signage.SHAPE_CROSS, + 'square': consts.Signage.SHAPE_SQUARE, + 'circle': consts.Signage.SHAPE_CIRCLE, + 'sine': consts.Signage.SHAPE_SINE, + 'slash': consts.Signage.SHAPE_SLASH, + 'star': consts.Signage.SHAPE_STAR, 'wavy': consts.Signage.SHAPE_WAVY, # If set and enabled, adds frames for >10 sign pairs @@ -217,6 +215,8 @@ def size(self) -> Tuple[int, int]: # Faith Plate bullseye for non-moving surfaces. 'bullseye': consts.Special.BULLSEYE, + # Tideline overlay around the outside of goo pits. + 'tideline': consts.Goo.TIDELINE, }, # Misc textures. GenCat.SPECIAL: { @@ -631,7 +631,7 @@ def load_config(conf: Property): OVERLAYS = GENERATORS[GenCat.OVERLAYS] -def setup(game: Game, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: +def setup(game: Game, vmf: VMF, tiles: List['TileDef']) -> None: """Do various setup steps, needed for generating textures. - Set randomisation seed on all the generators. @@ -643,7 +643,6 @@ def setup(game: Game, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> Non antigel_loc.mkdir(parents=True, exist_ok=True) fsys = game.get_filesystem() - fsys.open_ref() # Basetexture -> material name tex_to_antigel: Dict[str, str] = {} @@ -670,21 +669,8 @@ def setup(game: Game, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> Non tex_to_antigel[texture.casefold()] = mat_name antigel_mats.add(vmt_file.stem) - gen_key_str: Union[GenCat, str] - for gen_key, generator in GENERATORS.items(): - # Compute a unique string for randomisation - if isinstance(gen_key, tuple): - gen_cat, gen_orient, gen_portal = gen_key - gen_key_str = '{}.{}.{}'.format( - gen_cat.value, - gen_portal.value, - gen_orient, - ) - else: - gen_key_str = gen_key - - generator.map_seed = '{}_tex_{}_'.format(global_seed, gen_key_str) - generator.setup(vmf, global_seed, tiles) + for generator in GENERATORS.values(): + generator.setup(vmf, tiles) # No need to convert if it's overlay, or it's bullseye and those # are incompatible. @@ -761,10 +747,6 @@ def __init__( self.options = options self.textures = textures - self._random = random.Random() - # When set, add the position to that and use to seed the RNG. - self.map_seed = '' - # Tells us the category each generator matches to. self.category = category self.orient = orient @@ -791,11 +773,6 @@ def get(self, loc: Vec, tex_name: str, *, antigel: Optional[bool] = None) -> str if self.category is GenCat.NORMAL and self.orient is Orient.WALL and BLOCK_TYPE[grid_loc].is_goo: tex_name = TileSize.GOO_SIDE - if self.map_seed: - self._random.seed(self.map_seed + str(loc)) - else: - LOGGER.warning('Choosing texture ("{}") without seed!', tex_name) - try: texture = self._get(loc, tex_name) except KeyError as exc: @@ -810,7 +787,7 @@ def get(self, loc: Vec, tex_name: str, *, antigel: Optional[bool] = None) -> str return texture - def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: + def setup(self, vmf: VMF, tiles: List['TileDef']) -> None: """Scan tiles in the map and setup the generator.""" def _missing_error(self, tex_name: str): @@ -868,7 +845,19 @@ def _get(self, loc: Vec, tex_name: str): raise ValueError( f'Unknown enum value {tex_name!r} ' f'for generator type {self.category}!') from None - return self._random.choice(self.textures[tex_name]) + return rand.seed(b'tex_rand', loc).choice(self.textures[tex_name]) + + +@attr.define +class Clump: + """Represents a region of map, used to create rectangular sections with the same pattern.""" + x1: float + y1: float + z1: float + x2: float + y2: float + z2: float + seed: bytes @GEN_CLASSES('CLUMP') @@ -881,11 +870,11 @@ class GenClump(Generator): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - # A seed only unique to this generator, in int form. - self.gen_seed = 0 - self._clump_locs = [] # type: List[Clump] + # A seed only unique to this generator. + self.gen_seed = b'' + self._clump_locs: list[Clump] = [] - def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: + def setup(self, vmf: VMF, tiles: List['TileDef']) -> None: """Build the list of clump locations.""" assert self.portal is not None assert self.orient is not None @@ -893,12 +882,11 @@ def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: # Convert the generator key to a generator-specific seed. # That ensures different surfaces don't end up reusing the same # texture indexes. - self.gen_seed = int.from_bytes( - self.category.name.encode() + - self.portal.name.encode() + + self.gen_seed = b''.join([ + self.category.name.encode(), + self.portal.name.encode(), self.orient.name.encode(), - 'big', - ) + ]) LOGGER.info('Generating texture clumps...') @@ -913,7 +901,7 @@ def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: } # A global RNG for picking clump positions. - clump_rand = random.Random(global_seed + '_clumping') + clump_rand = rand.seed(b'clump_pos') pos_min = Vec() pos_max = Vec() @@ -959,7 +947,7 @@ def setup(self, vmf: VMF, global_seed: str, tiles: List['TileDef']) -> None: pos_max.x, pos_max.y, pos_max.z, # We use this to reseed an RNG, giving us the same textures # each time for the same clump. - clump_rand.getrandbits(32), + clump_rand.getrandbits(64).to_bytes(8, 'little'), )) if debug_visgroup is not None: # noinspection PyUnboundLocalVariable @@ -988,24 +976,20 @@ def _get(self, loc: Vec, tex_name: str) -> str: # No clump found - return the gap texture. # But if the texture is GOO_SIDE, do that instead. # If we don't have a gap texture, just use any one. - self._random.seed(self.gen_seed ^ hash(loc.as_tuple())) + rng = rand.seed(b'tex_clump_side', loc) if tex_name == TileSize.GOO_SIDE or TileSize.CLUMP_GAP not in self: - return self._random.choice(self.textures[tex_name]) + return rng.choice(self.textures[tex_name]) else: - return self._random.choice(self.textures[TileSize.CLUMP_GAP]) + return rng.choice(self.textures[TileSize.CLUMP_GAP]) # Mix these three values together to determine the texture. # The clump seed makes each clump different, and adding the texture # name makes sure different surface types don't copy each other's # indexes. - self._random.seed( - self.gen_seed ^ - int.from_bytes(tex_name.encode(), 'big') ^ - clump_seed - ) - return self._random.choice(self.textures[tex_name]) + rng = rand.seed(b'tex_clump_side', self.gen_seed, tex_name, clump_seed) + return rng.choice(self.textures[tex_name]) - def _find_clump(self, loc: Vec) -> Optional[int]: + def _find_clump(self, loc: Vec) -> Optional[bytes]: """Return the clump seed matching a location.""" for clump in self._clump_locs: if ( diff --git a/src/precomp/tiling.py b/src/precomp/tiling.py index 3d97e2f3e..d3985adff 100644 --- a/src/precomp/tiling.py +++ b/src/precomp/tiling.py @@ -15,9 +15,9 @@ from typing import Optional, Union, cast, Tuple from weakref import WeakKeyDictionary import attr -import random -from srctools import Vec, VMF, Entity, Side, Solid, Output, Angle, Matrix +from srctools import Vec, Angle, Matrix +from srctools.vmf import VMF, Entity, Side, Solid, Output, UVAxis import srctools.logger import srctools.vmf from precomp.brushLoc import POS as BLOCK_POS, Block, grid_to_world @@ -29,7 +29,7 @@ options, antlines, template_brush, - conditions, + conditions, rand, ) import utils import consts @@ -47,17 +47,6 @@ dict[Union[str, tuple[int, int, int, bool]], Side] ] = {} -# Maps normals to the index in PrismFace. -PRISM_NORMALS: dict[tuple[float, float, float], int] = { - # 0 = solid - Vec.top: 1, - Vec.bottom: 2, - Vec.north: 3, - Vec.south: 4, - Vec.east: 5, - Vec.west: 6, -} - NORMALS = [Vec(x=1), Vec(x=-1), Vec(y=1), Vec(y=-1), Vec(z=1), Vec(z=-1)] # Specific angles, these ensure the textures align to world once done. # IE upright on walls, up=north for floor and ceilings. @@ -127,40 +116,40 @@ class TileType(Enum): BLACK_4x4 = 3 GOO_SIDE = 4 # Black sides of goo pits. - + NODRAW = 10 # Covered, so it should be set to nodraw # Air - used for embedFace sections. VOID = 11 - # 3 unit recess, with backpanels or props/plastic behind. - # _BROKEN is ignored when allocating patterns - it wasn't there when the - # tiles were installed. + # 3 unit recess, with backpanels or props/plastic behind. + # _BROKEN is ignored when allocating patterns - it wasn't there when the + # tiles were installed. # _PARTIAL is not, it's for WIP chambers. # If the skybox is 3D, _PARTIAL uses tools/skybox. CUTOUT_TILE_BROKEN = 22 CUTOUT_TILE_PARTIAL = 23 - + @property def is_recess(self) -> bool: """Should this recess the surface?""" return self.name.startswith('CUTOUT_TILE') - - @property + + @property def is_nodraw(self) -> bool: """Should this swap to nodraw?""" return self is self.NODRAW - + @property def blocks_pattern(self) -> bool: """Does this affect patterns?""" return self is not self.CUTOUT_TILE_BROKEN - + @property def is_tile(self) -> bool: """Is this a regular tile (white/black).""" return self.value < 10 - + @property def is_white(self) -> bool: """Is this portalable?""" @@ -183,14 +172,40 @@ def color(self) -> texturing.Portalable: @property def inverted(self) -> TileType: """Swap the color of a type.""" + try: + col = self.color + except ValueError: + return self + if col is texturing.Portalable.WHITE: + return self.as_black + else: + return self.as_white + + @property + def as_white(self) -> TileType: + """Force to the white version.""" if self is self.GOO_SIDE: return self.WHITE_4x4 - if self.name.startswith('WHITE'): - return getattr(self, f'BLACK{self.name[5:]}') if self.name.startswith('BLACK'): return getattr(self, f'WHITE{self.name[5:]}') return self + @property + def as_black(self) -> TileType: + """Force to the black version.""" + if self.is_white: + return getattr(self, f'BLACK{self.name[5:]}') + return self + + @property + def as_4x4(self) -> TileType: + """Convert to a 4x4-forcing version.""" + if self is TileType.WHITE: + return TileType.WHITE_4x4 + elif self is TileType.BLACK: + return TileType.BLACK_4x4 + return self + @property def tile_size(self) -> TileSize: """The size of the tile this should force.""" @@ -312,7 +327,7 @@ def __init__( assert 0 <= vmin < vmax <= 4, tile_tex assert (umax - umin) % tile_u == 0, tile_tex assert (vmax - vmin) % tile_v == 0, tile_tex - + def __repr__(self) -> str: return 'Pattern({!r}, {}{}'.format( self.tex, @@ -474,7 +489,8 @@ def export( """Generate the panel brushes.""" # We need to do the checks to handle multiple panels with shared # data. - if all(tile is TileType.VOID for tile in sub_tiles.values()): + if all(subtile is TileType.VOID for subtile in sub_tiles.values()): + LOGGER.debug('Placing panel failed at {} @ {}: {} = {}', tile.pos, tile.normal, self, tile.format_tiles()) # The brush entity isn't used. if self.brush_ent in vmf.entities: self.brush_ent.remove() @@ -626,19 +642,15 @@ def export( # We can just produce any plane that is the correct # orientation and let VBSP sort out the geometry. - # So construct a box, and grab the side pointing "down". - clip_template: Side = vmf.make_prism( - tile.pos + 64 + 128 * tile.normal, - tile.pos - 64 + 128 * tile.normal, - )[PRISM_NORMALS[(-tile.normal).as_tuple()]] - - front_normal: Vec = round(orient.forward(), 6) - + front_normal = orient.forward() for brush in all_brushes: clip_face = None + # Find the face at the edge pointing in the front normal direction. + # That's the face we're replacing. There should be only one in + # each brush, but it could be not there - if it's split for tiles. for face in brush: if ( - face.normal() == front_normal + Vec.dot(face.normal(), front_normal) > 0.99 and math.isclose( face.get_origin().dot(front_normal), panel_offset.dot(front_normal) @@ -646,12 +658,19 @@ def export( ): clip_face = face break + # Move to put 0 0 0 at the hinge point, then rotate and return. brush.localise(-panel_offset) brush.localise(panel_offset, rotation) if clip_face is not None: - clip_face.uaxis = clip_template.uaxis.copy() - clip_face.vaxis = clip_template.vaxis.copy() - clip_face.planes = [p.copy() for p in clip_template.planes] + # Figure out the appropriate face info. We don't really + # care about texture scaling etc. + clip_face.uaxis = UVAxis(*orient.left()) + clip_face.vaxis = UVAxis(*orient.up()) + clip_face.planes = [ + panel_offset + Vec(0, 64, -64) @ orient, + panel_offset + Vec(0, 64, 64) @ orient, + panel_offset + Vec(0, -64, 64) @ orient, + ] clip_face.mat = consts.Tools.NODRAW # Helpfully the angled surfaces are always going to be forced @@ -668,7 +687,7 @@ def export( # We need to make a placement helper. vmf.create_ent( 'info_placement_helper', - angles=angled_normal.to_angle(), + angles=Matrix.from_basis(z=tile.portal_helper_orient, x=tile.normal) @ rotation, origin=top_center, force_placement=int(force_helper), snap_to_helper_angles=int(force_helper), @@ -755,7 +774,7 @@ def position_bullseye(self, tile: TileDef, target: Entity) -> None: class TileDef: """Represents one 128 block side. - + Attributes: pos: Vec for the center of the block. normal: The direction out of the block, towards the face. @@ -804,7 +823,7 @@ class TileDef: def __init__( self, - pos: Vec, + pos: Vec, normal: Vec, base_type: TileType, subtiles: dict[tuple[int, int], TileType]=None, @@ -850,14 +869,14 @@ def __repr__(self) -> str: self.pos, ) - def print_tiles(self) -> None: + def format_tiles(self) -> str: """Debug utility, log the subtile shape.""" out = [] for v in reversed(range(4)): for u in range(4): out.append(TILETYPE_TO_CHAR[self[u, v]]) out.append('\n') - LOGGER.info('Subtiles: \n{}', ''.join(out)) + return ''.join(out) @classmethod def ensure( @@ -893,7 +912,7 @@ def __getitem__(self, item: Tuple[int, int]) -> TileType: u, v = item if u not in (0, 1, 2, 3) or v not in (0, 1, 2, 3): raise IndexError(u, v) - + if self._sub_tiles is None: return self.base_type else: @@ -904,7 +923,7 @@ def __setitem__(self, item: tuple[int, int], value: TileType) -> None: u, v = item if u not in (0, 1, 2, 3) or v not in (0, 1, 2, 3): raise IndexError(u, v) - + if self._sub_tiles is None: self._sub_tiles = { (x, y): value if u == x and v == y else self.base_type @@ -1166,13 +1185,10 @@ def export(self, vmf: VMF) -> None: # We need to make a placement helper. vmf.create_ent( 'info_placement_helper', - angles=Matrix.from_basis( - x=self.normal, - z=self.portal_helper_orient, - ).to_angle(), + angles=Angle.from_basis(x=self.normal, z=self.portal_helper_orient), origin=front_pos, - force_placement=int(force_helper), - snap_to_helper_angles=int(force_helper), + force_placement=force_helper, + snap_to_helper_angles=force_helper, radius=64, ) if self.use_bullseye(): @@ -1211,7 +1227,7 @@ def gen_multitile_pattern( The specified bevels are a set of UV points around the tile. If a tile neighbours one of these points, it will be bevelled. If interior_bevel is true, VOID tiles also are treated as this. - + If face_output is set, it will be filled with (u, v) -> top face. """ brushes = [] @@ -1478,8 +1494,8 @@ def edit_quarter_tile( def make_tile( vmf: VMF, - origin: Vec, - normal: Vec, + origin: Vec, + normal: Vec, top_surf: str, back_surf: str=consts.Tools.NODRAW.value, *, @@ -1493,10 +1509,10 @@ def make_tile( v_align: int=512, antigel: Optional[bool] = None, ) -> tuple[Solid, Side]: - """Generate a tile. - + """Generate a tile. + This uses UV coordinates, which equal xy, xz, or yz depending on normal. - + Parameters: vmf: The map to add the tile to. origin: Location of the center of the tile, on the block surface. @@ -1884,6 +1900,9 @@ def tiledef_from_flip_panel(brush_ent: Entity, panel_ent: Entity) -> None: norm = Vec(z=1) @ Angle.from_str(panel_ent['angles']) grid_pos -= 128*norm + # To match the editor model, flip around the orientation. + panel_ent['spawnflags'] = srctools.conv_int(panel_ent['spawnflags']) ^ 2 + TILES[grid_pos.as_tuple(), norm.as_tuple()] = tile = TileDef( grid_pos, norm, @@ -1962,11 +1981,11 @@ def inset_flip_panel(panel: list[Solid], pos: Vec, normal: Vec) -> None: for brush in panel: for side in brush: norm = side.normal() - if norm.axis() == norm_axis: + if abs(Vec.dot(norm, normal)) > 0.99: continue # Front or back u_off, v_off = (side.get_origin() - pos).other_axes(norm_axis) - if abs(u_off) == 64 or abs(v_off) == 64: + if abs(round(u_off)) == 64 or abs(round(v_off)) == 64: side.translate(2 * norm) # Snap squarebeams to each other. side.vaxis.offset = 0 @@ -2042,7 +2061,7 @@ def generate_brushes(vmf: VMF) -> None: # Add the portal helper in directly. vmf.create_ent( 'info_placement_helper', - angles=Matrix.from_basis(x=tile.portal_helper_orient, z=tile.normal).to_angle(), + angles=Angle.from_basis(x=tile.normal, z=tile.portal_helper_orient), origin=pos, force_placement=int(tile.has_oriented_portal_helper), snap_to_helper_angles=int(tile.has_oriented_portal_helper), @@ -2146,11 +2165,20 @@ def generate_brushes(vmf: VMF) -> None: LOGGER.info('Generating goop...') generate_goo(vmf) + nodraw = consts.Tools.NODRAW for over, over_tiles in OVERLAY_BINDS.items(): + # Keep already set sides. faces = set(over['sides', ''].split()) + # We don't want to include nodraw, since that doesn't accept + # overlays anyway. for tile in over_tiles: - faces.update(str(f.id) for f in tile.brush_faces) + faces.update( + str(f.id) + for f in tile.brush_faces + if f.mat != nodraw + ) + # If it turns out there's no faces for this, discard the overlay. if faces: over['sides'] = ' '.join(sorted(faces)) else: @@ -2216,13 +2244,14 @@ def generate_goo(vmf: VMF) -> None: try: tideline = tideline_over[key] except KeyError: + ent_pos = voxel_center + 32 * Vec(x, y, 1) tideline = tideline_over[key] = Tideline( vmf.create_ent( 'info_overlay', - material='overlays/tideline01b', + material=texturing.OVERLAYS.get(ent_pos, 'tideline'), angles='0 0 0', - origin=voxel_center + (0, 0, 32), - basisOrigin=voxel_center + (0, 0, 32), + origin=ent_pos, + basisOrigin=ent_pos, basisNormal=f'{x} {y} 0', basisU=side, basisV='0 0 1', @@ -2239,15 +2268,14 @@ def generate_goo(vmf: VMF) -> None: tideline.max = max(tideline.max, off) OVERLAY_BINDS[tideline.over].append(tile) - tideline_rand = random.Random() for tideline in tideline_over.values(): tide_min = tideline.min - tideline.mid - 64 tide_max = tideline.max - tideline.mid + 64 - tideline_rand.seed(f'{tideline.over["origin"]}:{tide_min}:{tide_max}') + rng = rand.seed(b'tideline', tide_min, tide_max) width = (tide_max - tide_min) / 128.0 # Randomly flip around - if tideline_rand.choice((False, True)): + if rng.choice((False, True)): tideline.over['startu'] = 0 tideline.over['endu'] = width else: @@ -2255,10 +2283,10 @@ def generate_goo(vmf: VMF) -> None: tideline.over['startu'] = width # Vary the ends up/down from 32, to distort a little. - tideline.over['uv0'] = f'{tide_min} {random.randint(-36, -28)} 0' - tideline.over['uv1'] = f'{tide_min} {random.randint(28, 32)} 0' - tideline.over['uv2'] = f'{tide_max} {random.randint(28, 32)} 0' - tideline.over['uv3'] = f'{tide_max} {random.randint(-36, -28)} 0' + tideline.over['uv0'] = f'{tide_min} {rng.randint(-36, -28)} 0' + tideline.over['uv1'] = f'{tide_min} {rng.randint(28, 32)} 0' + tideline.over['uv2'] = f'{tide_max} {rng.randint(28, 32)} 0' + tideline.over['uv3'] = f'{tide_max} {rng.randint(-36, -28)} 0' # No goo. if not goo_pos or pos is None: diff --git a/src/precomp/voice_line.py b/src/precomp/voice_line.py index 9497685c8..0def4b3d2 100644 --- a/src/precomp/voice_line.py +++ b/src/precomp/voice_line.py @@ -1,12 +1,11 @@ """Adds voicelines dynamically into the map.""" import itertools -import random from decimal import Decimal from typing import List, Set, NamedTuple, Iterator import srctools.logger import vbsp -from precomp import options as vbsp_options, packing, conditions +from precomp import options as vbsp_options, packing, conditions, rand from BEE2_config import ConfigFile from srctools import Property, Vec, VMF, Output, Entity @@ -48,7 +47,7 @@ class PossibleQuote(NamedTuple): ) -def has_responses(): +def has_responses() -> bool: """Check if we have any valid 'response' data for Coop.""" return vbsp.GAME_MODE == 'COOP' and 'CoopResponses' in QUOTE_DATA @@ -56,7 +55,7 @@ def has_responses(): def encode_coop_responses(vmf: VMF, pos: Vec, allow_dings: bool, voice_attrs: dict) -> None: """Write the coop responses information into the map.""" config = ConfigFile('bee2/resp_voice.cfg', in_conf_folder=False) - response_block = QUOTE_DATA.find_key('CoopResponses', []) + response_block = QUOTE_DATA.find_key('CoopResponses', or_blank=True) # Pass in whether to include dings or not. vmf.create_ent( @@ -443,7 +442,6 @@ def add_voice( voice_attrs: dict, style_vars: dict, vmf: VMF, - map_seed: str, use_priority=True, ) -> None: """Add a voice line to the map.""" @@ -586,26 +584,23 @@ def add_voice( if use_priority: chosen = possible_quotes[0].lines else: - # Chose one of the quote blocks.. - random.seed('{}-VOICE_QUOTE_{}'.format( - map_seed, - len(possible_quotes), - )) - chosen = random.choice(possible_quotes).lines - - # Join the IDs for - # the voice lines to the map seed, - # so each quote block will chose different lines. - random.seed(map_seed + '-VOICE_LINE_' + '|'.join( + # Chose one of the quote blocks. + chosen = rand.seed(b'VOICE_QUOTE_BLOCK', *[ + prop['id', 'ID'] for quoteblock in possible_quotes + for prop in quoteblock.lines + ]).choice(possible_quotes).lines + + # Use the IDs for the voice lines, so each quote block will chose different lines. + rng = rand.seed(b'VOICE_QUOTE', *[ prop['id', 'ID'] for prop in chosen - )) + ]) # Add one of the associated quotes add_quote( vmf, - random.choice(chosen), + rng.choice(chosen), quote_targetname, choreo_loc, style_vars, @@ -643,7 +638,7 @@ def add_voice( LOGGER.info('{} Mid quotes', len(mid_quotes)) for mid_lines in mid_quotes: - line = random.choice(mid_lines) + line = rand.seed(b'mid_quote', *[name for item, ding, name in mid_lines]).choice(mid_lines) mid_item, use_ding, mid_name = line add_quote(vmf, mid_item, mid_name, quote_loc, style_vars, use_ding) diff --git a/src/pygtrie.pyi b/src/pygtrie.pyi index b0fb9f436..c84f1ad43 100644 --- a/src/pygtrie.pyi +++ b/src/pygtrie.pyi @@ -1,13 +1,15 @@ """Implements stubs for pygtrie.""" import collections as _abc from typing import ( - Any, Set, TypeVar, Iterator, Literal, NoReturn, + Any, Set, TypeVar, Iterator, Literal, NoReturn, Union, Type, Generic, MutableMapping, overload, Mapping, Iterable, Callable, ) - +__version__: str KeyT = TypeVar('KeyT') ValueT = TypeVar('ValueT') +Key2T = TypeVar('Key2T') +Value2T = TypeVar('Value2T') T = TypeVar('T') TrieT = TypeVar('TrieT', bound=Trie) _EMPTY: _NoChildren @@ -33,9 +35,9 @@ class _OneChild(Generic[KeyT, ValueT]): step: Any = ... node: Any = ... def __init__(self, step: Any, node: Any) -> None: ... - def __bool__(self) -> Literal[True]: ... - def __nonzero__(self) -> Literal[True]: ... - def __len__(self) -> Literal[1]: ... + def __bool__(self) -> bool: ... + def __nonzero__(self) -> bool: ... + def __len__(self) -> int: ... def sorted_items(self) -> list[tuple[KeyT, ValueT]]: ... def iteritems(self) -> Iterator[tuple[KeyT, ValueT]]: ... def get(self, step: Any): ... @@ -46,12 +48,12 @@ class _OneChild(Generic[KeyT, ValueT]): class _Children(dict): def __init__(self, *items: Any) -> None: ... - def sorted_items(self): ... - def iteritems(self): ... + def sorted_items(self) -> list[Any]: ... + def iteritems(self) -> Iterator[Any]: ... def add(self, _parent: Any, step: Any): ... def require(self, _parent: Any, step: Any): ... def delete(self, parent: Any, step: Any) -> None: ... - def copy(self, make_copy: Any, queue: Any): ... + def copy(self, make_copy: Any, queue: Any): ... # type: ignore class _Node: children: Any = ... @@ -65,8 +67,8 @@ class _Node: __hash__: Any = ... def shallow_copy(self, make_copy: Any): ... def copy(self, make_copy: Any): ... - -AnyNode = _Node | _NoChildren | _OneChild + +AnyNode = Union[_Node, _NoChildren, _OneChild] class Trie(MutableMapping[KeyT, ValueT], Generic[KeyT, ValueT]): def __init__(self, *args: Any, **kwargs: Any) -> None: ... @@ -74,40 +76,34 @@ class Trie(MutableMapping[KeyT, ValueT], Generic[KeyT, ValueT]): def clear(self) -> None: ... @overload - def update(self: Trie[KeyT, str], m: Mapping[KeyT, ValueT], /, **kwargs: ValueT) -> None: ... - @overload - def update(self: Trie[KeyT, str], m: Iterable[tuple[KeyT, ValueT]], /, **kwargs: ValueT) -> None: ... - @overload - def update(self: Trie[KeyT, str], /, **kwargs: ValueT) -> None: ... - @overload - def update(self, m: Mapping[KeyT, ValueT], /) -> None: ... + def update(self, m: Mapping[KeyT, ValueT], /, **kwargs: ValueT) -> None: ... @overload - def update(self, m: Iterable[tuple[KeyT, ValueT]], /) -> None: ... + def update(self, m: Iterable[tuple[KeyT, ValueT]], /, **kwargs: ValueT) -> None: ... @overload - def update(self, /) -> None: ... - + def update(self, /, **kwargs: ValueT) -> None: ... + def copy(self: TrieT, __make_copy: Callable[[T], T] = ...) -> TrieT: ... def __copy__(self: TrieT) -> TrieT: ... def __deepcopy__(self: TrieT, memo: dict) -> TrieT: ... @overload @classmethod - def fromkeys(cls: type[TrieT], keys: Iterable[KeyT]) -> TrieT[KeyT, None]: ... + def fromkeys(cls: Type[TrieT], keys: Iterable[KeyT]) -> TrieT: ... @overload @classmethod - def fromkeys(cls: type[TrieT], keys: Iterable[KeyT], value: ValueT) -> TrieT[KeyT, ValueT]: ... - - def __iter__(self) -> list[KeyT]: ... + def fromkeys(cls: Type[TrieT], keys: Iterable[KeyT], value: ValueT) -> TrieT: ... + + def __iter__(self) -> Iterator[KeyT]: ... def iteritems(self, prefix: KeyT = ..., shallow: bool = ...) -> Iterator[tuple[KeyT, ValueT]]: ... def iterkeys(self, prefix: KeyT = ..., shallow: bool = ...) -> Iterator[KeyT]: ... def itervalues(self, prefix: KeyT = ..., shallow: bool = ...) -> Iterator[ValueT]: ... - def items(self, prefix: KeyT = ..., shallow: bool = ...) -> list[tuple[KeyT, ValueT]]: ... - def keys(self, prefix: KeyT = ..., shallow: bool = ...) -> list[KeyT]: ... - def values(self, prefix: KeyT = ..., shallow: bool = ...) -> list[ValueT]: ... + def items(self, prefix: KeyT = ..., shallow: bool = ...) -> list[tuple[KeyT, ValueT]]: ... # type: ignore # Py2 + def keys(self, prefix: KeyT = ..., shallow: bool = ...) -> list[KeyT]: ... # type: ignore # Py2 + def values(self, prefix: KeyT = ..., shallow: bool = ...) -> list[ValueT]: ... # type: ignore # Py2 def __len__(self) -> int: ... def __bool__(self) -> bool: ... def __nonzero__(self) -> bool: ... - __hash__: None + __hash__ = None # type: ignore HAS_VALUE: int HAS_SUBTRIE: int def has_node(self, key: KeyT): ... @@ -123,49 +119,43 @@ class Trie(MutableMapping[KeyT, ValueT], Generic[KeyT, ValueT]): def pop(self, key: KeyT, default: ValueT | T = ...) -> ValueT | T: ... def popitem(self) -> tuple[KeyT, ValueT]: ... def __delitem__(self, key_or_slice: KeyT | slice) -> None: ... - - class _NoneStep: - def __bool__(self) -> Literal[False]: ... - def __nonzero__(self) -> Literal[False]: ... - def get(self, default: T = None) -> T: ... - is_set: Literal[False] - has_subtrie: Literal[False] - def key(self) -> None: ... - def value(self) -> None: ... - def __getitem__(self, index: int) -> None: ... - - class _Step(_NoneStep, Generic[KeyT, ValueT]): - def __init__(self, trie: Trie, path: KeyT, pos: int, node: AnyNode) -> None: ... + + class _Step(Generic[Key2T, Value2T]): + def __init__(self, trie: Trie, path: Key2T, pos: int, node: AnyNode) -> None: ... def __bool__(self) -> bool: ... def __nonzero__(self) -> bool: ... + def __getitem__(self, index: int) -> Value2T: ... + @property def is_set(self) -> bool: ... @property def has_subtrie(self) -> bool: ... - def get(self, default: T = None) -> ValueT | T: ... - def set(self, value: ValueT) -> None: ... - def setdefault(self, value: ValueT) -> ValueT: ... + + def get(self, default: T = None) -> Union[Value2T, T]: ... + def set(self, value: Value2T) -> None: ... + def setdefault(self, value: Value2T) -> Value2T: ... @property - def key(self) -> KeyT: ... + def key(self) -> Key2T: ... @property - def value(self) -> ValueT: ... + def value(self) -> Value2T: ... @value.setter - def value(self, value: ValueT) -> None: ... - + def value(self, value: Value2T) -> None: ... + class _NoneStep(_Step[None, None]): ... + def walk_towards(self, key: KeyT) -> Iterator[_Step[KeyT, ValueT]]: ... def prefixes(self, key: KeyT) -> Iterator[_Step[KeyT, ValueT]]: ... def shortest_prefix(self, key: KeyT): ... def longest_prefix(self, key: KeyT): ... - def __eq__(self, other: Trie[KeyT, ValueT]) -> bool: ... - def __ne__(self, other: Trie[KeyT, ValueT]) -> bool: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... def traverse(self, node_factory: Callable[..., T], prefix: KeyT = ...) -> T: ... class CharTrie(Trie[str, ValueT], Generic[ValueT]): ... class StringTrie(Trie[str, ValueT], Generic[ValueT]): def __init__(self, *args: Any, separator: str='/', **kwargs: Any) -> None: ... - - @overload + + @overload # type: ignore # Incompatible override @classmethod def fromkeys(cls, keys: Iterable[str], *, separator: str = ...) -> StringTrie[None]: ... @overload @@ -173,13 +163,13 @@ class StringTrie(Trie[str, ValueT], Generic[ValueT]): def fromkeys(cls, keys: Iterable[str], value: ValueT, separator: str = ...) -> StringTrie[ValueT]: ... class PrefixSet(Set[KeyT], Generic[KeyT]): - # TODO: Used as factory(**kwargs), but can't express that. + # TODO: Used as factory(**kwargs), but can't express that. def __init__(self, iterable: Iterable[KeyT] = ..., factory: Callable[..., Trie] = ..., **kwargs: Any) -> None: ... def copy(self) -> PrefixSet[KeyT]: ... def __copy__(self) -> PrefixSet[KeyT]: ... def __deepcopy__(self, memo: dict) -> PrefixSet[KeyT]: ... def clear(self) -> None: ... - def __contains__(self, key: KeyT) -> bool: ... + def __contains__(self, key: object) -> bool: ... def __iter__(self) -> Iterator[KeyT]: ... def iter(self, prefix: KeyT = ...) -> Iterator[KeyT]: ... def __len__(self) -> int: ... diff --git a/src/test/test_editoritems.py b/src/test/test_editoritems.py new file mode 100644 index 000000000..54a2f0e5d --- /dev/null +++ b/src/test/test_editoritems.py @@ -0,0 +1,195 @@ +"""Test Editoritems syntax.""" +from srctools import Vec +from editoritems import ( + Item, ItemClass, OccupiedVoxel, + CollType, DesiredFacing, FSPath, + Sound, Handle, ConnSide, InstCount, +) +import pytest + + +# Definition for a simple item, with 'exporting' open. +START_EXPORTING = ''' +Item +{ + "Type" "SOME_ITEM" + "Editor" + { + "SubType" + { + "Name" "instance item" + } + } + "Exporting" + { + "TargetName" "goo" +''' + + +def test_parse_goo() -> None: + """Verify all the values in a goo item definition are correct.""" + [[item], _] = Item.parse(''' + Item + { + "Type" "ITEM_GOO" + "ItemClass" "ItemGoo" + "Editor" + { + "SubType" + { + "Name" "PORTAL2_PuzzleEditor_Item_goo" + "Model" + { + "ModelName" "goo_man.3ds" + } + "Model" + { + "ModelName" "goo_man_water.mdl" + } + "Palette" + { + "Tooltip" "PORTAL2_PuzzleEditor_Palette_goo" + "Image" "palette/goo.png" + "Position" "2 6 0" + } + "Sounds" + { + "SOUND_CREATED" "P2Editor.PlaceOther" + "SOUND_EDITING_ACTIVATE" "P2Editor.ExpandOther" + "SOUND_EDITING_DEACTIVATE" "P2Editor.CollapseOther" + "SOUND_DELETED" "P2Editor.RemoveOther" + } + } + "MovementHandle" "HANDLE_NONE" + "DesiredFacing" "DESIRES_UP" + } + "Exporting" + { + "TargetName" "goo" + "Offset" "64 64 64" + "OccupiedVoxels" + { + "Voxel" + { + "Pos" "0 0 0" + "CollideType" "COLLIDE_NOTHING" + "CollideAgainst" "COLLIDE_NOTHING" + + "Surface" + { + "Normal" "0 0 1" + } + } + } + } + } + ''') + assert item.id == "ITEM_GOO" + assert item.cls is ItemClass.GOO + assert len(item.subtypes) == 1 + [subtype] = item.subtypes + assert subtype.name == "PORTAL2_PuzzleEditor_Item_goo" + assert subtype.models == [ + # Regardless of original extension, both become .mdl since that's more + # correct. + FSPath("goo_man.mdl"), + FSPath("goo_man_water.mdl"), + ] + assert subtype.pal_name == "PORTAL2_PuzzleEditor_Palette_goo" + assert subtype.pal_icon == FSPath("palette/goo.vtf") + assert subtype.pal_pos == (2, 6) + + assert subtype.sounds == { + Sound.CREATE: "P2Editor.PlaceOther", + Sound.PROPS_OPEN: "P2Editor.ExpandOther", + Sound.PROPS_CLOSE: "P2Editor.CollapseOther", + Sound.DELETE: "P2Editor.RemoveOther", + # Default values. + Sound.SELECT: '', + Sound.DESELECT: '', + } + assert item.handle is Handle.NONE + assert item.facing is DesiredFacing.UP + assert item.targetname == "goo" + assert item.offset == Vec(64, 64, 64) + + assert len(item.occupy_voxels) == 1 + occupation: OccupiedVoxel + [occupation] = item.occupy_voxels + assert occupation.type is CollType.NOTHING + assert occupation.against is CollType.NOTHING + assert occupation.pos == Vec(0, 0, 0) + assert occupation.normal == Vec(0, 0, 1) + assert occupation.subpos is None + + # Check these are default. + assert item.occupies_voxel is False + assert item.copiable is True + assert item.deletable is True + assert item.anchor_goo is False + assert item.anchor_barriers is False + assert item.pseudo_handle is False + assert item.force_input is False + assert item.force_output is False + assert item.antline_points == { + ConnSide.UP: [], + ConnSide.DOWN: [], + ConnSide.LEFT: [], + ConnSide.RIGHT: [], + } + assert item.animations == {} + assert item.properties == {} + assert item.invalid_surf == set() + assert item.cust_instances == {} + assert item.instances == [] + assert item.embed_voxels == set() + assert item.embed_faces == [] + assert item.overlays == [] + assert item.conn_inputs == {} + assert item.conn_outputs == {} + assert item.conn_config is None + + +def test_instances() -> None: + """Test instance definitions.""" + [[item], renderables] = Item.parse(START_EXPORTING + ''' + "Instances" + { + "0" // Full PeTI style definition + { + "Name" "instances/p2editor/something.vmf" + "EntityCount" "30" + "BrushCount" "28" + "BrushSideCount" "4892" + } + "another_name" "instances/more_custom.vmf" + "bee2_second_CUst" "instances/even_more.vmf" + "1" + { + "Name" "instances/somewhere_else/item.vmf" + } + "5" "instances/skipping_indexes.vmf" + "2" "instances/direct_path.vmf" + "cust_name" + { + "Name" "instances/a_custom_item.vmf" + "EntityCount" "327" + "BrushCount" "1" + "BrushSideCount" "32" + } + } + }} // End exporting + item + ''') + assert len(item.instances) == 6 + assert item.instances[0] == InstCount(FSPath("instances/p2editor/something.vmf"), 30, 28, 4892) + assert item.instances[1] == InstCount(FSPath("instances/somewhere_else/item.vmf"), 0, 0, 0) + assert item.instances[2] == InstCount(FSPath("instances/direct_path.vmf"), 0, 0, 0) + assert item.instances[3] == InstCount(FSPath(), 0, 0, 0) + assert item.instances[4] == InstCount(FSPath(), 0, 0, 0) + assert item.instances[5] == InstCount(FSPath("instances/skipping_indexes.vmf"), 0, 0, 0) + # Counts discarded for custom items, and casefolded. + assert item.cust_instances == { + "another_name": FSPath("instances/more_custom.vmf"), + "second_cust": FSPath("instances/even_more.vmf"), + "cust_name": FSPath("instances/a_custom_item.vmf"), + } diff --git a/src/utils.py b/src/utils.py index 8b4170b43..fde0c8201 100644 --- a/src/utils.py +++ b/src/utils.py @@ -13,23 +13,13 @@ import shutil import copyreg import sys +import zipfile from pathlib import Path from enum import Enum from types import TracebackType +from srctools import Angle -try: - # This module is generated when cx_freeze compiles the app. - from BUILD_CONSTANTS import BEE_VERSION # type: ignore -except ImportError: - # We're running from source! - BEE_VERSION = "(dev)" - FROZEN = False - DEV_MODE = True -else: - FROZEN = True - # If blank, in dev mode. - DEV_MODE = not BEE_VERSION WIN = sys.platform.startswith('win') MAC = sys.platform.startswith('darwin') @@ -82,19 +72,49 @@ # Defer the error until used, so it goes in logs and whatnot. # Utils is early, so it'll get lost in stderr. _SETTINGS_ROOT = None # type: ignore - + # We always go in a BEE2 subfolder if _SETTINGS_ROOT: _SETTINGS_ROOT /= 'BEEMOD2' -if FROZEN: - # This special attribute is set by PyInstaller to our folder. - _INSTALL_ROOT = Path(getattr(sys, '_MEIPASS')) -else: + +def get_git_version(inst_path: Path | str) -> str: + """Load the version from Git history.""" + import versioningit + return versioningit.get_version( + project_dir=inst_path, + config={ + 'vcs': {'method': 'git'}, + 'default-version': '(dev)', + 'format': { + # Ignore dirtyness, we generate the translation files every time. + 'distance': '{version}.dev+{rev}', + 'dirty': '{version}', + 'distance-dirty': '{version}.dev+{rev}', + }, + }, + ) + +try: + # This module is generated when the app is compiled. + from _compiled_version import BEE_VERSION # type: ignore +except ImportError: # We're running from src/, so data is in the folder above that. # Go up once from the file to its containing folder, then to the parent. _INSTALL_ROOT = Path(sys.argv[0]).resolve().parent.parent + BEE_VERSION = get_git_version(_INSTALL_ROOT) + FROZEN = False + DEV_MODE = True +else: + FROZEN = True + # This special attribute is set by PyInstaller to our folder. + _INSTALL_ROOT = Path(getattr(sys, '_MEIPASS')) + # Check if this was produced by above + DEV_MODE = '#' in BEE_VERSION + +BITNESS = '64' if sys.maxsize > (2 << 48) else '32' +BEE_VERSION += f' {BITNESS}-bit' def install_path(path: str) -> Path: """Return the path to a file inside our installation folder.""" @@ -103,7 +123,7 @@ def install_path(path: str) -> Path: def conf_location(path: str) -> Path: """Return the full path to save settings to. - + The passed-in path is relative to the settings folder. Any additional subfolders will be created if necessary. If it ends with a '/' or '\', it is treated as a folder. @@ -112,7 +132,7 @@ def conf_location(path: str) -> Path: raise FileNotFoundError("Don't know a good config directory!") loc = _SETTINGS_ROOT / path - + if path.endswith(('\\', '/')) and not loc.suffix: folder = loc else: @@ -153,10 +173,10 @@ class CONN_TYPES(Enum): triple = 4 # Points N-S-W all = 5 # Points N-S-E-W -N = "0 90 0" -S = "0 270 0" -E = "0 0 0" -W = "0 180 0" +N = Angle(yaw=90) +S = Angle(yaw=270) +E = Angle(yaw=0) +W = Angle(yaw=180) # Lookup values for joining things together. CONN_LOOKUP = { #N S E W : (Type, Rotation) @@ -188,10 +208,9 @@ class CONN_TYPES(Enum): RetT = TypeVar('RetT') FuncT = TypeVar('FuncT', bound=Callable) EnumT = TypeVar('EnumT', bound=Enum) -EnumTypeT = TypeVar('EnumTypeT', bound=Type[Enum]) -def freeze_enum_props(cls: EnumTypeT) -> EnumTypeT: +def freeze_enum_props(cls: Type[EnumT]) -> Type[EnumT]: """Make a enum with property getters more efficent. Call the getter on each member, and then replace it with a dict lookup. @@ -204,7 +223,13 @@ def freeze_enum_props(cls: EnumTypeT) -> EnumTypeT: data_exc = {} exc: Exception + enum: EnumT for enum in cls: + # Put the class into the globals, so it can refer to itself. + try: + value.fget.__globals__[cls.__name__] = cls # type: ignore + except AttributeError: + pass try: res = value.fget(enum) except Exception as exc: @@ -218,7 +243,7 @@ def freeze_enum_props(cls: EnumTypeT) -> EnumTypeT: data[enum] = res if data_exc: func = _exc_freeze(data, data_exc) - else: # If we don't raise, we can use the C-func + else: # If we don't raise, we can use the C-func func = data.get setattr(cls, name, property(fget=func, doc=value.__doc__)) return cls @@ -239,7 +264,21 @@ def getter(value: EnumT) -> RetT: return getter -class FuncLookup(Generic[FuncT], Mapping[str, Callable[..., FuncT]]): +# Patch zipfile to fix an issue with it not being threadsafe. +# See https://bugs.python.org/issue42369 +if hasattr(zipfile, '_SharedFile'): + # noinspection PyProtectedMember + class _SharedZipFile(zipfile._SharedFile): # type: ignore + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + # tell() reads the actual file position, but that may have been + # changed by another thread - instead keep our own private value. + self.tell = lambda: self._pos + + zipfile._SharedFile = _SharedZipFile # type: ignore + + +class FuncLookup(Generic[FuncT], Mapping[str, FuncT]): """A dict for holding callback functions. Functions are added by using this as a decorator. Positional arguments @@ -267,7 +306,10 @@ def __call__(self, *names: str, **kwargs) -> Callable[[FuncT], FuncT]: bad_keywords = kwargs.keys() - self.allowed_attrs if bad_keywords: - raise TypeError('Invalid keywords: ' + ', '.join(bad_keywords)) + raise TypeError( + f'Invalid keywords: {", ".join(bad_keywords)}. ' + f'Allowed: {", ".join(self.allowed_attrs)}' + ) def callback(func: FuncT) -> FuncT: """Decorator to do the work of adding the function.""" @@ -435,6 +477,38 @@ def iter_grid( yield x, y +def check_cython(report: Callable[[str], None] = print) -> None: + """Check if srctools has its Cython accellerators installed correctly.""" + from srctools import math, tokenizer + if math.Cy_Vec is math.Py_Vec: + report('Cythonised vector lib is not installed, expect slow math.') + if tokenizer.Cy_Tokenizer is tokenizer.Py_Tokenizer: + report('Cythonised tokeniser is not installed, expect slow parsing.') + + vtf = sys.modules.get('srctools.vtf', None) # Don't import if not already. + # noinspection PyProtectedMember, PyUnresolvedReferences + if vtf is not None and vtf._cy_format_funcs is vtf._py_format_funcs: + report('Cythonised VTF functions is not installed, no DXT export!') + + +if WIN: + def check_shift() -> bool: + """Check if Shift is currently held.""" + import ctypes + # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate + GetAsyncKeyState = ctypes.windll.User32.GetAsyncKeyState + GetAsyncKeyState.returntype = ctypes.c_short + GetAsyncKeyState.argtypes = [ctypes.c_int] + VK_SHIFT = 0x10 + # Most significant bit set if currently held. + return GetAsyncKeyState(VK_SHIFT) & 0b1000_0000_0000_0000 != 0 +else: + def check_shift() -> bool: + """Check if Shift is currently held.""" + return False + print('Need implementation of utils.check_shift()!') + + DISABLE_ADJUST = False @@ -645,101 +719,3 @@ def merge_tree( errors.append((src, dst, str(why))) if errors: raise shutil.Error(errors) - - -def setup_localisations(logger: logging.Logger) -> None: - """Setup gettext localisations.""" - from srctools.property_parser import PROP_FLAGS_DEFAULT - import gettext - import locale - - # Get the 'en_US' style language code - lang_code = locale.getdefaultlocale()[0] - - # Allow overriding through command line. - if len(sys.argv) > 1: - for arg in sys.argv[1:]: - if arg.casefold().startswith('lang='): - lang_code = arg[5:] - break - - # Expands single code to parent categories. - expanded_langs = gettext._expand_lang(lang_code) - - logger.info('Language: {!r}', lang_code) - logger.debug('Language codes: {!r}', expanded_langs) - - # Add these to Property's default flags, so config files can also - # be localised. - for lang in expanded_langs: - PROP_FLAGS_DEFAULT['lang_' + lang] = True - - lang_folder = install_path('i18n') - - trans: gettext.NullTranslations - - for lang in expanded_langs: - try: - file = open(lang_folder / (lang + '.mo').format(lang), 'rb') - except FileNotFoundError: - continue - with file: - trans = gettext.GNUTranslations(file) - break - else: - # To help identify missing translations, replace everything with - # something noticeable. - if lang_code == 'dummy': - class DummyTranslations(gettext.NullTranslations): - """Dummy form for identifying missing translation entries.""" - def gettext(self, message: str) -> str: - """Generate placeholder of the right size.""" - # We don't want to leave {arr} intact. - return ''.join([ - '#' if s.isalnum() or s in '{}' else s - for s in message - ]) - - def ngettext(self, msgid1: str, msgid2: str, n: int) -> str: - """Generate placeholder of the right size for plurals.""" - return self.gettext(msgid1 if n == 1 else msgid2) - - lgettext = gettext - lngettext = ngettext - - trans = DummyTranslations() - # No translations, fallback to English. - # That's fine if the user's language is actually English. - else: - if 'en' not in expanded_langs: - logger.warning( - "Can't find translation for codes: {!r}!", - expanded_langs, - ) - trans = gettext.NullTranslations() - - # Add these functions to builtins, plus _=gettext - trans.install(['gettext', 'ngettext']) - - # Some lang-specific overrides.. - - if trans.gettext('__LANG_USE_SANS_SERIF__') == 'YES': - # For Japanese/Chinese, we want a 'sans-serif' / gothic font - # style. - try: - from tkinter import font - except ImportError: - return - font_names = [ - 'TkDefaultFont', - 'TkHeadingFont', - 'TkTooltipFont', - 'TkMenuFont', - 'TkTextFont', - 'TkCaptionFont', - 'TkSmallCaptionFont', - 'TkIconFont', - # Note - not fixed-width... - ] - for font_name in font_names: - font.nametofont(font_name).configure(family='sans-serif') diff --git a/src/vbsp.py b/src/vbsp.py index 95af8a95c..f0969393f 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -7,7 +7,6 @@ import os import sys import shutil -import random import logging import pickle from io import StringIO @@ -40,11 +39,12 @@ fizzler, voice_line, music, + rand, ) import consts import editoritems -from typing import Any, Dict, Tuple, List, Set, Iterable, Optional +from typing import Any, Dict, Tuple, Set, Iterable, Optional COND_MOD_NAME = 'VBSP' @@ -68,11 +68,6 @@ # Are we in preview mode? (Spawn in entry door instead of elevator) IS_PREVIEW = 'ERR' # type: bool -# A seed value for randomness, based on the general map layout. -# This stops patterns from repeating in different maps, but keeps it the same -# when recompiling. -MAP_RAND_SEED = '' - # These are overlays which have been modified by # conditions, and shouldn't be restyled or modified later. IGNORED_OVERLAYS = set() @@ -90,7 +85,7 @@ def load_settings() -> Tuple[antlines.AntType, antlines.AntType, Dict[str, edito conf = Property(None, []) # All the find_all commands will fail, and we will use the defaults. - texturing.load_config(conf.find_key('textures', [])) + texturing.load_config(conf.find_block('textures', or_blank=True)) # Antline texturing settings. # We optionally allow different ones for floors. @@ -110,11 +105,11 @@ def load_settings() -> Tuple[antlines.AntType, antlines.AntType, Dict[str, edito # Load in our main configs.. options.load(conf.find_all('Options')) + utils.DEV_MODE = options.get(bool, 'dev_mode') # The voice line property block for quote_block in conf.find_all("quotes"): - quote_block.name = None - voice_line.QUOTE_DATA.append(quote_block) + voice_line.QUOTE_DATA.extend(quote_block) # Configuration properties for styles. for stylevar_block in conf.find_all('stylevars'): @@ -126,11 +121,20 @@ def load_settings() -> Tuple[antlines.AntType, antlines.AntType, Dict[str, edito template_brush.load_templates('bee2/templates.lst') # Load a copy of the item configuration. - id_to_item: Dict[str, editoritems.Item] = {} + id_to_item: dict[str, editoritems.Item] = {} item: editoritems.Item with open('bee2/editor.bin', 'rb') as inst: - for item in pickle.load(inst): - id_to_item[item.id.casefold()] = item + try: + for item in pickle.load(inst): + id_to_item[item.id.casefold()] = item + except Exception: # Anything from __setstate__ etc. + LOGGER.exception( + 'Failed to parse editoritems dump. Recompile the compiler ' + 'and/or export the palette.' + if utils.DEV_MODE else + 'Failed to parse editoritems dump. Re-export BEE2.' + ) + sys.exit(1) # Send that data to the relevant modules. instanceLocs.load_conf(id_to_item.values()) @@ -159,7 +163,7 @@ def load_settings() -> Tuple[antlines.AntType, antlines.AntType, Dict[str, edito load_signs(conf) # Get configuration for the elevator, defaulting to ''. - elev = conf.find_key('elevator', []) + elev = conf.find_block('elevator', or_blank=True) settings['elevator'] = { key: elev[key, ''] for key in @@ -169,14 +173,13 @@ def load_settings() -> Tuple[antlines.AntType, antlines.AntType, Dict[str, edito ) } - settings['music_conf'] = conf.find_key('MusicScript', []) + settings['music_conf'] = conf.find_block('MusicScript', or_blank=True) # Bottomless pit configuration - pit = conf.find_key("bottomless_pit", []) - bottomlessPit.load_settings(pit) + bottomlessPit.load_settings(conf.find_block("bottomless_pit", or_blank=True)) # Fog settings - from the skybox (env_fog_controller, env_tonemap_controller) - fog_config = conf.find_key("fog", []) + fog_config = conf.find_block("fog", or_blank=True) # Update inplace so imports get the settings settings['fog'].update({ # These defaults are from Clean Style. @@ -223,8 +226,7 @@ def add_voice(vmf: VMF): voice_attrs=settings['has_attr'], style_vars=settings['style_vars'], vmf=vmf, - map_seed=MAP_RAND_SEED, - use_priority=BEE2_config.get_bool('General', 'use_voice_priority', True), + use_priority=BEE2_config.get_bool('General', 'voiceline_priority', False), ) @@ -449,7 +451,7 @@ def set_player_portalgun(vmf: VMF) -> None: has['spawn_nogun'] = True ent_pos = options.get(Vec, 'global_pti_ents_loc') - + logic_auto = vmf.create_ent('logic_auto', origin=ent_pos, flags='1') if not blue_portal or not oran_portal or force_portal_man: @@ -638,11 +640,11 @@ def add_fog_ents(vmf: VMF) -> None: fog_opt = settings['fog'] - random.seed(MAP_RAND_SEED + '_shadow_angle') + rng = rand.seed(b'shadow_angle') vmf.create_ent( classname='shadow_control', # Slight variations around downward direction. - angles=Vec(random.randrange(85, 90), random.randrange(0, 360), 0), + angles=Angle(rng.randrange(85, 90), rng.randrange(0, 360), 0), origin=pos + (0, 16, 0), distance=100, color=fog_opt['shadow'], @@ -823,6 +825,10 @@ def get_map_info(vmf: VMF) -> Set[str]: # Should we force the player to spawn in the elevator? elev_override = BEE2_config.get_bool('General', 'spawn_elev') + # If shift is held, this is reversed. + if utils.check_shift(): + LOGGER.info('Shift held, inverting configured elevator/chamber spawn!') + elev_override = not elev_override if elev_override: # Make conditions set appropriately @@ -830,7 +836,7 @@ def get_map_info(vmf: VMF) -> Set[str]: IS_PREVIEW = False # Door frames use the same instance for both the entry and exit doors, - # and it'd be useful to disinguish between them. Add an instvar to help. + # and it'd be useful to distinguish between them. Add an instvar to help. door_frames = [] entry_origin = Vec(-999, -999, -999) exit_origin = Vec(-999, -999, -999) @@ -849,6 +855,8 @@ def get_map_info(vmf: VMF) -> Set[str]: # The door frame instances entry_door_frame = exit_door_frame = None + filenames = Counter() + for item in vmf.by_class['func_instance']: # Loop through all the instances in the map, looking for the entry/exit # doors. @@ -862,7 +870,7 @@ def get_map_info(vmf: VMF) -> Set[str]: # later file = item['file'].casefold() - LOGGER.debug('File: "{}"', file) + filenames[file] += 1 if file in file_sp_exit_corr: GAME_MODE = 'SP' # In SP mode the same instance is used for entry and exit door @@ -940,6 +948,11 @@ def get_map_info(vmf: VMF) -> Set[str]: inst_files.add(item['file']) + LOGGER.debug('Instances present:\n{}', '\n'.join([ + f'- "{file}": {count}' + for file, count in filenames.most_common() + ])) + LOGGER.info("Game Mode: " + GAME_MODE) LOGGER.info("Is Preview: " + str(IS_PREVIEW)) @@ -1098,25 +1111,6 @@ def mod_doorframe(inst: Entity, corr_id, corr_type, corr_name): inst['file'] = replace -def calc_rand_seed(vmf: VMF) -> str: - """Use the ambient light entities to create a map seed. - - This ensures textures remain the same when the map is recompiled. - """ - amb_light = instanceLocs.resolve('') - lst = [ - inst['targetname'] or '-' # If no targ - for inst in - vmf.by_class['func_instance'] - if inst['file'].casefold() in amb_light - ] - if len(lst) == 0: - # Very small maps won't have any ambient light entities at all. - return 'SEED' - else: - return '|'.join(lst) - - def add_goo_mist(vmf, sides: Iterable[Vec_tuple]): """Add water_mist* particle systems to goo. @@ -1165,7 +1159,7 @@ def add_goo_mist(vmf, sides: Iterable[Vec_tuple]): def fit_goo_mist( vmf: VMF, sides: Iterable[Vec_tuple], - needs_mist: Set[Vec_tuple], + needs_mist: Set[Tuple[float, float, float]], grid_x: int, grid_y: int, particle: str, @@ -1200,59 +1194,6 @@ def fit_goo_mist( needs_mist.remove((pos.x+x, pos.y+y, pos.z)) -@conditions.meta_cond(priority=-50) -def set_barrier_frame_type(vmf: VMF) -> None: - """Set a $type instvar on glass frame. - - This allows using different instances on glass and grating. - """ - barrier_types = {} # origin, normal -> 'glass' / 'grating' - barrier_pos: List[Tuple[Vec, str]] = [] - - # Find glass and grating brushes.. - for brush in vmf.iter_wbrushes(world=False, detail=True): - for side in brush: - if side.mat == consts.Special.GLASS: - break - else: - # Not glass.. - continue - barrier_pos.append((brush.get_origin(), 'glass')) - - for brush_ent in vmf.by_class['func_brush']: - for side in brush_ent.sides(): - if side.mat == consts.Special.GRATING: - break - else: - # Not grating.. - continue - barrier_pos.append((brush_ent.get_origin(), 'grating')) - - # The origins are at weird offsets, calc a grid pos + normal instead - for pos, barrier_type in barrier_pos: - grid_pos = pos // 128 * 128 + (64, 64, 64) - barrier_types[ - grid_pos.as_tuple(), - (pos - grid_pos).norm().as_tuple() - ] = barrier_type - - barrier_files = instanceLocs.resolve('') - glass_file = instanceLocs.resolve('[glass_128]') - for inst in vmf.by_class['func_instance']: - if inst['file'].casefold() not in barrier_files: - continue - if inst['file'].casefold() in glass_file: - # The glass instance faces a different way to the frames.. - norm = Vec(-1, 0, 0) @ Angle.from_str(inst['angles']) - else: - norm = Vec(0, 0, -1) @ Angle.from_str(inst['angles']) - origin = Vec.from_str(inst['origin']) - try: - inst.fixup[consts.FixupVars.BEE_GLS_TYPE] = barrier_types[origin.as_tuple(), round(norm).as_tuple()] - except KeyError: - pass - - def change_brush(vmf: VMF) -> None: """Alter all world/detail brush textures to use the configured ones.""" LOGGER.info("Editing Brushes...") @@ -1461,8 +1402,12 @@ def position_exit_signs(vmf: VMF) -> None: ) inst.fixup['$arrow'] = sign_dir inst.fixup['$orient'] = orient - # Indicate the singular instances shouldn't be placed. - exit_sign['bee_noframe'] = exit_arrow['bee_noframe'] = '1' + if options.get(bool, "remove_exit_signs_dual"): + exit_sign.remove() + exit_arrow.remove() + else: + # Indicate the singular instances shouldn't be placed. + exit_sign['bee_noframe'] = exit_arrow['bee_noframe'] = '1' def change_overlays(vmf: VMF) -> None: @@ -1528,7 +1473,7 @@ def add_extra_ents(vmf: VMF, game_mode: str) -> None: music.add( vmf, loc, - settings['music_conf'], + settings['music_conf'], # type: ignore settings['has_attr'], game_mode == 'SP', ) @@ -1808,8 +1753,10 @@ def main() -> None: """ global MAP_RAND_SEED - LOGGER.info("BEE{} VBSP hook initiallised.", utils.BEE_VERSION) + LOGGER.info("BEE{} VBSP hook initiallised, srctools v{}.", utils.BEE_VERSION, srctools.__version__) + # Warn if srctools Cython code isn't installed. + utils.check_cython(LOGGER.warning) conditions.import_conditions() # Import all the conditions and # register them. @@ -1924,7 +1871,7 @@ def main() -> None: antline_floor=ant_floor, ) - MAP_RAND_SEED = calc_rand_seed(vmf) + rand.init_seed(vmf) all_inst = get_map_info(vmf) @@ -1933,14 +1880,14 @@ def main() -> None: fizzler.parse_map(vmf, settings['has_attr']) barriers.parse_map(vmf, settings['has_attr']) - conditions.init(MAP_RAND_SEED, all_inst) + conditions.init(all_inst) tiling.gen_tile_temp() tiling.analyse_map(vmf, side_to_antline) del side_to_antline - texturing.setup(game, vmf, MAP_RAND_SEED, list(tiling.TILES.values())) + texturing.setup(game, vmf, list(tiling.TILES.values())) conditions.check_all(vmf) add_extra_ents(vmf, GAME_MODE) diff --git a/src/vrad.py b/src/vrad.py index fea9abf72..9ff2f6b6a 100644 --- a/src/vrad.py +++ b/src/vrad.py @@ -8,13 +8,10 @@ LOGGER = init_logging('bee2/vrad.log') import os -import shutil import sys -import importlib -import pkgutil from io import BytesIO from zipfile import ZipFile -from typing import List, Set +from typing import List from pathlib import Path import srctools.run @@ -25,6 +22,7 @@ from srctools.game import find_gameinfo from srctools.bsp_transform import run_transformations from srctools.scripts.plugin import PluginFinder, Source as PluginSource +from srctools.compiler import __version__ as version_haddons from BEE2_config import ConfigFile from postcomp import music, screenshot @@ -43,18 +41,10 @@ def load_transforms() -> None: We need to do this differently when frozen, since they're embedded in our executable. """ - # Find the modules in the conditions package. - # PyInstaller messes this up a bit. if utils.FROZEN: - # This is the PyInstaller loader injected during bootstrap. - # See PyInstaller/loader/pyimod03_importers.py - # toc is a PyInstaller-specific attribute containing a set of - # all frozen modules. - loader = pkgutil.get_loader('postcomp.transforms') - for module in loader.toc: - if module.startswith('postcomp.transforms'): - LOGGER.debug('Importing transform {}', module) - sys.modules[module] = importlib.import_module(module) + # We embedded a copy of all the transforms in this package, which auto-imports the others. + # noinspection PyUnresolvedReferences + from postcomp import transforms else: # We can just delegate to the regular postcompiler finder. try: @@ -74,35 +64,6 @@ def load_transforms() -> None: finder.load_all() -def dump_files(bsp: BSP, dump_folder: str) -> None: - """Dump packed files to a location. - """ - dump_folder = os.path.abspath(dump_folder) - - LOGGER.info('Dumping packed files to "{}"...', dump_folder) - - # Delete files in the folder, but don't delete the folder itself. - try: - files = os.listdir(dump_folder) - except FileNotFoundError: - return - - for name in files: - name = os.path.join(dump_folder, name) - if os.path.isdir(name): - try: - shutil.rmtree(name) - except OSError: - # It's possible to fail here, if the window is open elsewhere. - # If so, just skip removal and fill the folder. - pass - else: - os.remove(name) - - for zipinfo in bsp.pakfile.infolist(): - bsp.pakfile.extract(zipinfo, dump_folder) - - def run_vrad(args: List[str]) -> None: """Execute the original VRAD.""" code = srctools.run.run_compiler(os.path.join(os.getcwd(), "vrad"), args) @@ -115,12 +76,18 @@ def run_vrad(args: List[str]) -> None: def main(argv: List[str]) -> None: """Main VRAD script.""" - LOGGER.info('BEE2 VRAD hook started!') - + LOGGER.info( + "BEE{} VRAD hook initiallised, srctools v{}, Hammer Addons v{}", + utils.BEE_VERSION, srctools.__version__, version_haddons, + ) + + # Warn if srctools Cython code isn't installed. + utils.check_cython(LOGGER.warning) + args = " ".join(argv) fast_args = argv[1:] full_args = argv[1:] - + if not fast_args: # No arguments! LOGGER.info( @@ -136,7 +103,7 @@ def main(argv: List[str]) -> None: # The path is the last argument to vrad # P2 adds wrong slashes sometimes, so fix that. - fast_args[-1] = path = os.path.normpath(argv[-1]) # type: str + fast_args[-1] = path = os.path.normpath(argv[-1]) LOGGER.info("Map path is " + path) @@ -187,6 +154,10 @@ def main(argv: List[str]) -> None: # check the config file to see what was specified there. if os.path.basename(path) == "preview.bsp": edit_args = not config.get_bool('General', 'vrad_force_full') + # If shift is held, reverse. + if utils.check_shift(): + LOGGER.info('Shift held, inverting configured lighting option!') + edit_args = not edit_args else: # publishing - always force full lighting. edit_args = False @@ -203,14 +174,18 @@ def main(argv: List[str]) -> None: is_peti = edit_args = False LOGGER.info('Final status: is_peti={}, edit_args={}', is_peti, edit_args) + if not is_peti: + # Skip everything, if the user wants these features install the Hammer Addons postcompiler. + LOGGER.info("Hammer map detected! Skipping all transforms.") + run_vrad(full_args) + return # Grab the currently mounted filesystems in P2. game = find_gameinfo(argv) root_folder = game.path.parent fsys = game.get_filesystem() - # Special case - move the BEE2 fsys FIRST, so we always pack files found - # there. + # Special case - move the BEE2 filesystem FIRST, so we always pack files found there. for child_sys in fsys.systems[:]: if 'bee2' in child_sys[0].path.casefold(): fsys.systems.remove(child_sys) @@ -223,8 +198,6 @@ def main(argv: List[str]) -> None: # Mount the existing packfile, so the cubemap files are recognised. fsys.add_sys(ZipFileSystem('', zipfile)) - fsys.open_ref() - LOGGER.info('Done!') LOGGER.debug('Filesystems:') @@ -235,21 +208,25 @@ def main(argv: List[str]) -> None: fgd = FGD.engine_dbase() packlist = PackList(fsys) + LOGGER.info('Reading soundscripts...') packlist.load_soundscript_manifest( str(root_folder / 'bin/bee2/sndscript_cache.vdf') ) - load_transforms() - # We need to add all soundscripts in scripts/bee2_snd/ # This way we can pack those, if required. for soundscript in fsys.walk_folder('scripts/bee2_snd/'): if soundscript.path.endswith('.txt'): packlist.load_soundscript(soundscript, always_include=False) - if is_peti: - LOGGER.info('Checking for music:') - music.generate(bsp_file.ents, packlist) + LOGGER.info('Reading particles....') + packlist.load_particle_manifest() + + LOGGER.info('Loading transforms...') + load_transforms() + + LOGGER.info('Checking for music:') + music.generate(bsp_file.ents, packlist) LOGGER.info('Run transformations...') run_transformations(bsp_file.ents, fsys, packlist, bsp_file, game) @@ -260,22 +237,32 @@ def main(argv: List[str]) -> None: packlist.eval_dependencies() LOGGER.info('Done!') - packlist.write_manifest() + packlist.write_soundscript_manifest() + packlist.write_particles_manifest(f'maps/{Path(path).stem}_particles.txt') # We need to disallow Valve folders. - pack_whitelist = set() # type: Set[FileSystem] - pack_blacklist = set() # type: Set[FileSystem] - if is_peti: - # Exclude absolutely everything except our folder. - for child_sys, _ in fsys.systems: - # Add 'bee2/' and 'bee2_dev/' only. - if ( - isinstance(child_sys, RawFileSystem) and - 'bee2' in os.path.basename(child_sys.path).casefold() - ): - pack_whitelist.add(child_sys) - else: - pack_blacklist.add(child_sys) + pack_whitelist: set[FileSystem] = set() + pack_blacklist: set[FileSystem] = set() + + # Exclude absolutely everything except our folder. + for child_sys, _ in fsys.systems: + # Add 'bee2/' and 'bee2_dev/' only. + if ( + isinstance(child_sys, RawFileSystem) and + 'bee2' in os.path.basename(child_sys.path).casefold() + ): + pack_whitelist.add(child_sys) + else: + pack_blacklist.add(child_sys) + + if config.get_bool('General', 'packfile_dump_enable'): + dump_loc = Path(config.get_val( + 'General', + 'packfile_dump_dir', + '../dump/' + )).absolute() + else: + dump_loc = None if '-no_pack' not in args: # Cubemap files packed into the map already. @@ -287,34 +274,24 @@ def main(argv: List[str]) -> None: ignore_vpk=True, whitelist=pack_whitelist, blacklist=pack_blacklist, + dump_loc=dump_loc, ) LOGGER.info('Packed files:\n{}', '\n'.join( set(bsp_file.pakfile.namelist()) - existing )) - if config.get_bool('General', 'packfile_dump_enable'): - dump_files(bsp_file, config.get_val( - 'General', - 'packfile_dump_dir', - '../dump/' - )) - LOGGER.info('Writing BSP...') bsp_file.save() LOGGER.info(' - BSP written!') - if is_peti: - screenshot.modify(config, game.path) + screenshot.modify(config, game.path) if edit_args: LOGGER.info("Forcing Cheap Lighting!") run_vrad(fast_args) else: - if is_peti: - LOGGER.info("Publishing - Full lighting enabled! (or forced to do so)") - else: - LOGGER.info("Hammer map detected! Not forcing cheap lighting..") + LOGGER.info("Publishing - Full lighting enabled! (or forced to do so)") run_vrad(full_args) LOGGER.info("BEE2 VRAD hook finished!")