diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd517ce..e8aabc2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,29 @@ and this project somewhat adheres to [Semantic Versioning](https://semver.org/sp The **"Breaking Changes"** listed below are changes that have been made in the decompilation projects (e.g. pokeemerald), which porymap requires in order to work properly. It also includes changes to the scripting API that may change the behavior of existing porymap scripts. If porymap is used with a project or API script that is not up-to-date with the breaking changes, then porymap will likely break or behave improperly. ## [Unreleased] +### Added +- Adds an editor window under `Options -> Project Settings...` to customize the project-specific settings in `porymap.project.cfg` and `porymap.user.cfg`. +- Adds an editor window under `Options -> Custom Scripts...` for Porymap's API scripts. +- Support for 8BPP tileset tile images. + ### Changed - The Palette Editor now remembers the Bit Depth setting. +- The min/max levels on the Wild Pokémon tab will now adjust automatically if they invalidate each other. +- If the recent project directory doesn't exist Porymap will open an empty project instead of failing with a misleading error message. +- Settings under `Options` were relocated either to the `Preferences` window or `Options -> Project Settings`. +- Secret Base and Weather Trigger events are automatically disabled if their respective constants files fail to parse, instead of not opening the project. ### Fixed - Fix text boxes in the Palette Editor calculating color incorrectly. - Fix metatile labels being sorted incorrectly for tileset names with multiple underscores. - Fix default object sprites retaining dimensions and transparency of the previous sprite. +- Fix connections not being deleted when the map name text box is cleared. +- Fix the map border not updating when a tileset is changed. +- Improve the poor speed of the API functions `setMetatileTile` and `setMetatileTiles`. +- Stop the Tileset Editor from scrolling to the initially selected metatile when saving. +- Fix `0x0`/`NULL` appearing more than once in the scripts dropdown. +- Fix the selection outline sticking in single-tile mode on the Prefab tab. +- Fix bad URL color contrast on dark themes. ## [5.1.1] - 2023-02-20 ### Added diff --git a/INSTALL.md b/INSTALL.md index 4cad7365..34f03196 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,7 +7,7 @@ porymap requires Qt 5.14.2 & C++11. The easiest way to get Qt is through [homebrew](https://brew.sh/). Once homebrew is installed, run these commands in Terminal: -``` +```bash xcode-select --install brew update @@ -32,8 +32,9 @@ Install [Qt development tools](https://www.qt.io/download-qt-installer), and use You need to install Qt. The minimum supported version is currently Qt 5.14.2. You can check your Qt version with `qtdiag`. -``` -sudo apt-get install qt5-default qtdeclarative5-dev +```bash +sudo apt-get install qt6-declarative-dev +# if your distro does not have qt6-declarative-dev, try sudo apt-get install qtdeclarative5-dev qmake make ./porymap diff --git a/docsrc/manual/images/project-files/settings.png b/docsrc/manual/images/project-files/settings.png new file mode 100644 index 00000000..91d55b4e Binary files /dev/null and b/docsrc/manual/images/project-files/settings.png differ diff --git a/docsrc/manual/images/settings-and-options/base-game-version.png b/docsrc/manual/images/settings-and-options/base-game-version.png new file mode 100644 index 00000000..4cc45099 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/base-game-version.png differ diff --git a/docsrc/manual/images/settings-and-options/default-tilesets.png b/docsrc/manual/images/settings-and-options/default-tilesets.png new file mode 100644 index 00000000..073e90de Binary files /dev/null and b/docsrc/manual/images/settings-and-options/default-tilesets.png differ diff --git a/docsrc/manual/images/settings-and-options/events.png b/docsrc/manual/images/settings-and-options/events.png new file mode 100644 index 00000000..ea6e5170 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/events.png differ diff --git a/docsrc/manual/images/settings-and-options/maps.png b/docsrc/manual/images/settings-and-options/maps.png new file mode 100644 index 00000000..3054c155 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/maps.png differ diff --git a/docsrc/manual/images/settings-and-options/new-map-defaults.png b/docsrc/manual/images/settings-and-options/new-map-defaults.png new file mode 100644 index 00000000..50b557f7 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/new-map-defaults.png differ diff --git a/docsrc/manual/images/settings-and-options/prefabs.png b/docsrc/manual/images/settings-and-options/prefabs.png new file mode 100644 index 00000000..40aa4c77 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/prefabs.png differ diff --git a/docsrc/manual/images/settings-and-options/preferences.png b/docsrc/manual/images/settings-and-options/preferences.png new file mode 100644 index 00000000..b1b5bf5e Binary files /dev/null and b/docsrc/manual/images/settings-and-options/preferences.png differ diff --git a/docsrc/manual/images/settings-and-options/tilesets-metatiles.png b/docsrc/manual/images/settings-and-options/tilesets-metatiles.png new file mode 100644 index 00000000..96f312c9 Binary files /dev/null and b/docsrc/manual/images/settings-and-options/tilesets-metatiles.png differ diff --git a/docsrc/manual/project-files.rst b/docsrc/manual/project-files.rst index 78a899dc..aef44032 100644 --- a/docsrc/manual/project-files.rst +++ b/docsrc/manual/project-files.rst @@ -6,8 +6,12 @@ Porymap relies on the user maintaining a certain level of integrity with their p This is a list of files that porymap reads from and writes to. Generally, if porymap writes to a file, it probably is not a good idea to edit yourself unless otherwise noted. -The filepath that Porymap expects for each file can be overridden with config options. The name of each config override is listed in the table, and should begin with ``path/``. -For example if you wanted to rename ``include/constants/items.h`` to ``headers/defines/stuff.h``, you would add ``path/constants_items=headers/defines/stuff.h`` to your project's ``porymap.project.cfg`` file. +The filepath that Porymap expects for each file can be overridden under the ``Project Files`` section of ``Options -> Project Settings``. A new path can be specified by entering it in the text box or choosing it with the folder button. Paths are expected to be relative to the root project folder. If no path is specified, or if the file/folder specified does not exist, then the default path will be used instead. The name of each setting in this section is listed in the table below under ``Override``. + +.. figure:: images/project-files/settings.png + :align: center + :width: 75% + :alt: Settings .. csv-table:: diff --git a/docsrc/manual/settings-and-options.rst b/docsrc/manual/settings-and-options.rst index 3679f3d7..e26dc08e 100644 --- a/docsrc/manual/settings-and-options.rst +++ b/docsrc/manual/settings-and-options.rst @@ -5,66 +5,307 @@ Porymap Settings **************** Porymap uses config files to read and store user and project settings. + +=============== +Global settings +=============== + A global settings file is stored in a platform-dependent location for app configuration files (``%Appdata%\pret\porymap\porymap.cfg`` on Windows, ``~/Library/Application\ Support/pret/porymap/porymap.cfg`` on macOS). -A config file is also created when opening a project in porymap for the first time. It is stored in -your project root as ``porymap.project.cfg``. There are several project-specific settings that are -determined by this file. You may want to force commit this file so that other users will automatically have access to your project settings. +A selection of the settings in this file can be edited under ``Preferences...``, and the rest are updated automatically while using Porymap. -A second config file is created for user-specific settings. It is stored in -your project root as ``porymap.user.cfg``. You should add this file to your gitignore. +================ +Project settings +================ -.. csv-table:: - :header: Setting,Default,Location,Can Edit?,Description - :widths: 10, 3, 5, 5, 20 +A config file for project-specific settings is also created when opening a project in porymap for the first time. It is stored in your project root as ``porymap.project.cfg``. You may want to force commit this file so that other users will automatically have access to your project settings. - ``recent_project``, , global, yes, The project that will be opened on launch - ``reopen_on_launch``, 1, global, yes, Whether the most recent project should be opened on launch - ``recent_map``, , user, yes, The map that will be opened on launch - ``pretty_cursors``, 1, global, yes, Whether to use custom crosshair cursors - ``map_sort_order``, group, global, yes, The order map list is sorted in - ``window_geometry``, , global, no, For restoring window sizes - ``window_state``, , global, no, For restoring window sizes - ``map_splitter_state``, , global, no, For restoring window sizes - ``main_splitter_state``, , global, no, For restoring window sizes - ``collision_opacity``, 50, global, yes, Opacity of collision overlay - ``metatiles_zoom``, 30, global, yes, Scale of map metatiles - ``show_player_view``, 0, global, yes, Display a rectangle for the GBA screen radius - ``show_cursor_tile``, 0, global, yes, Display a rectangle around the hovered metatile(s) - ``monitor_files``, 1, global, yes, Whether porymap will monitor changes to project files - ``tileset_checkerboard_fill``, 1, global, yes, Whether new tilesets will be filled with a checkerboard pattern of metatiles. - ``theme``, default, global, yes, The color theme for porymap windows and widgets - ``text_editor_goto_line``, , global, yes, The command that will be executed when clicking the button next the ``Script`` combo-box. - ``text_editor_open_directory``, , global, yes, The command that will be executed when clicking ``Open Project in Text Editor``. - ``base_game_version``, , project, no, The base pret repo for this project - ``use_encounter_json``, 1, user, yes, Enables wild encounter table editing - ``use_poryscript``, 0, project, yes, Whether to open .pory files for scripts - ``use_custom_border_size``, 0, project, yes, Whether to allow variable border sizes - ``enable_event_weather_trigger``, 1 if not ``pokefirered``, project, yes, Allows adding Weather Trigger events - ``enable_event_secret_base``, 1 if not ``pokefirered``, project, yes, Allows adding Secret Base events - ``enable_event_clone_object``, 1 if ``pokefirered``, project, yes, Allows adding Clone Object events - ``enable_hidden_item_quantity``, 1 if ``pokefirered``, project, yes, Adds ``Quantity`` to Hidden Item events - ``enable_hidden_item_requires_itemfinder``, 1 if ``pokefirered``, project, yes, Adds ``Requires Itemfinder`` to Hidden Item events - ``enable_heal_location_respawn_data``, 1 if ``pokefirered``, project, yes, Adds ``Respawn Map`` and ``Respawn NPC`` to Heal Location events - ``enable_floor_number``, 1 if ``pokefirered``, project, yes, Adds ``Floor Number`` to map headers - ``enable_map_allow_flags``, 1 if not ``pokeruby``, project, yes, "Adds ``Allow Running``, ``Allow Biking``, and ``Allow Dig & Escape Rope`` to map headers" - ``create_map_text_file``, 1 if not ``pokeemerald``, project, yes, A ``text.inc`` or ``text.pory`` file will be created for any new map - ``enable_triple_layer_metatiles``, 0, project, yes, Enables triple-layer metatiles (See https://github.com/pret/pokeemerald/wiki/Triple-layer-metatiles) - ``new_map_metatile``, 1, project, yes, The metatile id that will be used to fill new maps - ``new_map_elevation``, 3, project, yes, The elevation that will be used to fill new maps - ``new_map_border_metatiles``, "``468,469,476,477`` or ``20,21,28,29``", project, yes, The list of metatile ids that will be used to fill the 2x2 border of new maps - ``default_primary_tileset``, ``gTileset_General``, project, yes, The label of the default primary tileset - ``default_secondary_tileset``, ``gTileset_Petalburg`` or ``gTileset_PalletTown``, project, yes, The label of the default secondary tileset - ``custom_scripts``, , user, yes, A list of script files to load into the scripting engine - ``prefabs_filepath``, ``/prefabs.json``, project, yes, The filepath containing prefab JSON data - ``prefabs_import_prompted``, 0, project, no, Keeps track of whether or not the project was prompted for importing default prefabs - ``tilesets_have_callback``, 1, project, yes, Whether new tileset headers should have the ``callback`` field - ``tilesets_have_is_compressed``, 1, project, yes, Whether new tileset headers should have the ``isCompressed`` field - ``metatile_attributes_size``, 2 or 4, project, yes, The number of attribute bytes each metatile has - ``metatile_behavior_mask``, ``0xFF`` or ``0x1FF``, project, yes, The mask for the metatile Behavior attribute - ``metatile_encounter_type_mask``, ``0x0`` or ``0x7000000``, project, yes, The mask for the metatile Encounter Type attribute - ``metatile_layer_type_mask``, ``0xF000`` or ``0x60000000``, project, yes, The mask for the metatile Layer Type attribute - ``metatile_terrain_type_mask``, ``0x0`` or ``0x3E00``, project, yes, The mask for the metatile Terrain Type attribute +A second config file is created for user-specific settings. It is stored in your project root as ``porymap.user.cfg``. You should add this file to your gitignore. -Some of these settings can be toggled manually in porymap via the *Options* menu. +The settings in ``porymap.project.cfg`` and ``porymap.user.cfg`` can be edited under ``Options -> Project Settings...``. Any changes made in this window will not take effect unless confirmed by selecting ``OK`` and then reloading the project. + +Each of the settings in the ``Project Settings...`` window are described below. + +.. warning:: + Changing any of the settings in the Project Settings Editor's red ``Warning`` box will require additional changes to your project to function correctly. Investigate the repository versions that have a setting natively supported to see what changes to your project are necessary. + + +Preferences +----------- + +.. figure:: images/settings-and-options/preferences.png + :align: left + :width: 60% + :alt: Preferences + +Use Poryscript + If this is checked, a ``scripts.pory`` (and ``text.pory``, if applicable) file will be created alongside new maps, instead of a ``scripts.inc`` file. Additionally, ``.pory`` files will be considered when searching for scripts labels and when opening scripts files (in addition to the regular ``.inc`` files). + + Defaults to ``unchecked``. + + Field name: ``use_poryscript`` + +Show Wild Encounter Tables + If this is checked, the ``Wild Pokemon`` tab will be enabled and wild encounter data will be read from the project's encounters JSON file. + + If no encounters JSON file is found this will be automatically unchecked. + + Field name: ``use_encounter_json`` + + +Default Tilesets +---------------- + +.. figure:: images/settings-and-options/default-tilesets.png + :align: left + :width: 60% + :alt: Default Tilesets + +Default Primary/Secondary Tilesest + These will be the initially-selected tilesets when creating a new map, and will be used if a layout's tileset fails to load. If a default tileset is not found then the first tileset in the respective list will be used instead. + + The default primary tileset is ``gTileset_General``. + + The default secondary tileset is ``gTileset_PalletTown`` for ``pokefirered``, and ``gTileset_Petalburg`` for other versions. + + Field names: ``default_primary_tileset`` and ``default_secondary_tileset`` + + +New Map Defaults +---------------- + +.. figure:: images/settings-and-options/new-map-defaults.png + :align: left + :width: 60% + :alt: New Map Defaults + +Border Metatiles + This is list of metatile ID values that will be used to fill the border on new maps. The spin boxes correspond to the top-left, top-right, bottom-left, and bottom-right border metatiles respectively. + + If ``Enable Custom Border Size`` is checked, this will instead be a comma-separated list of metatile ID values that will be used to fill the border on new maps. Values in the list will be read sequentially to fill the new border left-to-right top-to-bottom. If the number of metatiles in the border for a new map is not the same as the number of values in the list then the border will be filled with metatile ID ``0x000`` instead. + + Defaults to ``0x014``, ``0x015``, ``0x01C``, ``0x01D`` for ``pokefirered``, and ``0x1D4``, ``0x1D5``, ``0x1DC``, ``0x1DD`` for other versions. + + Field name: ``new_map_border_metatiles`` + +Fill Metatile + This is the metatile ID value that will be used to fill new maps. + + Defaults to ``0x1``. + + Field name: ``new_map_metatile`` + +Elevation + This is the elevation that will be used to fill new maps. New maps will be filled with passable collision. + + Defaults to ``3``. + + Field name: ``new_map_elevation`` + +Create separate text file + If this is checked, a ``text.inc`` (or ``text.pory``) file will be created alongside new maps. + + Defaults to ``unchecked`` for ``pokeemerald`` and ``checked`` for other versions. + + Field name: ``create_map_text_file`` + + +Prefabs +------- + +.. figure:: images/settings-and-options/prefabs.png + :align: left + :width: 60% + :alt: Prefabs + +Prefabs Path + This is the file path to a ``.json`` file that contains definitions of prefabs. This will be used to populate the ``Prefabs`` panel on the ``Map`` tab. If no path is specified prefabs will be saved to a new ``prefabs.json`` file in the root project folder. A new file can be selected with the folder button. + + The ``Import Defaults`` button will populate the specified file with version-specific prefabs constructed using the vanilla tilesets. This will overwrite any existing prefabs. + + Field name: ``prefabs_filepath``. + + Additionally, there is a ``prefabs_import_prompted`` field that should not be edited. + + +Base game version +----------------- + +.. figure:: images/settings-and-options/base-game-version.png + :align: left + :width: 60% + :alt: Base Game Version + +This is the name of base pret repository for this project. The options are ``pokeruby``, ``pokefirered``, and ``pokeemerald``, and can be selected (or automatically from the project folder name) when the project is first opened. Changing the base game version setting will prompt you to restore the default project settings for any of the three versions. You can also do this for the currently-selected base game version by selecting ``Restore Defaults`` at the bottom. For up-to-date projects changing this setting has no other effect. + +Field name: ``base_game_version`` + + +Tilesets / Metatiles +-------------------- + +.. figure:: images/settings-and-options/tilesets-metatiles.png + :align: left + :width: 60% + :alt: Tilesets / Metatiles + +Enable Triple Layer Metatiles + Metatile data normally consists of 2 layers with 4 tiles each. If this is checked, they should instead consist of 3 layers with 4 tiles each. Additionally, the ``Layer Type`` option in the ``Tileset Editor`` will be removed. Note that layer type data will still be read and written according to your ``Layer Type mask`` setting. + + For details on supporting this setting in your project, see https://github.com/pret/pokeemerald/wiki/Triple-layer-metatiles. + + Defaults to ``unchecked`` + + Field name: ``enable_triple_layer_metatiles`` + +Attributes size + The number of bytes used per metatile for metatile attributes. The data in each of your project's ``metatile_attributes.bin`` files will be expected to be ``s * n``, where ``s`` is this size and ``n`` is the number of metatiles in the tileset. Additionally, new ``metatile_attributes.bin`` will be included in the project with a corresponding ``INCBIN_U8``, ``INCBIN_U16``, or ``INCBIN_U32`` directive. + + Changing this setting will automatically enforce the new limit on the metatile attribute mask settings below. + + Defaults to ``4`` for ``pokefirered`` and ``2`` for other versions. + + Field name: ``metatile_attributes_size`` + +Attribute masks + Each of the following four settings are bit masks that will be used to read and write a specific metatile attribute from the metatile attributes data. If you are instead importing metatile attribute data from AdvanceMap, a default mask value will be used to read the data, and the mask value specified here will be used to write the new file. + + If any of the mask values are set to ``0x0``, the corresponding option in the Tileset Editor will be removed. The maximum for all the attribute masks is determined by the Attributes size setting. + +.. warning:: + If any of the metatile attribute masks have overlapping bits they may behave in unexpected ways. A warning will be logged in the Porymap log file if this happens + + +Metatile Behavior mask + See Attribute masks. This is the mask value for the ``Metatile Behavior`` metatile attribute. + + Defaults to ``0x1FF`` for ``pokefirered``, and ``0xFF`` for other versions. + + Field name: ``metatile_behavior_mask`` + +Layer Type mask + See Attribute masks. This is the mask value for the ``Layer Type`` metatile attribute. If the value is set to ``0x0`` the ``Layer Type`` option will be disabled in the Tileset Editor, and all metatiles will be treated in the editor as if they had the ``Normal`` layer type. + + Defaults to ``0x60000000`` for ``pokefirered`` and ``0xF000`` for other versions. + + Field name: ``metatile_layer_type_mask`` + +Encounter Type mask + See Attribute masks. This is the mask value for the ``Encounter Type`` metatile attribute. + + Defaults to ``0x7000000`` for ``pokefirered`` and ``0x0`` for other versions. + + Field name: ``metatile_encounter_type_mask`` + +Terrain Type mask + See Attribute masks. This is the mask value for the ``Terrain Type`` metatile attribute. + + Defaults to ``0x3E00`` for ``pokefirered`` and ``0x0`` for other versions. + + Field name: ``metatile_terrain_type_mask`` + +Output 'callback' and 'isCompressed' fields + If these are checked, then ``callback`` and ``isCompressed`` fields will be output in the C data for new tilesets. Their default values will be ``NULL`` and ``TRUE``, respectively. + + Defaults to ``checked`` for both. + + Field names: ``tilesets_have_callback`` and ``tilesets_have_is_compressed`` + + +Project Files +------------- + This is a list of the files and folders Porymap expects from your project. Each can be overridden by typing a new path or selecting a file/folder with the folder button. If the file/folder doesn't exist when the project is loaded then the default path will be used instead. + + For more information on each of these files/folders, see https://huderlem.github.io/porymap/manual/project-files.html + + Field name: ``path/`` + +Events +------ + +.. figure:: images/settings-and-options/events.png + :align: left + :width: 60% + :alt: Events + +Enable Clone Objects + If this is checked Clone Object Events will be available on the ``Events`` tab. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#clone-object-events + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``enable_event_clone_object`` + +Enable Secret Bases + If this is checked Secret Base Events will be available on the ``Events`` tab. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#secret-base-event + + Defaults to ``unchecked`` for ``pokefirered`` and ``checked`` for other versions. + + Field name: ``enable_event_secret_base`` + +Enable Weather Triggers + If this is checked Weather Trigger Events will be available on the ``Events`` tab. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#weather-trigger-events + + Defaults to ``unchecked`` for ``pokefirered`` and ``checked`` for other versions. + + Field name: ``enable_event_weather_trigger`` + +Enable 'Quantity' for Hidden Items + If this is checked the ``Quantity`` property will be available for Hidden Item Events. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#hidden-item-event + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``enable_hidden_item_quantity`` + +Enable 'Requires Itemfinder' for Hidden Items + If this is checked the ``Requires Itemfinder`` property will be available for Hidden Item Events. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#hidden-item-event + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``enable_hidden_item_requires_itemfinder`` + +Enable 'Repsawn Map/NPC' for Heal Locations + If this is checked the ``Respawn Map`` and ``Respawn NPC`` properties will be available for Heal Location events. For more information see https://huderlem.github.io/porymap/manual/editing-map-events.html#heal-location-healspots + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``enable_heal_location_respawn_data`` + + +Maps +---- + +.. figure:: images/settings-and-options/maps.png + :align: left + :width: 60% + :alt: Maps + +Enable 'Floor Number' + If this is checked, a ``Floor Number`` option will become available on the ``Header`` tab and on the new map prompt. For more information see https://huderlem.github.io/porymap/manual/editing-map-header.html + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``enable_floor_number`` + +Enable 'Allow Running/Biking/Escaping' + If this is checked, ``Allow Running``, ``Allow Biking``, and ``Allow Dig & Escape Rope`` options will become available on the ``Header`` tab and on the new map prompt. For more information see https://huderlem.github.io/porymap/manual/editing-map-header.html + + Defaults to ``unchecked`` for ``pokeruby`` and ``checked`` for other versions. + + Field name: ``enable_map_allow_flags`` + +Enable Custom Border Size + If this is checked, ``Border Width`` and ``Border Height`` options will become available under the ``Change Dimensions`` button and on the new map prompt. If it is unchecked all maps will use the default 2x2 dimensions. + + Defaults to ``checked`` for ``pokefirered`` and ``unchecked`` for other versions. + + Field name: ``use_custom_border_size`` + + +Additional Fields +----------------- + There are two additional fields in ``porymap.user.cfg`` that aren't described above. + + ``recent_map`` is the name of the most recently opened map and is updated automatically. This is the map that will be opened when the project is opened. If no map is found with this name (or if the field is empty) then the first map in the map list will be used instead. + + ``custom_scripts`` is a comma-separated list of filepaths to scripts for Porymap's API. These can be edited under ``Options -> Custom Scripts...``. For more information see https://huderlem.github.io/porymap/manual/scripting-capabilities.html diff --git a/forms/aboutporymap.ui b/forms/aboutporymap.ui index 546d0817..26fd3f0a 100644 --- a/forms/aboutporymap.ui +++ b/forms/aboutporymap.ui @@ -78,7 +78,7 @@ - <html><head/><body><p>Official Documentation: <a href="https://huderlem.github.io/porymap/"><span style=" text-decoration: underline; color:#0000ff;">https://huderlem.github.io/porymap/</span></a></p></body></html> + <html><head/><body><p>Official Documentation: <a href="https://huderlem.github.io/porymap/"><span style=" text-decoration: underline;">https://huderlem.github.io/porymap/</span></a></p></body></html> Qt::AlignCenter diff --git a/forms/customscriptseditor.ui b/forms/customscriptseditor.ui new file mode 100644 index 00000000..8166ed8d --- /dev/null +++ b/forms/customscriptseditor.ui @@ -0,0 +1,164 @@ + + + CustomScriptsEditor + + + + 0 + 0 + 374 + 355 + + + + Custom Scripts Editor + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Add New Script... + + + + :/icons/add.ico:/icons/add.ico + + + + + + + Reload Scripts + + + + :/icons/refresh.ico:/icons/refresh.ico + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + <html><head/><body><p><a href="https://huderlem.github.io/porymap/manual/scripting-capabilities.html"><span style=" text-decoration: underline;">What are custom scripts?</span></a></p></body></html> + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 5 + + + + + + + + Scripts + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::NoDragDrop + + + Qt::IgnoreAction + + + QAbstractItemView::ExtendedSelection + + + Qt::ElideLeft + + + QListView::Free + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + + + + diff --git a/forms/customscriptslistitem.ui b/forms/customscriptslistitem.ui new file mode 100644 index 00000000..7c7e8794 --- /dev/null +++ b/forms/customscriptslistitem.ui @@ -0,0 +1,97 @@ + + + CustomScriptsListItem + + + + 0 + 0 + 129 + 34 + + + + + 0 + 0 + + + + + 4 + + + 4 + + + + + Choose a new filepath for this script + + + ... + + + + :/icons/folder.ico:/icons/folder.ico + + + + + + + + 1 + 0 + + + + + + + + + + + If unchecked this script will be ignored + + + + + + + + + + Open this script file + + + ... + + + + :/icons/edit_document.ico:/icons/edit_document.ico + + + + + + + Remove this script + + + ... + + + + :/icons/delete.ico:/icons/delete.ico + + + + + + + + + + diff --git a/forms/mainwindow.ui b/forms/mainwindow.ui index 52772b99..7b8aa8b4 100644 --- a/forms/mainwindow.ui +++ b/forms/mainwindow.ui @@ -3012,13 +3012,10 @@ Options - - - - - - - + + + + @@ -3064,38 +3061,6 @@ Ctrl+S - - - true - - - Show Wild Encounter Tables - - - - - true - - - Monitor Project Files - - - - - true - - - Use Poryscript - - - - - true - - - Open Recent Project On Launch - - New Map... @@ -3288,9 +3253,9 @@ Export Map Stitch Image... - + - Edit Preferences... + Preferences... Ctrl+, @@ -3301,9 +3266,9 @@ Open Project in Text Editor - + - Edit Shortcuts... + Shortcuts... @@ -3326,6 +3291,16 @@ Import Map from Advance Map 1.92... + + + Project Settings... + + + + + Custom Scripts... + + diff --git a/forms/preferenceeditor.ui b/forms/preferenceeditor.ui index c44fa98c..974d6322 100644 --- a/forms/preferenceeditor.ui +++ b/forms/preferenceeditor.ui @@ -6,8 +6,8 @@ 0 0 - 600 - 480 + 522 + 493 @@ -18,6 +18,29 @@ 9 + + + + Miscellaneous + + + + + + Monitor project files + + + + + + + Open recent project on launch + + + + + + @@ -65,8 +88,8 @@ 0 0 - 582 - 372 + 492 + 327 diff --git a/forms/projectsettingseditor.ui b/forms/projectsettingseditor.ui new file mode 100644 index 00000000..7980e017 --- /dev/null +++ b/forms/projectsettingseditor.ui @@ -0,0 +1,765 @@ + + + ProjectSettingsEditor + + + + 0 + 0 + 600 + 600 + + + + Project Settings + + + + + 9 + + + + + true + + + + + 0 + 0 + 585 + 585 + + + + + + + Preferences + + + + + + Whether map script files should prefer using .pory + + + Use Poryscript + + + + + + + Show Wild Encounter Tables + + + + + + + + + + Default Tilesets + + + + + + Primary Tileset + + + + + + + + + + Secondary Tileset + + + + + + + + + + + + + New Map Defaults + + + + + + The default metatile value that will be used to fill new maps + + + 0x + + + 16 + + + + + + + Elevation + + + + + + + The default elevation that will be used to fill new maps + + + + + + + Fill Metatile + + + + + + + Whether a separate text.inc or text.pory file will be created for new maps, alongside the scripts file + + + Create separate text file + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Border Metatiles + + + + + + + A comma-separated list of metatile values that will be used to fill new map borders + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Border Metatiles + + + + + + + The default metatile value that will be used for the top-left border metatile on new maps. + + + 0x + + + 16 + + + + + + + The default metatile value that will be used for the top-right border metatile on new maps. + + + 0x + + + 16 + + + + + + + The default metatile value that will be used for the bottom-left border metatile on new maps. + + + 0x + + + 16 + + + + + + + The default metatile value that will be used for the bottom-right border metatile on new maps. + + + 0x + + + 16 + + + + + + + + + + + + + Prefabs + + + + + + ... + + + + :/icons/folder.ico:/icons/folder.ico + + + + + + + Restore the data in the prefabs file to the version defaults. Will create a new file if one doesn't exist. + + + Import Defaults + + + + + + + The file that will be used to populate the Prefabs tab + + + + + + + Prefabs Path + + + + + + + + + + Qt::Horizontal + + + + + + + .QFrame { border: 1px solid red; } + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 12 + 75 + true + + + + <html><head/><body><p><span style=" font-size:13pt; color:#d7000c;">WARNING: </span><span style=" font-weight:400;">The settings from this point below require project changes to function properly. Do not modify these settings without the necessary changes. </span></p></body></html> + + + true + + + + + + + + + + Base game version + + + + + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Tilesets / Metatiles + + + + + + Qt::Vertical + + + + 20 + 10 + + + + + + + + Enable Triple Layer Metatiles + + + + + + + The mask used to read/write Terrain Type from the metatile's attributes data. If 0, this attribute is disabled. + + + 0x + + + 16 + + + true + + + + + + + The number of bytes used per metatile for metatile attributes + + + Attributes size (in bytes) + + + + + + + Terrain Type mask + + + + + + + false + + + + + + + Qt::Vertical + + + + 20 + 15 + + + + + + + + Behavior mask + + + + + + + The mask used to read/write Metatile Behavior from the metatile's attributes data. If 0, this attribute is disabled. + + + 0x + + + 16 + + + true + + + + + + + Whether the C data outputted for new tilesets will include the "callback" field + + + Output 'callback' field + + + + + + + Whether the C data outputted for new tilesets will include the "isCompressed" field + + + Output 'isCompressed' field + + + + + + + The mask used to read/write Encounter Type from the metatile's attributes data. If 0, this attribute is disabled. + + + 0x + + + 16 + + + true + + + + + + + Encounter Type mask + + + + + + + Layer Type mask + + + + + + + The mask used to read/write Layer Type from the metatile's attributes data. If 0, this attribute is disabled. + + + 0x + + + 16 + + + true + + + + + + + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <html><head/><body><p><a href="https://huderlem.github.io/porymap/manual/project-files.html"><span style=" text-decoration: underline;">Project Files</span></a></p></body></html> + + + Qt::RichText + + + Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft + + + true + + + + + + + + 0 + 320 + + + + 2 + + + true + + + + + 0 + 0 + 533 + 318 + + + + + 0 + + + 0 + + + 4 + + + + + + + + + + + + Events + + + + + + Enable Weather Triggers + + + + + + + Enable Secret Bases + + + + + + + Enable Clone Objects + + + + + + + Enable 'Requires Itemfinder' for Hidden Items + + + + + + + Enable 'Quantity' for Hidden Items + + + + + + + Enable 'Respawn Map/NPC' for Heal Locations + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Maps + + + + + + Whether "Allow Running", "Allow Biking" and "Allow Dig & Escape Rope" are default options for Map Headers + + + Enable 'Allow Running/Biking/Escaping' + + + + + + + Whether "Floor Number" is a default option for Map Headers + + + Enable 'Floor Number' + + + + + + + Whether the dimensions of the border can be changed. If not set, all borders are 2x2 + + + Enable Custom Border Size + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults + + + + + + + + + NoScrollComboBox + QComboBox +
noscrollcombobox.h
+
+ + NoScrollSpinBox + QSpinBox +
noscrollspinbox.h
+
+ + UIntSpinBox + QAbstractSpinBox +
uintspinbox.h
+
+
+ + + + +
diff --git a/forms/tileseteditor.ui b/forms/tileseteditor.ui index df04f720..6a2a4c69 100644 --- a/forms/tileseteditor.ui +++ b/forms/tileseteditor.ui @@ -169,7 +169,7 @@
- QLayout::SetFixedSize + QLayout::SetMinimumSize 0 @@ -186,8 +186,8 @@ - - 1 + + 0 0 @@ -201,7 +201,44 @@ false - + + + + Bottom/Top + + + + + + + + + + Encounter Type + + + + + + + Terrain Type + + + + + + + + + + Metatile Label (Optional) + + + + + + + <html><head/><body><p>Copies the full metatile label to the clipboard.</p></body></html> @@ -215,46 +252,29 @@ - + true - - + + - Metatile Label (Optional) + Layer Type - - + + - Bottom/Top + Metatile Behavior - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Encounter Type - - + + @@ -278,44 +298,17 @@ - - - - - - - Layer Type - - - - - - - - - - Metatile Behavior - - - - - - - - - - - - - Terrain Type - - -
+ + + 0 + 0 + + Tile Properties @@ -331,7 +324,14 @@ - + + + + 0 + 0 + + + @@ -364,7 +364,7 @@ - + @@ -377,7 +377,7 @@ - + @@ -402,6 +402,32 @@ + + + + Qt::Vertical + + + + 20 + 10 + + + + + + + + Qt::Vertical + + + + 20 + 10 + + + + @@ -410,6 +436,12 @@ + + + 0 + 0 + + true @@ -419,7 +451,7 @@ 0 0 411 - 247 + 272 diff --git a/include/config.h b/include/config.h index e99b535c..7f10ce91 100644 --- a/include/config.h +++ b/include/config.h @@ -10,8 +10,8 @@ #include // In both versions the default new map border is a generic tree -#define DEFAULT_BORDER_RSE (QList{468, 469, 476, 477}) -#define DEFAULT_BORDER_FRLG (QList{20, 21, 28, 29}) +#define DEFAULT_BORDER_RSE (QList{0x1D4, 0x1D5, 0x1DC, 0x1DD}) +#define DEFAULT_BORDER_FRLG (QList{0x14, 0x15, 0x1C, 0x1D}) #define CONFIG_BACKWARDS_COMPATABILITY @@ -26,6 +26,7 @@ class KeyValueConfigBase public: void save(); void load(); + void setSaveDisabled(bool disabled); virtual ~KeyValueConfigBase(); virtual void reset() = 0; protected: @@ -37,6 +38,8 @@ protected: bool getConfigBool(QString key, QString value); int getConfigInteger(QString key, QString value, int min, int max, int defaultValue); uint32_t getConfigUint32(QString key, QString value, uint32_t min, uint32_t max, uint32_t defaultValue); +private: + bool saveDisabled = false; }; class PorymapConfig: public KeyValueConfigBase @@ -70,6 +73,8 @@ public: void setTilesetEditorGeometry(QByteArray, QByteArray); void setPaletteEditorGeometry(QByteArray, QByteArray); void setRegionMapEditorGeometry(QByteArray, QByteArray); + void setProjectSettingsEditorGeometry(QByteArray, QByteArray); + void setCustomScriptsEditorGeometry(QByteArray, QByteArray); void setCollisionOpacity(int opacity); void setMetatilesZoom(int zoom); void setShowPlayerView(bool enabled); @@ -90,6 +95,8 @@ public: QMap getTilesetEditorGeometry(); QMap getPaletteEditorGeometry(); QMap getRegionMapEditorGeometry(); + QMap getProjectSettingsEditorGeometry(); + QMap getCustomScriptsEditorGeometry(); int getCollisionOpacity(); int getMetatilesZoom(); bool getShowPlayerView(); @@ -126,6 +133,10 @@ private: QByteArray paletteEditorState; QByteArray regionMapEditorGeometry; QByteArray regionMapEditorState; + QByteArray projectSettingsEditorGeometry; + QByteArray projectSettingsEditorState; + QByteArray customScriptsEditorGeometry; + QByteArray customScriptsEditorState; int collisionOpacity; int metatilesZoom; bool showPlayerView; @@ -203,20 +214,11 @@ public: } virtual void reset() override { this->baseGameVersion = BaseGameVersion::pokeemerald; - this->useCustomBorderSize = false; - this->enableEventWeatherTrigger = true; - this->enableEventSecretBase = true; - this->enableHiddenItemQuantity = false; - this->enableHiddenItemRequiresItemfinder = false; - this->enableHealLocationRespawnData = false; - this->enableEventCloneObject = false; - this->enableFloorNumber = false; - this->createMapTextFile = false; + // Reset non-version-specific settings this->usePoryScript = false; this->enableTripleLayerMetatiles = false; this->newMapMetatileId = 1; this->newMapElevation = 3; - this->newMapBorderMetatileIds = DEFAULT_BORDER_RSE; this->defaultPrimaryTileset = "gTileset_General"; this->prefabFilepath = QString(); this->prefabImportPrompted = false; @@ -225,9 +227,14 @@ public: this->filePaths.clear(); this->readKeys.clear(); } + static const QMap> defaultPaths; + static const QStringList versionStrings; + void reset(BaseGameVersion baseGameVersion); void setBaseGameVersion(BaseGameVersion baseGameVersion); BaseGameVersion getBaseGameVersion(); QString getBaseGameVersionString(); + QString getBaseGameVersionString(BaseGameVersion version); + BaseGameVersion stringToBaseGameVersion(QString string, bool * ok = nullptr); void setUsePoryScript(bool usePoryScript); bool getUsePoryScript(); void setProjectDir(QString projectDir); @@ -254,18 +261,22 @@ public: bool getTripleLayerMetatilesEnabled(); int getNumLayersInMetatile(); int getNumTilesInMetatile(); - void setNewMapMetatileId(int metatileId); - int getNewMapMetatileId(); + void setNewMapMetatileId(uint16_t metatileId); + uint16_t getNewMapMetatileId(); void setNewMapElevation(int elevation); int getNewMapElevation(); - void setNewMapBorderMetatileIds(QList metatileIds); - QList getNewMapBorderMetatileIds(); + void setNewMapBorderMetatileIds(QList metatileIds); + QList getNewMapBorderMetatileIds(); QString getDefaultPrimaryTileset(); QString getDefaultSecondaryTileset(); + void setDefaultPrimaryTileset(QString tilesetName); + void setDefaultSecondaryTileset(QString tilesetName); + void setFilePath(QString pathId, QString path); void setFilePath(ProjectFilePath pathId, QString path); - QString getFilePath(ProjectFilePath pathId); + QString getFilePath(QString defaultPath, bool customOnly = false); + QString getFilePath(ProjectFilePath pathId, bool customOnly = false); void setPrefabFilepath(QString filepath); - QString getPrefabFilepath(bool setIfEmpty); + QString getPrefabFilepath(); void setPrefabImportPrompted(bool prompted); bool getPrefabImportPrompted(); void setTilesetsHaveCallback(bool has); @@ -273,11 +284,17 @@ public: void setTilesetsHaveIsCompressed(bool has); bool getTilesetsHaveIsCompressed(); int getMetatileAttributesSize(); + void setMetatileAttributesSize(int size); uint32_t getMetatileBehaviorMask(); uint32_t getMetatileTerrainTypeMask(); uint32_t getMetatileEncounterTypeMask(); uint32_t getMetatileLayerTypeMask(); + void setMetatileBehaviorMask(uint32_t mask); + void setMetatileTerrainTypeMask(uint32_t mask); + void setMetatileEncounterTypeMask(uint32_t mask); + void setMetatileLayerTypeMask(uint32_t mask); bool getMapAllowFlagsEnabled(); + void setMapAllowFlagsEnabled(bool enabled); protected: virtual QString getConfigFilepath() override; virtual void parseConfigKeyValue(QString key, QString value) override; @@ -299,9 +316,9 @@ private: bool enableFloorNumber; bool createMapTextFile; bool enableTripleLayerMetatiles; - int newMapMetatileId; + uint16_t newMapMetatileId; int newMapElevation; - QList newMapBorderMetatileIds; + QList newMapBorderMetatileIds; QString defaultPrimaryTileset; QString defaultSecondaryTileset; QStringList readKeys; @@ -337,8 +354,11 @@ public: bool getEncounterJsonActive(); void setProjectDir(QString projectDir); QString getProjectDir(); - void setCustomScripts(QList scripts); - QList getCustomScripts(); + void setCustomScripts(QStringList scripts, QList enabled); + QStringList getCustomScriptPaths(); + QList getCustomScriptsEnabled(); + void parseCustomScripts(QString input); + QString outputCustomScripts(); protected: virtual QString getConfigFilepath() override; virtual void parseConfigKeyValue(QString key, QString value) override; @@ -352,7 +372,7 @@ private: QString projectDir; QString recentMap; bool useEncounterJson; - QList customScripts; + QMap customScripts; QStringList readKeys; }; diff --git a/include/core/metatile.h b/include/core/metatile.h index 49fb5c7b..ed1f84ee 100644 --- a/include/core/metatile.h +++ b/include/core/metatile.h @@ -93,6 +93,15 @@ public: static QPoint coordFromPixmapCoord(const QPointF &pixelCoord); static int getDefaultAttributesSize(BaseGameVersion version); static void setCustomLayout(Project*); + static QString getMetatileIdString(uint16_t metatileId) { + return "0x" + QString("%1").arg(metatileId, 3, 16, QChar('0')).toUpper(); + }; + static QString getMetatileIdStringList(const QList metatileIds) { + QStringList metatiles; + for (auto metatileId : metatileIds) + metatiles << Metatile::getMetatileIdString(metatileId); + return metatiles.join(","); + }; private: // Stores how each attribute should be laid out for all metatiles, according to the user's config diff --git a/include/core/tileset.h b/include/core/tileset.h index 3f50f41b..b120b2c5 100644 --- a/include/core/tileset.h +++ b/include/core/tileset.h @@ -38,6 +38,8 @@ public: QList> palettes; QList> palettePreviews; + bool hasUnsavedTilesImage; + static Tileset* getMetatileTileset(int, Tileset*, Tileset*); static Tileset* getTileTileset(int, Tileset*, Tileset*); static Metatile* getMetatile(int, Tileset*, Tileset*); diff --git a/include/editor.h b/include/editor.h index e28d9120..2282fba7 100644 --- a/include/editor.h +++ b/include/editor.h @@ -149,7 +149,7 @@ public: void shouldReselectEvents(); void scaleMapView(int); - void openInTextEditor(const QString &path, int lineNum = 0) const; + static void openInTextEditor(const QString &path, int lineNum = 0); bool eventLimitReached(Event::Type type); public slots: @@ -179,9 +179,9 @@ private: void updateEncounterFields(EncounterFields newFields); QString getMovementPermissionText(uint16_t collision, uint16_t elevation); QString getMetatileDisplayMessage(uint16_t metatileId); - bool startDetachedProcess(const QString &command, - const QString &workingDirectory = QString(), - qint64 *pid = nullptr) const; + static bool startDetachedProcess(const QString &command, + const QString &workingDirectory = QString(), + qint64 *pid = nullptr); private slots: void onMapStartPaint(QGraphicsSceneMouseEvent *event, MapPixmapItem *item); diff --git a/include/lib/fex/lexer.h b/include/lib/fex/lexer.h index b888e86b..9b22976d 100644 --- a/include/lib/fex/lexer.h +++ b/include/lib/fex/lexer.h @@ -1,6 +1,7 @@ #ifndef INCLUDE_CORE_LEXER_H #define INCLUDE_CORE_LEXER_H +#include #include #include diff --git a/include/mainwindow.h b/include/mainwindow.h index 87ea30aa..0f1eeac7 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -25,6 +25,8 @@ #include "newtilesetdialog.h" #include "shortcutseditor.h" #include "preferenceeditor.h" +#include "projectsettingseditor.h" +#include "customscriptseditor.h" @@ -197,11 +199,7 @@ private slots: void on_checkBox_AllowBiking_stateChanged(int selected); void on_checkBox_AllowEscaping_stateChanged(int selected); void on_spinBox_FloorNumber_valueChanged(int offset); - void on_actionUse_Encounter_Json_triggered(bool checked); - void on_actionMonitor_Project_Files_triggered(bool checked); - void on_actionUse_Poryscript_triggered(bool checked); - void on_actionOpen_Recent_Project_On_Launch_triggered(bool checked); - void on_actionEdit_Shortcuts_triggered(); + void on_actionShortcuts_triggered(); void on_actionZoom_In_triggered(); void on_actionZoom_Out_triggered(); @@ -284,8 +282,11 @@ private slots: void on_pushButton_CreatePrefab_clicked(); void on_actionRegion_Map_Editor_triggered(); - void on_actionEdit_Preferences_triggered(); + void on_actionPreferences_triggered(); void togglePreferenceSpecificUi(); + void on_actionProject_Settings_triggered(); + void on_actionCustom_Scripts_triggered(); + void reloadScriptEngine(); public: Ui::MainWindow *ui; @@ -299,6 +300,8 @@ private: QPointer mapImageExporter = nullptr; QPointer newMapPrompt = nullptr; QPointer preferenceEditor = nullptr; + QPointer projectSettingsEditor = nullptr; + QPointer customScriptsEditor = nullptr; FilterChildrenProxyModel *mapListProxyModel; QStandardItemModel *mapListModel; QList *mapGroupItemsList; @@ -333,7 +336,7 @@ private: MapSortOrder mapSortOrder; - bool needsFullRedraw = false; + bool tilesetNeedsRedraw = false; bool setMap(QString, bool scrollTreeView = false); void redrawMapScene(); @@ -342,6 +345,7 @@ private: bool loadProjectCombos(); bool populateMapList(); void sortMapList(); + void openSubWindow(QWidget * window); QString getExistingDirectory(QString); bool openProject(QString dir); QString getDefaultMap(); @@ -381,6 +385,7 @@ private: void initTilesetEditor(); bool initRegionMapEditor(bool silent = false); void initShortcutsEditor(); + void initCustomScriptsEditor(); void connectSubEditorsToShortcutsEditor(); bool isProjectOpen(); diff --git a/include/ui/customscriptseditor.h b/include/ui/customscriptseditor.h new file mode 100644 index 00000000..d54a7cc3 --- /dev/null +++ b/include/ui/customscriptseditor.h @@ -0,0 +1,61 @@ +#ifndef CUSTOMSCRIPTSEDITOR_H +#define CUSTOMSCRIPTSEDITOR_H + +#include +#include +#include +#include + +#include "customscriptslistitem.h" + +namespace Ui { +class CustomScriptsEditor; +} + + +class CustomScriptsEditor : public QMainWindow +{ + Q_OBJECT + +public: + explicit CustomScriptsEditor(QWidget *parent = nullptr); + ~CustomScriptsEditor(); + +signals: + void reloadScriptEngine(); + +public slots: + void applyUserShortcuts(); + +private: + Ui::CustomScriptsEditor *ui; + + bool hasUnsavedChanges = false; + QString importDir; + const QString baseDir; + + void displayScript(const QString &filepath, bool enabled); + QString chooseScript(QString dir); + void removeScript(QListWidgetItem * item); + void replaceScript(QListWidgetItem * item); + void openScript(QListWidgetItem * item); + QString getScriptFilepath(QListWidgetItem * item, bool absolutePath = true) const; + void setScriptFilepath(QListWidgetItem * item, QString filepath) const; + bool getScriptEnabled(QListWidgetItem * item) const; + void markEdited(); + int prompt(const QString &text, QMessageBox::StandardButton defaultButton); + void save(); + void closeEvent(QCloseEvent*); + void restoreWindowState(); + void initShortcuts(); + QObjectList shortcutableObjects() const; + +private slots: + void dialogButtonClicked(QAbstractButton *button); + void addNewScript(); + void reloadScripts(); + void removeSelectedScripts(); + void openSelectedScripts(); +}; + +#endif // CUSTOMSCRIPTSEDITOR_H diff --git a/include/ui/customscriptslistitem.h b/include/ui/customscriptslistitem.h new file mode 100644 index 00000000..d166db8a --- /dev/null +++ b/include/ui/customscriptslistitem.h @@ -0,0 +1,22 @@ +#ifndef CUSTOMSCRIPTSLISTITEM_H +#define CUSTOMSCRIPTSLISTITEM_H + +#include + +namespace Ui { +class CustomScriptsListItem; +} + +class CustomScriptsListItem : public QFrame +{ + Q_OBJECT + +public: + explicit CustomScriptsListItem(QWidget *parent = nullptr); + ~CustomScriptsListItem(); + +public: + Ui::CustomScriptsListItem *ui; +}; + +#endif // CUSTOMSCRIPTSLISTITEM_H diff --git a/include/ui/imageproviders.h b/include/ui/imageproviders.h index db04bdb2..fefa5546 100644 --- a/include/ui/imageproviders.h +++ b/include/ui/imageproviders.h @@ -13,6 +13,7 @@ QImage getMetatileImage(Metatile*, Tileset*, Tileset*, QList, QList, QImage getTileImage(uint16_t, Tileset*, Tileset*); QImage getPalettedTileImage(uint16_t, Tileset*, Tileset*, int, bool useTruePalettes = false); QImage getGreyscaleTileImage(uint16_t tile, Tileset *primaryTileset, Tileset *secondaryTileset); +void flattenTo4bppImage(QImage * image); static QList greyscalePalette({ qRgb(0, 0, 0), diff --git a/include/ui/noscrollcombobox.h b/include/ui/noscrollcombobox.h index 5f4d73e7..98cacf70 100644 --- a/include/ui/noscrollcombobox.h +++ b/include/ui/noscrollcombobox.h @@ -11,8 +11,10 @@ public: explicit NoScrollComboBox(QWidget *parent = nullptr); void wheelEvent(QWheelEvent *event); void setTextItem(const QString &text); + void setNumberItem(int value); private: + void setItem(int index, const QString &text); }; #endif // NOSCROLLCOMBOBOX_H diff --git a/include/ui/prefab.h b/include/ui/prefab.h index 00b2d714..7bd9e0b2 100644 --- a/include/ui/prefab.h +++ b/include/ui/prefab.h @@ -23,7 +23,7 @@ public: void initPrefabUI(MetatileSelector *selector, QWidget *prefabWidget, QLabel *emptyPrefabLabel, Map *map); void addPrefab(MetatileSelection selection, Map *map, QString name); void updatePrefabUi(Map *map); - void tryImportDefaultPrefabs(Map *map); + bool tryImportDefaultPrefabs(QWidget * parent, BaseGameVersion version, QString filepath = ""); private: MetatileSelector *selector; diff --git a/include/ui/preferenceeditor.h b/include/ui/preferenceeditor.h index c16716d9..c59641c2 100644 --- a/include/ui/preferenceeditor.h +++ b/include/ui/preferenceeditor.h @@ -18,6 +18,7 @@ class PreferenceEditor : public QMainWindow public: explicit PreferenceEditor(QWidget *parent = nullptr); ~PreferenceEditor(); + void updateFields(); signals: void preferencesSaved(); @@ -27,9 +28,10 @@ private: Ui::PreferenceEditor *ui; NoScrollComboBox *themeSelector; - void populateFields(); + void initFields(); void saveFields(); + private slots: void dialogButtonClicked(QAbstractButton *button); }; diff --git a/include/ui/projectsettingseditor.h b/include/ui/projectsettingseditor.h new file mode 100644 index 00000000..3bd4d921 --- /dev/null +++ b/include/ui/projectsettingseditor.h @@ -0,0 +1,60 @@ +#ifndef PROJECTSETTINGSEDITOR_H +#define PROJECTSETTINGSEDITOR_H + +#include +#include "project.h" + +class NoScrollComboBox; +class QAbstractButton; + + +namespace Ui { +class ProjectSettingsEditor; +} + +class ProjectSettingsEditor : public QMainWindow +{ + Q_OBJECT + +public: + explicit ProjectSettingsEditor(QWidget *parent = nullptr, Project *project = nullptr); + ~ProjectSettingsEditor(); + +signals: + void reloadProject(); + +private: + Ui::ProjectSettingsEditor *ui; + Project *project; + + bool hasUnsavedChanges = false; + bool projectNeedsReload = false; + bool refreshing = false; + const QString baseDir; + + void initUi(); + void connectSignals(); + void restoreWindowState(); + void save(); + void refresh(); + void closeEvent(QCloseEvent*); + int prompt(const QString &, QMessageBox::StandardButton = QMessageBox::Yes); + bool promptSaveChanges(); + bool promptRestoreDefaults(); + + void setBorderMetatilesUi(bool customSize); + void setBorderMetatileIds(bool customSize, QList metatileIds); + QList getBorderMetatileIds(bool customSize); + + void createProjectPathsTable(); + QString chooseProjectFile(const QString &defaultFilepath); + +private slots: + void dialogButtonClicked(QAbstractButton *button); + void choosePrefabsFileClicked(bool); + void importDefaultPrefabsClicked(bool); + void updateAttributeLimits(const QString &attrSize); + void markEdited(); +}; + +#endif // PROJECTSETTINGSEDITOR_H diff --git a/include/ui/tilemaptileselector.h b/include/ui/tilemaptileselector.h index 9d419d63..867f6302 100644 --- a/include/ui/tilemaptileselector.h +++ b/include/ui/tilemaptileselector.h @@ -4,6 +4,7 @@ #include "selectablepixmapitem.h" #include "paletteutil.h" +#include "imageproviders.h" #include using std::shared_ptr; @@ -127,10 +128,7 @@ public: this->tileset = QImage(tilesetFilepath); this->format = format; if (this->tileset.format() == QImage::Format::Format_Indexed8 && this->format == TilemapFormat::BPP_4) { - // Squash pixel data to fit 4BPP. Allows project repo to use 8BPP images for 4BPP tilemaps - uchar * pixel = this->tileset.bits(); - for (int i = 0; i < this->tileset.sizeInBytes(); i++, pixel++) - *pixel %= 16; + flattenTo4bppImage(&this->tileset); } bool err; if (!palFilepath.isEmpty()) { diff --git a/include/ui/tileseteditor.h b/include/ui/tileseteditor.h index b03964b5..5a46075c 100644 --- a/include/ui/tileseteditor.h +++ b/include/ui/tileseteditor.h @@ -52,11 +52,11 @@ public: public slots: void applyUserShortcuts(); + void onSelectedMetatileChanged(uint16_t); private slots: void onHoveredMetatileChanged(uint16_t); void onHoveredMetatileCleared(); - void onSelectedMetatileChanged(uint16_t); void onHoveredTileChanged(uint16_t); void onHoveredTileCleared(); void onSelectedTilesChanged(); @@ -138,7 +138,6 @@ private: void copyMetatile(bool cut); void pasteMetatile(const Metatile * toPaste, QString label); bool replaceMetatile(uint16_t metatileId, const Metatile * src, QString label); - void setComboValue(QComboBox * combo, int value); void commitMetatileChange(Metatile * prevMetatile); void commitMetatileAndLabelChange(Metatile * prevMetatile, QString prevLabel); @@ -164,6 +163,7 @@ private: QGraphicsScene *selectedTileScene = nullptr; QGraphicsPixmapItem *selectedTilePixmapItem = nullptr; QGraphicsScene *metatileLayersScene = nullptr; + bool lockSelection = false; signals: void tilesetsSaved(QString, QString); diff --git a/include/ui/uintspinbox.h b/include/ui/uintspinbox.h new file mode 100644 index 00000000..dc08c9eb --- /dev/null +++ b/include/ui/uintspinbox.h @@ -0,0 +1,67 @@ +#ifndef UINTSPINBOX_H +#define UINTSPINBOX_H + +#include +#include + +/* + QSpinBox stores minimum/maximum/value as ints. This class is a version of QAbstractSpinBox for values above 0x7FFFFFFF. + It minimally implements some QSpinBox-specific features like prefixes to simplify support for hex value input. + It also implements the scroll focus requirements of NoScrollSpinBox. +*/ + +class UIntSpinBox : public QAbstractSpinBox +{ + Q_OBJECT + +public: + explicit UIntSpinBox(QWidget *parent = nullptr); + ~UIntSpinBox() {}; + + uint32_t value() const { return m_value; } + uint32_t minimum() const { return m_minimum; } + uint32_t maximum() const { return m_maximum; } + QString prefix() const { return m_prefix; } + int displayIntegerBase() const { return m_displayIntegerBase; } + bool hasPadding() const { return m_hasPadding; } + + void setMinimum(uint32_t min); + void setMaximum(uint32_t max); + void setRange(uint32_t min, uint32_t max); + void setPrefix(const QString &prefix); + void setDisplayIntegerBase(int base); + void setHasPadding(bool enabled); + +private: + uint32_t m_minimum; + uint32_t m_maximum; + uint32_t m_value; + QString m_prefix; + int m_displayIntegerBase; + bool m_hasPadding; + int m_numChars; + + void updateEdit(); + QString stripped(QString input) const; + + virtual void stepBy(int steps) override; + virtual void wheelEvent(QWheelEvent *event) override; + virtual void focusOutEvent(QFocusEvent *event) override; + +protected: + virtual uint32_t valueFromText(const QString &text) const; + virtual QString textFromValue(uint32_t val) const; + virtual QValidator::State validate(QString &input, int &pos) const override; + virtual QAbstractSpinBox::StepEnabled stepEnabled() const override; + + +public slots: + void setValue(uint32_t val); + void onEditFinished(); + +signals: + void valueChanged(uint32_t val); + void textChanged(const QString &text); +}; + +#endif // UINTSPINBOX_H diff --git a/porymap.pro b/porymap.pro index 8d2d0ba4..63b97c1a 100644 --- a/porymap.pro +++ b/porymap.pro @@ -42,6 +42,8 @@ SOURCES += src/core/block.cpp \ src/scriptapi/apiutility.cpp \ src/scriptapi/scripting.cpp \ src/ui/aboutporymap.cpp \ + src/ui/customscriptseditor.cpp \ + src/ui/customscriptslistitem.cpp \ src/ui/draggablepixmapitem.cpp \ src/ui/bordermetatilespixmapitem.cpp \ src/ui/collisionpixmapitem.cpp \ @@ -49,6 +51,7 @@ SOURCES += src/core/block.cpp \ src/ui/currentselectedmetatilespixmapitem.cpp \ src/ui/overlay.cpp \ src/ui/prefab.cpp \ + src/ui/projectsettingseditor.cpp \ src/ui/regionmaplayoutpixmapitem.cpp \ src/ui/regionmapentriespixmapitem.cpp \ src/ui/cursortilerect.cpp \ @@ -97,7 +100,8 @@ SOURCES += src/core/block.cpp \ src/mainwindow.cpp \ src/project.cpp \ src/settings.cpp \ - src/log.cpp + src/log.cpp \ + src/ui/uintspinbox.cpp HEADERS += include/core/block.h \ include/core/blockdata.h \ @@ -128,12 +132,15 @@ HEADERS += include/core/block.h \ include/lib/orderedmap.h \ include/lib/orderedjson.h \ include/ui/aboutporymap.h \ + include/ui/customscriptseditor.h \ + include/ui/customscriptslistitem.h \ include/ui/draggablepixmapitem.h \ include/ui/bordermetatilespixmapitem.h \ include/ui/collisionpixmapitem.h \ include/ui/connectionpixmapitem.h \ include/ui/currentselectedmetatilespixmapitem.h \ include/ui/prefabframe.h \ + include/ui/projectsettingseditor.h \ include/ui/regionmaplayoutpixmapitem.h \ include/ui/regionmapentriespixmapitem.h \ include/ui/cursortilerect.h \ @@ -186,7 +193,8 @@ HEADERS += include/core/block.h \ include/scripting.h \ include/scriptutility.h \ include/settings.h \ - include/log.h + include/log.h \ + include/ui/uintspinbox.h FORMS += forms/mainwindow.ui \ forms/prefabcreationdialog.ui \ @@ -201,7 +209,10 @@ FORMS += forms/mainwindow.ui \ forms/shortcutseditor.ui \ forms/preferenceeditor.ui \ forms/regionmappropertiesdialog.ui \ - forms/colorpicker.ui + forms/colorpicker.ui \ + forms/projectsettingseditor.ui \ + forms/customscriptseditor.ui \ + forms/customscriptslistitem.ui RESOURCES += \ resources/images.qrc \ diff --git a/resources/icons/edit_document.ico b/resources/icons/edit_document.ico new file mode 100755 index 00000000..cd4bb026 Binary files /dev/null and b/resources/icons/edit_document.ico differ diff --git a/resources/icons/refresh.ico b/resources/icons/refresh.ico new file mode 100755 index 00000000..fdf20e26 Binary files /dev/null and b/resources/icons/refresh.ico differ diff --git a/resources/images.qrc b/resources/images.qrc index 4c739582..2399cbe9 100644 --- a/resources/images.qrc +++ b/resources/images.qrc @@ -4,6 +4,7 @@ icons/collapse_all.ico icons/cursor.ico icons/delete.ico + icons/edit_document.ico icons/expand_all.ico icons/fill_color_cursor.ico icons/fill_color.ico @@ -24,6 +25,7 @@ icons/porymap-icon-1.ico icons/porymap-icon-2.ico icons/porymap.icns + icons/refresh.ico icons/shift_cursor.ico icons/shift.ico icons/sort_alphabet.ico diff --git a/src/config.cpp b/src/config.cpp index 7ece2555..681dc3f9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -16,7 +16,7 @@ #include #include -const QMap> defaultPaths = { +const QMap> ProjectConfig::defaultPaths = { {ProjectFilePath::data_map_folders, { "data_map_folders", "data/maps/"}}, {ProjectFilePath::data_scripts_folders, { "data_scripts_folders", "data/scripts/"}}, {ProjectFilePath::data_layouts_folders, { "data_layouts_folders", "data/layouts/"}}, @@ -64,7 +64,7 @@ const QMap> defaultPaths = { }; ProjectFilePath reverseDefaultPaths(QString str) { - for (auto it = defaultPaths.constKeyValueBegin(); it != defaultPaths.constKeyValueEnd(); ++it) { + for (auto it = ProjectConfig::defaultPaths.constKeyValueBegin(); it != ProjectConfig::defaultPaths.constKeyValueEnd(); ++it) { if ((*it).second.first == str) return (*it).first; } return static_cast(-1); @@ -119,6 +119,9 @@ void KeyValueConfigBase::load() { } void KeyValueConfigBase::save() { + if (this->saveDisabled) + return; + QString text = ""; QMap map = this->getKeyValueMap(); for (QMap::iterator it = map.begin(); it != map.end(); it++) { @@ -163,6 +166,11 @@ uint32_t KeyValueConfigBase::getConfigUint32(QString key, QString value, uint32_ return qMin(max, qMax(min, result)); } +// For temporarily disabling saving during frequent config changes. +void KeyValueConfigBase::setSaveDisabled(bool disabled) { + this->saveDisabled = disabled; +} + const QMap mapSortOrderMap = { {MapSortOrder::Group, "group"}, {MapSortOrder::Layout, "layout"}, @@ -226,6 +234,14 @@ void PorymapConfig::parseConfigKeyValue(QString key, QString value) { this->regionMapEditorGeometry = bytesFromString(value); } else if (key == "region_map_editor_state") { this->regionMapEditorState = bytesFromString(value); + } else if (key == "project_settings_editor_geometry") { + this->projectSettingsEditorGeometry = bytesFromString(value); + } else if (key == "project_settings_editor_state") { + this->projectSettingsEditorState = bytesFromString(value); + } else if (key == "custom_scripts_editor_geometry") { + this->customScriptsEditorGeometry = bytesFromString(value); + } else if (key == "custom_scripts_editor_state") { + this->customScriptsEditorState = bytesFromString(value); } else if (key == "metatiles_zoom") { this->metatilesZoom = getConfigInteger(key, value, 10, 100, 30); } else if (key == "show_player_view") { @@ -272,6 +288,10 @@ QMap PorymapConfig::getKeyValueMap() { map.insert("palette_editor_state", stringFromByteArray(this->paletteEditorState)); map.insert("region_map_editor_geometry", stringFromByteArray(this->regionMapEditorGeometry)); map.insert("region_map_editor_state", stringFromByteArray(this->regionMapEditorState)); + map.insert("project_settings_editor_geometry", stringFromByteArray(this->projectSettingsEditorGeometry)); + map.insert("project_settings_editor_state", stringFromByteArray(this->projectSettingsEditorState)); + map.insert("custom_scripts_editor_geometry", stringFromByteArray(this->customScriptsEditorGeometry)); + map.insert("custom_scripts_editor_state", stringFromByteArray(this->customScriptsEditorState)); map.insert("collision_opacity", QString("%1").arg(this->collisionOpacity)); map.insert("metatiles_zoom", QString("%1").arg(this->metatilesZoom)); map.insert("show_player_view", this->showPlayerView ? "1" : "0"); @@ -362,6 +382,18 @@ void PorymapConfig::setRegionMapEditorGeometry(QByteArray regionMapEditorGeometr this->save(); } +void PorymapConfig::setProjectSettingsEditorGeometry(QByteArray projectSettingsEditorGeometry_, QByteArray projectSettingsEditorState_) { + this->projectSettingsEditorGeometry = projectSettingsEditorGeometry_; + this->projectSettingsEditorState = projectSettingsEditorState_; + this->save(); +} + +void PorymapConfig::setCustomScriptsEditorGeometry(QByteArray customScriptsEditorGeometry_, QByteArray customScriptsEditorState_) { + this->customScriptsEditorGeometry = customScriptsEditorGeometry_; + this->customScriptsEditorState = customScriptsEditorState_; + this->save(); +} + void PorymapConfig::setCollisionOpacity(int opacity) { this->collisionOpacity = opacity; // don't auto-save here because this can be called very frequently. @@ -465,6 +497,24 @@ QMap PorymapConfig::getRegionMapEditorGeometry() { return geometry; } +QMap PorymapConfig::getProjectSettingsEditorGeometry() { + QMap geometry; + + geometry.insert("project_settings_editor_geometry", this->projectSettingsEditorGeometry); + geometry.insert("project_settings_editor_state", this->projectSettingsEditorState); + + return geometry; +} + +QMap PorymapConfig::getCustomScriptsEditorGeometry() { + QMap geometry; + + geometry.insert("custom_scripts_editor_geometry", this->customScriptsEditorGeometry); + geometry.insert("custom_scripts_editor_state", this->customScriptsEditorState); + + return geometry; +} + int PorymapConfig::getCollisionOpacity() { return this->collisionOpacity; } @@ -513,18 +563,34 @@ int PorymapConfig::getPaletteEditorBitDepth() { return this->paletteEditorBitDepth; } +const QStringList ProjectConfig::versionStrings = { + "pokeruby", + "pokefirered", + "pokeemerald", +}; + const QMap baseGameVersionMap = { - {BaseGameVersion::pokeruby, "pokeruby"}, - {BaseGameVersion::pokefirered, "pokefirered"}, - {BaseGameVersion::pokeemerald, "pokeemerald"}, + {BaseGameVersion::pokeruby, ProjectConfig::versionStrings[0]}, + {BaseGameVersion::pokefirered, ProjectConfig::versionStrings[1]}, + {BaseGameVersion::pokeemerald, ProjectConfig::versionStrings[2]}, }; const QMap baseGameVersionReverseMap = { - {"pokeruby", BaseGameVersion::pokeruby}, - {"pokefirered", BaseGameVersion::pokefirered}, - {"pokeemerald", BaseGameVersion::pokeemerald}, + {ProjectConfig::versionStrings[0], BaseGameVersion::pokeruby}, + {ProjectConfig::versionStrings[1], BaseGameVersion::pokefirered}, + {ProjectConfig::versionStrings[2], BaseGameVersion::pokeemerald}, }; +BaseGameVersion ProjectConfig::stringToBaseGameVersion(QString string, bool * ok) { + if (baseGameVersionReverseMap.contains(string)) { + if (ok) *ok = true; + return baseGameVersionReverseMap.value(string); + } else { + if (ok) *ok = false; + return BaseGameVersion::pokeemerald; + } +} + ProjectConfig projectConfig; QString ProjectConfig::getConfigFilepath() { @@ -534,13 +600,10 @@ QString ProjectConfig::getConfigFilepath() { void ProjectConfig::parseConfigKeyValue(QString key, QString value) { if (key == "base_game_version") { - QString baseGameVersion = value.toLower(); - if (baseGameVersionReverseMap.contains(baseGameVersion)) { - this->baseGameVersion = baseGameVersionReverseMap.value(baseGameVersion); - } else { - this->baseGameVersion = BaseGameVersion::pokeemerald; + bool ok; + this->baseGameVersion = this->stringToBaseGameVersion(value.toLower(), &ok); + if (!ok) logWarn(QString("Invalid config value for base_game_version: '%1'. Must be 'pokeruby', 'pokefirered' or 'pokeemerald'.").arg(value)); - } } else if (key == "use_poryscript") { this->usePoryScript = getConfigBool(key, value); } else if (key == "use_custom_border_size") { @@ -565,23 +628,18 @@ void ProjectConfig::parseConfigKeyValue(QString key, QString value) { } else if (key == "enable_triple_layer_metatiles") { this->enableTripleLayerMetatiles = getConfigBool(key, value); } else if (key == "new_map_metatile") { - this->newMapMetatileId = getConfigInteger(key, value, 0, 1023, 0); + this->newMapMetatileId = getConfigUint32(key, value, 0, 1023, 0); } else if (key == "new_map_elevation") { this->newMapElevation = getConfigInteger(key, value, 0, 15, 3); } else if (key == "new_map_border_metatiles") { this->newMapBorderMetatileIds.clear(); QList metatileIds = value.split(","); - const int maxSize = DEFAULT_BORDER_WIDTH * DEFAULT_BORDER_HEIGHT; - const int size = qMin(metatileIds.size(), maxSize); - int i; - for (i = 0; i < size; i++) { - int metatileId = getConfigInteger(key, metatileIds.at(i), 0, 1023, 0); + for (int i = 0; i < metatileIds.size(); i++) { + // TODO: The max of 1023 here should eventually reflect Project::num_metatiles_total-1, + // but the config is parsed well before that constant is. + int metatileId = getConfigUint32(key, metatileIds.at(i), 0, 1023, 0); this->newMapBorderMetatileIds.append(metatileId); } - for (; i < maxSize; i++) { - // Set any metatiles not provided to 0 - this->newMapBorderMetatileIds.append(0); - } } else if (key == "default_primary_tileset") { this->defaultPrimaryTileset = value; } else if (key == "default_secondary_tileset") { @@ -609,14 +667,7 @@ void ProjectConfig::parseConfigKeyValue(QString key, QString value) { } else if (key == "use_encounter_json") { userConfig.useEncounterJson = getConfigBool(key, value); } else if (key == "custom_scripts") { - userConfig.customScripts.clear(); - QList paths = value.split(","); - paths.removeDuplicates(); - for (QString script : paths) { - if (!script.isEmpty()) { - userConfig.customScripts.append(script); - } - } + userConfig.parseCustomScripts(value); #endif } else if (key.startsWith("path/")) { auto k = reverseDefaultPaths(key.mid(5)); @@ -639,6 +690,13 @@ void ProjectConfig::parseConfigKeyValue(QString key, QString value) { readKeys.append(key); } +// Restore config to version-specific defaults +void::ProjectConfig::reset(BaseGameVersion baseGameVersion) { + this->reset(); + this->baseGameVersion = baseGameVersion; + this->setUnreadKeys(); +} + void ProjectConfig::setUnreadKeys() { // Set game-version specific defaults for any config field that wasn't read bool isPokefirered = this->baseGameVersion == BaseGameVersion::pokefirered; @@ -657,7 +715,7 @@ void ProjectConfig::setUnreadKeys() { if (!readKeys.contains("metatile_behavior_mask")) this->metatileBehaviorMask = Metatile::getBehaviorMask(this->baseGameVersion); if (!readKeys.contains("metatile_terrain_type_mask")) this->metatileTerrainTypeMask = Metatile::getTerrainTypeMask(this->baseGameVersion); if (!readKeys.contains("metatile_encounter_type_mask")) this->metatileEncounterTypeMask = Metatile::getEncounterTypeMask(this->baseGameVersion); - if (!readKeys.contains("metatile_layer_type_mask")) this-> metatileLayerTypeMask = Metatile::getLayerTypeMask(this->baseGameVersion); + if (!readKeys.contains("metatile_layer_type_mask")) this->metatileLayerTypeMask = Metatile::getLayerTypeMask(this->baseGameVersion); if (!readKeys.contains("enable_map_allow_flags")) this->enableMapAllowFlags = (this->baseGameVersion != BaseGameVersion::pokeruby); } @@ -675,12 +733,9 @@ QMap ProjectConfig::getKeyValueMap() { map.insert("enable_floor_number", QString::number(this->enableFloorNumber)); map.insert("create_map_text_file", QString::number(this->createMapTextFile)); map.insert("enable_triple_layer_metatiles", QString::number(this->enableTripleLayerMetatiles)); - map.insert("new_map_metatile", QString::number(this->newMapMetatileId)); + map.insert("new_map_metatile", Metatile::getMetatileIdString(this->newMapMetatileId)); map.insert("new_map_elevation", QString::number(this->newMapElevation)); - QStringList metatiles; - for (auto metatile : this->newMapBorderMetatileIds) - metatiles << QString::number(metatile); - map.insert("new_map_border_metatiles", metatiles.join(",")); + map.insert("new_map_border_metatiles", Metatile::getMetatileIdStringList(this->newMapBorderMetatileIds)); map.insert("default_primary_tileset", this->defaultPrimaryTileset); map.insert("default_secondary_tileset", this->defaultSecondaryTileset); map.insert("prefabs_filepath", this->prefabFilepath); @@ -738,17 +793,40 @@ QString ProjectConfig::getProjectDir() { void ProjectConfig::setFilePath(ProjectFilePath pathId, QString path) { if (!defaultPaths.contains(pathId)) return; - this->filePaths[pathId] = path; + if (path.isEmpty()) { + this->filePaths.remove(pathId); + } else { + this->filePaths[pathId] = path; + } } -QString ProjectConfig::getFilePath(ProjectFilePath pathId) { - if (this->filePaths.contains(pathId)) { - return this->filePaths[pathId]; - } else if (defaultPaths.contains(pathId)) { - return defaultPaths[pathId].second; - } else { - return QString(); +void ProjectConfig::setFilePath(QString defaultPath, QString newPath) { + this->setFilePath(reverseDefaultPaths(defaultPath), newPath); +} + +QString ProjectConfig::getFilePath(ProjectFilePath pathId, bool customOnly) { + const QString customPath = this->filePaths.value(pathId); + + // When reading custom filepaths for the settings editor we don't care + // about the default path or whether the custom path exists. + if (customOnly) + return customPath; + + if (!customPath.isEmpty()) { + // A custom filepath has been specified. If the file/folder exists, use that. + const QString absCustomPath = this->projectDir + QDir::separator() + customPath; + if (QFileInfo::exists(absCustomPath)) { + return customPath; + } else { + logError(QString("Custom project filepath '%1' not found. Using default.").arg(absCustomPath)); + } } + return defaultPaths.contains(pathId) ? defaultPaths[pathId].second : QString(); + +} + +QString ProjectConfig::getFilePath(QString defaultPath, bool customOnly) { + return this->getFilePath(reverseDefaultPaths(defaultPath), customOnly); } void ProjectConfig::setBaseGameVersion(BaseGameVersion baseGameVersion) { @@ -760,8 +838,15 @@ BaseGameVersion ProjectConfig::getBaseGameVersion() { return this->baseGameVersion; } +QString ProjectConfig::getBaseGameVersionString(BaseGameVersion version) { + if (!baseGameVersionMap.contains(version)) { + version = BaseGameVersion::pokeemerald; + } + return baseGameVersionMap.value(version); +} + QString ProjectConfig::getBaseGameVersionString() { - return baseGameVersionMap.value(this->baseGameVersion); + return this->getBaseGameVersionString(this->baseGameVersion); } void ProjectConfig::setUsePoryScript(bool usePoryScript) { @@ -871,12 +956,12 @@ int ProjectConfig::getNumTilesInMetatile() { return this->enableTripleLayerMetatiles ? 12 : 8; } -void ProjectConfig::setNewMapMetatileId(int metatileId) { +void ProjectConfig::setNewMapMetatileId(uint16_t metatileId) { this->newMapMetatileId = metatileId; this->save(); } -int ProjectConfig::getNewMapMetatileId() { +uint16_t ProjectConfig::getNewMapMetatileId() { return this->newMapMetatileId; } @@ -889,12 +974,12 @@ int ProjectConfig::getNewMapElevation() { return this->newMapElevation; } -void ProjectConfig::setNewMapBorderMetatileIds(QList metatileIds) { +void ProjectConfig::setNewMapBorderMetatileIds(QList metatileIds) { this->newMapBorderMetatileIds = metatileIds; this->save(); } -QList ProjectConfig::getNewMapBorderMetatileIds() { +QList ProjectConfig::getNewMapBorderMetatileIds() { return this->newMapBorderMetatileIds; } @@ -906,15 +991,22 @@ QString ProjectConfig::getDefaultSecondaryTileset() { return this->defaultSecondaryTileset; } +void ProjectConfig::setDefaultPrimaryTileset(QString tilesetName) { + this->defaultPrimaryTileset = tilesetName; + this->save(); +} + +void ProjectConfig::setDefaultSecondaryTileset(QString tilesetName) { + this->defaultSecondaryTileset = tilesetName; + this->save(); +} + void ProjectConfig::setPrefabFilepath(QString filepath) { this->prefabFilepath = filepath; this->save(); } -QString ProjectConfig::getPrefabFilepath(bool setIfEmpty) { - if (setIfEmpty && this->prefabFilepath.isEmpty()) { - this->setPrefabFilepath("prefabs.json"); - } +QString ProjectConfig::getPrefabFilepath() { return this->prefabFilepath; } @@ -949,6 +1041,11 @@ int ProjectConfig::getMetatileAttributesSize() { return this->metatileAttributesSize; } +void ProjectConfig::setMetatileAttributesSize(int size) { + this->metatileAttributesSize = size; + this->save(); +} + uint32_t ProjectConfig::getMetatileBehaviorMask() { return this->metatileBehaviorMask; } @@ -965,10 +1062,35 @@ uint32_t ProjectConfig::getMetatileLayerTypeMask() { return this->metatileLayerTypeMask; } +void ProjectConfig::setMetatileBehaviorMask(uint32_t mask) { + this->metatileBehaviorMask = mask; + this->save(); +} + +void ProjectConfig::setMetatileTerrainTypeMask(uint32_t mask) { + this->metatileTerrainTypeMask = mask; + this->save(); +} + +void ProjectConfig::setMetatileEncounterTypeMask(uint32_t mask) { + this->metatileEncounterTypeMask = mask; + this->save(); +} + +void ProjectConfig::setMetatileLayerTypeMask(uint32_t mask) { + this->metatileLayerTypeMask = mask; + this->save(); +} + bool ProjectConfig::getMapAllowFlagsEnabled() { return this->enableMapAllowFlags; } +void ProjectConfig::setMapAllowFlagsEnabled(bool enabled) { + this->enableMapAllowFlags = enabled; + this->save(); +} + UserConfig userConfig; @@ -983,14 +1105,7 @@ void UserConfig::parseConfigKeyValue(QString key, QString value) { } else if (key == "use_encounter_json") { this->useEncounterJson = getConfigBool(key, value); } else if (key == "custom_scripts") { - this->customScripts.clear(); - QList paths = value.split(","); - paths.removeDuplicates(); - for (QString script : paths) { - if (!script.isEmpty()) { - this->customScripts.append(script); - } - } + this->parseCustomScripts(value); } else { logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key)); } @@ -1004,7 +1119,7 @@ QMap UserConfig::getKeyValueMap() { QMap map; map.insert("recent_map", this->recentMap); map.insert("use_encounter_json", QString::number(this->useEncounterJson)); - map.insert("custom_scripts", this->customScripts.join(",")); + map.insert("custom_scripts", this->outputCustomScripts()); return map; } @@ -1040,13 +1155,51 @@ bool UserConfig::getEncounterJsonActive() { return this->useEncounterJson; } -void UserConfig::setCustomScripts(QList scripts) { - this->customScripts = scripts; +// Read input from the config to get the script paths and whether each is enabled or disbled. +// The format is a comma-separated list of paths. Each path can be followed (before the comma) +// by a :0 or :1 to indicate whether it should be disabled or enabled, respectively. If neither +// follow, it's assumed the script should be enabled. +void UserConfig::parseCustomScripts(QString input) { + this->customScripts.clear(); + QList paths = input.split(",", Qt::SkipEmptyParts); + for (QString path : paths) { + // Read and remove suffix + bool enabled = !path.endsWith(":0"); + if (!enabled || path.endsWith(":1")) + path.chop(2); + + if (!path.isEmpty()) { + // If a path is repeated only its last instance will be considered. + this->customScripts.insert(path, enabled); + } + } +} + +// Inverse of UserConfig::parseCustomScripts +QString UserConfig::outputCustomScripts() { + QStringList list; + QMapIterator i(this->customScripts); + while (i.hasNext()) { + i.next(); + list.append(QString("%1:%2").arg(i.key()).arg(i.value() ? "1" : "0")); + } + return list.join(","); +} + +void UserConfig::setCustomScripts(QStringList scripts, QList enabled) { + this->customScripts.clear(); + size_t size = qMin(scripts.length(), enabled.length()); + for (size_t i = 0; i < size; i++) + this->customScripts.insert(scripts.at(i), enabled.at(i)); this->save(); } -QList UserConfig::getCustomScripts() { - return this->customScripts; +QStringList UserConfig::getCustomScriptPaths() { + return this->customScripts.keys(); +} + +QList UserConfig::getCustomScriptsEnabled() { + return this->customScripts.values(); } ShortcutsConfig shortcutsConfig; diff --git a/src/core/map.cpp b/src/core/map.cpp index 5114b01d..176d4d2f 100644 --- a/src/core/map.cpp +++ b/src/core/map.cpp @@ -479,9 +479,9 @@ QStringList Map::eventScriptLabels(Event::Group group) const { } scriptLabels.removeAll(""); - scriptLabels.removeDuplicates(); scriptLabels.prepend("0x0"); scriptLabels.prepend("NULL"); + scriptLabels.removeDuplicates(); return scriptLabels; } diff --git a/src/core/tileset.cpp b/src/core/tileset.cpp index 4809ba6d..31d623d5 100644 --- a/src/core/tileset.cpp +++ b/src/core/tileset.cpp @@ -22,7 +22,8 @@ Tileset::Tileset(const Tileset &other) palettePaths(other.palettePaths), metatileLabels(other.metatileLabels), palettes(other.palettes), - palettePreviews(other.palettePreviews) + palettePreviews(other.palettePreviews), + hasUnsavedTilesImage(false) { for (auto tile : other.tiles) { tiles.append(tile.copy()); diff --git a/src/editor.cpp b/src/editor.cpp index 76124324..4dc12885 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -933,8 +933,7 @@ void Editor::onHoveredMovementPermissionCleared() { QString Editor::getMetatileDisplayMessage(uint16_t metatileId) { Metatile *metatile = Tileset::getMetatile(metatileId, map->layout->tileset_primary, map->layout->tileset_secondary); QString label = Tileset::getMetatileLabel(metatileId, map->layout->tileset_primary, map->layout->tileset_secondary); - QString hexString = QString("%1").arg(metatileId, 3, 16, QChar('0')).toUpper(); - QString message = QString("Metatile: 0x%1").arg(hexString); + QString message = QString("Metatile: %1").arg(Metatile::getMetatileIdString(metatileId)); if (label.size()) message += QString(" \"%1\"").arg(label); if (metatile && metatile->behavior) // Skip MB_NORMAL @@ -1918,6 +1917,7 @@ void Editor::updatePrimaryTileset(QString tilesetLabel, bool forceLoad) { map->layout->tileset_primary_label = tilesetLabel; map->layout->tileset_primary = project->getTileset(tilesetLabel, forceLoad); + map->clearBorderCache(); } } @@ -1927,6 +1927,7 @@ void Editor::updateSecondaryTileset(QString tilesetLabel, bool forceLoad) { map->layout->tileset_secondary_label = tilesetLabel; map->layout->tileset_secondary = project->getTileset(tilesetLabel, forceLoad); + map->clearBorderCache(); } } @@ -2145,7 +2146,7 @@ void Editor::openScript(const QString &scriptLabel) const { openInTextEditor(scriptPath, lineNum); } -void Editor::openInTextEditor(const QString &path, int lineNum) const { +void Editor::openInTextEditor(const QString &path, int lineNum) { QString command = porymapConfig.getTextEditorGotoLine(); if (command.isEmpty()) { // Open map scripts in the system's default editor. @@ -2158,7 +2159,7 @@ void Editor::openInTextEditor(const QString &path, int lineNum) const { } else { command += " \"" + path + '\"'; } - startDetachedProcess(command); + Editor::startDetachedProcess(command); } } @@ -2171,7 +2172,7 @@ void Editor::openProjectInTextEditor() const { startDetachedProcess(command); } -bool Editor::startDetachedProcess(const QString &command, const QString &workingDirectory, qint64 *pid) const { +bool Editor::startDetachedProcess(const QString &command, const QString &workingDirectory, qint64 *pid) { logInfo("Executing command: " + command); QProcess process; #ifdef Q_OS_WIN diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 519ec32b..92fbb23f 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -18,6 +18,7 @@ #include "mapparser.h" #include "prefab.h" #include "montabwidget.h" +#include "imageexport.h" #include #include @@ -368,13 +369,11 @@ void MainWindow::markMapEdited() { } void MainWindow::setWildEncountersUIEnabled(bool enabled) { - ui->actionUse_Encounter_Json->setChecked(enabled); ui->mainTabBar->setTabEnabled(4, enabled); } void MainWindow::setProjectSpecificUIVisibility() { - ui->actionUse_Poryscript->setChecked(projectConfig.getUsePoryScript()); this->setWildEncountersUIEnabled(userConfig.getEncounterJsonActive()); bool hasFlags = projectConfig.getMapAllowFlagsEnabled(); @@ -453,8 +452,6 @@ void MainWindow::loadUserSettings() { ui->horizontalSlider_MetatileZoom->blockSignals(true); ui->horizontalSlider_MetatileZoom->setValue(porymapConfig.getMetatilesZoom()); ui->horizontalSlider_MetatileZoom->blockSignals(false); - ui->actionMonitor_Project_Files->setChecked(porymapConfig.getMonitorFiles()); - ui->actionOpen_Recent_Project_On_Launch->setChecked(porymapConfig.getReopenOnLaunch()); setTheme(porymapConfig.getTheme()); } @@ -483,12 +480,18 @@ bool MainWindow::openRecentProject() { return false; QString default_dir = porymapConfig.getRecentProject(); - if (!default_dir.isNull() && default_dir.length() > 0) { - logInfo(QString("Opening recent project: '%1'").arg(default_dir)); - return openProject(default_dir); + if (default_dir.isNull() || default_dir.length() <= 0) + return false; + + if (!QDir(default_dir).exists()) { + QString message = QString("Recent project directory '%1' doesn't exist.").arg(QDir::toNativeSeparators(default_dir)); + logWarn(message); + this->statusBar()->showMessage(message); + return false; } - return false; + logInfo(QString("Opening recent project: '%1'").arg(default_dir)); + return openProject(default_dir); } bool MainWindow::openProject(QString dir) { @@ -519,8 +522,11 @@ bool MainWindow::openProject(QString dir) { QObject::connect(editor->project, &Project::reloadProject, this, &MainWindow::on_action_Reload_Project_triggered); QObject::connect(editor->project, &Project::mapCacheCleared, this, &MainWindow::onMapCacheCleared); QObject::connect(editor->project, &Project::disableWildEncountersUI, [this]() { this->setWildEncountersUIEnabled(false); }); - QObject::connect(editor->project, &Project::uncheckMonitorFilesAction, [this]() { ui->actionMonitor_Project_Files->setChecked(false); }); - on_actionMonitor_Project_Files_triggered(porymapConfig.getMonitorFiles()); + QObject::connect(editor->project, &Project::uncheckMonitorFilesAction, [this]() { + porymapConfig.setMonitorFiles(false); + if (this->preferenceEditor) + this->preferenceEditor->updateFields(); + }); editor->project->set_root(dir); success = loadDataStructures() && populateMapList() @@ -585,6 +591,19 @@ QString MainWindow::getDefaultMap() { return QString(); } +void MainWindow::openSubWindow(QWidget * window) { + if (!window) return; + + if (!window->isVisible()) { + window->show(); + } else if (window->isMinimized()) { + window->showNormal(); + } else { + window->raise(); + window->activateWindow(); + } +} + QString MainWindow::getExistingDirectory(QString dir) { return QFileDialog::getExistingDirectory(this, "Open Directory", dir, QFileDialog::ShowDirsOnly); } @@ -1179,12 +1198,9 @@ void MainWindow::openNewMapPopupWindow() { if (!this->newMapPrompt) { this->newMapPrompt = new NewMapPopup(this, this->editor->project); } - if (!this->newMapPrompt->isVisible()) { - this->newMapPrompt->show(); - } else { - this->newMapPrompt->raise(); - this->newMapPrompt->activateWindow(); - } + + openSubWindow(this->newMapPrompt); + connect(this->newMapPrompt, &NewMapPopup::applied, this, &MainWindow::onNewMapCreated); this->newMapPrompt->setAttribute(Qt::WA_DeleteOnClose); } @@ -1280,7 +1296,7 @@ void MainWindow::on_actionNew_Tileset_triggered() { } newSet.palettes[0][1] = qRgb(255,0,255); newSet.palettePreviews[0][1] = qRgb(255,0,255); - editor->project->saveTilesetTilesImage(&newSet); + exportIndexed4BPPPng(newSet.tilesImage, newSet.tilesImagePath); editor->project->saveTilesetMetatiles(&newSet); editor->project->saveTilesetMetatileAttributes(&newSet); editor->project->saveTilesetPalettes(&newSet); @@ -1673,7 +1689,12 @@ void MainWindow::on_mapViewTab_tabBarClicked(int index) editor->setEditingCollision(); } else if (index == 2) { editor->setEditingMap(); - prefab.tryImportDefaultPrefabs(this->editor->map); + if (projectConfig.getPrefabFilepath().isEmpty() && !projectConfig.getPrefabImportPrompted()) { + // User hasn't set up prefabs and hasn't been prompted before. + // Ask if they'd like to import the default prefabs file. + if (prefab.tryImportDefaultPrefabs(this, projectConfig.getBaseGameVersion())) + prefab.updatePrefabUi(this->editor->map); + } } editor->setCursorRectVisible(false); } @@ -1750,43 +1771,12 @@ void MainWindow::on_actionCursor_Tile_Outline_triggered() } } -void MainWindow::on_actionUse_Encounter_Json_triggered(bool checked) -{ - QMessageBox warning(this); - warning.setText("You must reload the project for this setting to take effect."); - warning.setIcon(QMessageBox::Information); - warning.exec(); - userConfig.setEncounterJsonActive(checked); -} - -void MainWindow::on_actionMonitor_Project_Files_triggered(bool checked) -{ - porymapConfig.setMonitorFiles(checked); -} - -void MainWindow::on_actionUse_Poryscript_triggered(bool checked) -{ - projectConfig.setUsePoryScript(checked); -} - -void MainWindow::on_actionOpen_Recent_Project_On_Launch_triggered(bool checked) -{ - porymapConfig.setReopenOnLaunch(checked); -} - -void MainWindow::on_actionEdit_Shortcuts_triggered() +void MainWindow::on_actionShortcuts_triggered() { if (!shortcutsEditor) initShortcutsEditor(); - if (shortcutsEditor->isHidden()) { - shortcutsEditor->show(); - } else if (shortcutsEditor->isMinimized()) { - shortcutsEditor->showNormal(); - } else { - shortcutsEditor->raise(); - shortcutsEditor->activateWindow(); - } + openSubWindow(shortcutsEditor); } void MainWindow::initShortcutsEditor() { @@ -1812,6 +1802,11 @@ void MainWindow::connectSubEditorsToShortcutsEditor() { if (regionMapEditor) connect(shortcutsEditor, &ShortcutsEditor::shortcutsSaved, regionMapEditor, &RegionMapEditor::applyUserShortcuts); + + if (!customScriptsEditor) + initCustomScriptsEditor(); + connect(shortcutsEditor, &ShortcutsEditor::shortcutsSaved, + customScriptsEditor, &CustomScriptsEditor::applyUserShortcuts); } void MainWindow::on_actionPencil_triggered() @@ -2205,7 +2200,7 @@ void MainWindow::on_toolButton_Paint_clicked() editor->settings->mapCursor = QCursor(QPixmap(":/icons/pencil_cursor.ico"), 10, 10); // do not stop single tile mode when editing collision - if (ui->mapViewTab->currentIndex() == 0) + if (ui->mapViewTab->currentIndex() != 1) editor->cursorMapTileRect->stopSingleTileMode(); ui->graphicsView_Map->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); @@ -2385,10 +2380,8 @@ void MainWindow::onTilesetsSaved(QString primaryTilesetLabel, QString secondaryT } else { this->editor->project->getTileset(secondaryTilesetLabel, true); } - if (updated) { - this->editor->map->clearBorderCache(); + if (updated) redrawMapScene(); - } } void MainWindow::onWildMonDataChanged() { @@ -2467,13 +2460,7 @@ void MainWindow::showExportMapImageWindow(ImageExporterMode mode) { this->mapImageExporter = new MapImageExporter(this, this->editor, mode); this->mapImageExporter->setAttribute(Qt::WA_DeleteOnClose); - if (!this->mapImageExporter->isVisible()) { - this->mapImageExporter->show(); - } else if (this->mapImageExporter->isMinimized()) { - this->mapImageExporter->showNormal(); - } else { - this->mapImageExporter->activateWindow(); - } + openSubWindow(this->mapImageExporter); } void MainWindow::on_comboBox_ConnectionDirection_currentTextChanged(const QString &direction) @@ -2490,7 +2477,7 @@ void MainWindow::on_spinBox_ConnectionOffset_valueChanged(int offset) void MainWindow::on_comboBox_ConnectedMap_currentTextChanged(const QString &mapName) { - if (editor->project->mapNames.contains(mapName)) { + if (mapName.isEmpty() || editor->project->mapNames.contains(mapName)) { editor->setConnectionMap(mapName); markMapEdited(); } @@ -2522,7 +2509,7 @@ void MainWindow::on_pushButton_ConfigureEncountersJSON_clicked() { void MainWindow::on_comboBox_DiveMap_currentTextChanged(const QString &mapName) { - if (editor->project->mapNames.contains(mapName)) { + if (mapName.isEmpty() || editor->project->mapNames.contains(mapName)) { editor->updateDiveMap(mapName); markMapEdited(); } @@ -2530,7 +2517,7 @@ void MainWindow::on_comboBox_DiveMap_currentTextChanged(const QString &mapName) void MainWindow::on_comboBox_EmergeMap_currentTextChanged(const QString &mapName) { - if (editor->project->mapNames.contains(mapName)) { + if (mapName.isEmpty() || editor->project->mapNames.contains(mapName)) { editor->updateEmergeMap(mapName); markMapEdited(); } @@ -2669,14 +2656,7 @@ void MainWindow::on_actionTileset_Editor_triggered() initTilesetEditor(); } - if (!this->tilesetEditor->isVisible()) { - this->tilesetEditor->show(); - } else if (this->tilesetEditor->isMinimized()) { - this->tilesetEditor->showNormal(); - } else { - this->tilesetEditor->raise(); - this->tilesetEditor->activateWindow(); - } + openSubWindow(this->tilesetEditor); MetatileSelection selection = this->editor->metatile_selector_item->getMetatileSelection(); this->tilesetEditor->selectMetatile(selection.metatileItems.first().metatileId); @@ -2718,7 +2698,7 @@ void MainWindow::on_actionOpen_Config_Folder_triggered() { QDesktopServices::openUrl(QUrl::fromLocalFile(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation))); } -void MainWindow::on_actionEdit_Preferences_triggered() { +void MainWindow::on_actionPreferences_triggered() { if (!preferenceEditor) { preferenceEditor = new PreferenceEditor(this); connect(preferenceEditor, &PreferenceEditor::themeChanged, @@ -2729,14 +2709,7 @@ void MainWindow::on_actionEdit_Preferences_triggered() { this, &MainWindow::togglePreferenceSpecificUi); } - if (!preferenceEditor->isVisible()) { - preferenceEditor->show(); - } else if (preferenceEditor->isMinimized()) { - preferenceEditor->showNormal(); - } else { - preferenceEditor->raise(); - preferenceEditor->activateWindow(); - } + openSubWindow(preferenceEditor); } void MainWindow::togglePreferenceSpecificUi() { @@ -2746,6 +2719,39 @@ void MainWindow::togglePreferenceSpecificUi() { ui->actionOpen_Project_in_Text_Editor->setEnabled(true); } +void MainWindow::on_actionProject_Settings_triggered() { + if (!this->projectSettingsEditor) { + this->projectSettingsEditor = new ProjectSettingsEditor(this, this->editor->project); + connect(this->projectSettingsEditor, &ProjectSettingsEditor::reloadProject, + this, &MainWindow::on_action_Reload_Project_triggered); + } + + openSubWindow(this->projectSettingsEditor); +} + +void MainWindow::on_actionCustom_Scripts_triggered() { + if (!this->customScriptsEditor) + initCustomScriptsEditor(); + + openSubWindow(this->customScriptsEditor); +} + +void MainWindow::initCustomScriptsEditor() { + this->customScriptsEditor = new CustomScriptsEditor(this); + connect(this->customScriptsEditor, &CustomScriptsEditor::reloadScriptEngine, + this, &MainWindow::reloadScriptEngine); +} + +void MainWindow::reloadScriptEngine() { + Scripting::init(this); + this->ui->graphicsView_Map->clearOverlayMap(); + Scripting::populateGlobalObject(this); + // Lying to the scripts here, simulating a project reload + Scripting::cb_ProjectOpened(projectConfig.getProjectDir()); + if (editor && editor->map) + Scripting::cb_MapOpened(editor->map->name); +} + void MainWindow::on_pushButton_AddCustomHeaderField_clicked() { bool ok; @@ -2795,14 +2801,7 @@ void MainWindow::on_actionRegion_Map_Editor_triggered() { } } - if (!this->regionMapEditor->isVisible()) { - this->regionMapEditor->show(); - } else if (this->regionMapEditor->isMinimized()) { - this->regionMapEditor->showNormal(); - } else { - this->regionMapEditor->raise(); - this->regionMapEditor->activateWindow(); - } + openSubWindow(this->regionMapEditor); } void MainWindow::on_pushButton_CreatePrefab_clicked() { diff --git a/src/project.cpp b/src/project.cpp index 7a73ac3e..54d99cea 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -6,7 +6,6 @@ #include "paletteutil.h" #include "tile.h" #include "tileset.h" -#include "imageexport.h" #include "map.h" #include "orderedjson.h" @@ -445,10 +444,6 @@ bool Project::readMapLayouts() { "blockdata_filepath", }; bool useCustomBorderSize = projectConfig.getUseCustomBorderSize(); - if (useCustomBorderSize) { - requiredFields.append("border_width"); - requiredFields.append("border_height"); - } for (int i = 0; i < layouts.size(); i++) { QJsonObject layoutObj = layouts[i].toObject(); if (layoutObj.isEmpty()) @@ -916,10 +911,9 @@ QString Project::buildMetatileLabelsText(const QMap defines) { // Generate defines text QString output = QString(); for (QString label : labels) { - int metatileId = defines[label]; - QString line = QString("#define %1 0x%2\n") + QString line = QString("#define %1 %2\n") .arg(label, -1 * longestLength) - .arg(QString("%1").arg(metatileId, 3, 16, QChar('0')).toUpper()); + .arg(Metatile::getMetatileIdString(defines[label])); output += line; } return output; @@ -994,7 +988,15 @@ void Project::saveTilesetMetatiles(Tileset *tileset) { } void Project::saveTilesetTilesImage(Tileset *tileset) { - exportIndexed4BPPPng(tileset->tilesImage, tileset->tilesImagePath); + // Only write the tiles image if it was changed. + // Porymap will only ever change an existing tiles image by importing a new one. + if (tileset->hasUnsavedTilesImage) { + if (!tileset->tilesImage.save(tileset->tilesImagePath, "PNG")) { + logError(QString("Failed to save tiles image '%1'").arg(tileset->tilesImagePath)); + return; + } + tileset->hasUnsavedTilesImage = false; + } } void Project::saveTilesetPalettes(Tileset *tileset) { @@ -1121,16 +1123,21 @@ void Project::setNewMapBorder(Map *map) { map->layout->border.clear(); int width = map->getBorderWidth(); int height = map->getBorderHeight(); - if (width != DEFAULT_BORDER_WIDTH || height != DEFAULT_BORDER_HEIGHT) { + + const QList configMetatileIds = projectConfig.getNewMapBorderMetatileIds(); + if (configMetatileIds.length() != width * height) { + // Border size doesn't match the number of default border metatiles. + // Fill the border with empty metatiles. for (int i = 0; i < width * height; i++) { map->layout->border.append(0); } } else { - QList metatileIds = projectConfig.getNewMapBorderMetatileIds(); - for (int i = 0; i < DEFAULT_BORDER_WIDTH * DEFAULT_BORDER_HEIGHT; i++) { - map->layout->border.append(qint16(metatileIds.at(i))); + // Fill the border with the default metatiles from the config. + for (int i = 0; i < width * height; i++) { + map->layout->border.append(configMetatileIds.at(i)); } } + map->layout->lastCommitBlocks.border = map->layout->border; map->layout->lastCommitBlocks.borderDimensions = QSize(width, height); } @@ -1336,6 +1343,7 @@ void Project::loadTilesetAssets(Tileset* tileset) { QImage image; if (QFile::exists(tileset->tilesImagePath)) { image = QImage(tileset->tilesImagePath).convertToFormat(QImage::Format_Indexed8, Qt::ThresholdDither); + flattenTo4bppImage(&image); } else { image = QImage(8, 8, QImage::Format_Indexed8); } @@ -2164,8 +2172,8 @@ bool Project::readCoordEventWeatherNames() { fileWatcher.addPath(root + "/" + filename); coordEventWeatherNames = parser.readCDefinesSorted(filename, prefixes); if (coordEventWeatherNames.isEmpty()) { - logError(QString("Failed to read coord event weather constants from %1").arg(filename)); - return false; + logWarn(QString("Failed to read coord event weather constants from %1. Disabling Weather Trigger events.").arg(filename)); + projectConfig.setEventWeatherTriggerEnabled(false); } return true; } @@ -2179,8 +2187,8 @@ bool Project::readSecretBaseIds() { fileWatcher.addPath(root + "/" + filename); secretBaseIds = parser.readCDefinesSorted(filename, prefixes); if (secretBaseIds.isEmpty()) { - logError(QString("Failed to read secret base id constants from %1").arg(filename)); - return false; + logWarn(QString("Failed to read secret base id constants from '%1'. Disabling Secret Base events.").arg(filename)); + projectConfig.setEventSecretBaseEnabled(false); } return true; } diff --git a/src/scriptapi/apimap.cpp b/src/scriptapi/apimap.cpp index fde049db..21f38a51 100644 --- a/src/scriptapi/apimap.cpp +++ b/src/scriptapi/apimap.cpp @@ -5,7 +5,7 @@ #include "config.h" #include "imageproviders.h" -// TODO: "needsFullRedraw" is used when redrawing the map after +// TODO: "tilesetNeedsRedraw" is used when redrawing the map after // changing a metatile's tiles via script. It is unnecessarily // resource intensive. The map metatiles that need to be updated are // not marked as changed, so they will not be redrawn if the cache @@ -16,13 +16,22 @@ void MainWindow::tryRedrawMapArea(bool forceRedraw) { if (!forceRedraw) return; - if (this->needsFullRedraw) { + if (this->tilesetNeedsRedraw) { + // Refresh anything that can display metatiles this->editor->map_item->draw(true); this->editor->collision_item->draw(true); this->editor->selected_border_metatiles_item->draw(); this->editor->updateMapBorder(); this->editor->updateMapConnections(); - this->needsFullRedraw = false; + if (this->tilesetEditor) + this->tilesetEditor->updateTilesets(this->editor->map->layout->tileset_primary_label, this->editor->map->layout->tileset_secondary_label); + if (this->editor->metatile_selector_item) + this->editor->metatile_selector_item->draw(); + if (this->editor->selected_border_metatiles_item) + this->editor->selected_border_metatiles_item->draw(); + if (this->editor->current_metatile_selection_item) + this->editor->current_metatile_selection_item->draw(); + this->tilesetNeedsRedraw = false; } else { this->editor->map_item->draw(); this->editor->collision_item->draw(); @@ -569,16 +578,6 @@ void MainWindow::saveMetatilesByMetatileId(int metatileId) { Tileset * tileset = Tileset::getMetatileTileset(metatileId, this->editor->map->layout->tileset_primary, this->editor->map->layout->tileset_secondary); if (this->editor->project && tileset) this->editor->project->saveTilesetMetatiles(tileset); - - // Refresh anything that can display metatiles (except the actual map view) - if (this->tilesetEditor) - this->tilesetEditor->updateTilesets(this->editor->map->layout->tileset_primary_label, this->editor->map->layout->tileset_secondary_label); - if (this->editor->metatile_selector_item) - this->editor->metatile_selector_item->draw(); - if (this->editor->selected_border_metatiles_item) - this->editor->selected_border_metatiles_item->draw(); - if (this->editor->current_metatile_selection_item) - this->editor->current_metatile_selection_item->draw(); } void MainWindow::saveMetatileAttributesByMetatileId(int metatileId) { @@ -588,7 +587,7 @@ void MainWindow::saveMetatileAttributesByMetatileId(int metatileId) { // If the Tileset Editor is currently displaying the updated metatile, refresh it if (this->tilesetEditor && this->tilesetEditor->getSelectedMetatileId() == metatileId) - this->tilesetEditor->updateTilesets(this->editor->map->layout->tileset_primary_label, this->editor->map->layout->tileset_secondary_label); + this->tilesetEditor->onSelectedMetatileChanged(metatileId); } Metatile * MainWindow::getMetatile(int metatileId) { @@ -735,7 +734,7 @@ void MainWindow::setMetatileTiles(int metatileId, QJSValue tilesObj, int tileSta metatile->tiles[tileStart] = Tile(); this->saveMetatilesByMetatileId(metatileId); - this->needsFullRedraw = true; + this->tilesetNeedsRedraw = true; this->tryRedrawMapArea(forceRedraw); } @@ -751,7 +750,7 @@ void MainWindow::setMetatileTiles(int metatileId, int tileId, bool xflip, bool y metatile->tiles[i] = tile; this->saveMetatilesByMetatileId(metatileId); - this->needsFullRedraw = true; + this->tilesetNeedsRedraw = true; this->tryRedrawMapArea(forceRedraw); } diff --git a/src/scriptapi/apiutility.cpp b/src/scriptapi/apiutility.cpp index baeb2017..559e46af 100644 --- a/src/scriptapi/apiutility.cpp +++ b/src/scriptapi/apiutility.cpp @@ -184,7 +184,7 @@ bool ScriptUtility::getSmartPathsEnabled() { } QList ScriptUtility::getCustomScripts() { - return userConfig.getCustomScripts(); + return userConfig.getCustomScriptPaths(); } QList ScriptUtility::getMetatileLayerOrder() { diff --git a/src/scriptapi/scripting.cpp b/src/scriptapi/scripting.cpp index 9aa6963c..5ff90d37 100644 --- a/src/scriptapi/scripting.cpp +++ b/src/scriptapi/scripting.cpp @@ -36,8 +36,11 @@ Scripting::Scripting(MainWindow *mainWindow) { this->mainWindow = mainWindow; this->engine = new QJSEngine(mainWindow); this->engine->installExtensions(QJSEngine::ConsoleExtension); - for (QString script : userConfig.getCustomScripts()) { - this->filepaths.append(script); + const QStringList paths = userConfig.getCustomScriptPaths(); + const QList enabled = userConfig.getCustomScriptsEnabled(); + for (int i = 0; i < paths.length(); i++) { + if (enabled.at(i)) + this->filepaths.append(paths.at(i)); } this->loadModules(this->filepaths); this->scriptUtility = new ScriptUtility(mainWindow); diff --git a/src/ui/customscriptseditor.cpp b/src/ui/customscriptseditor.cpp new file mode 100644 index 00000000..6daeb0eb --- /dev/null +++ b/src/ui/customscriptseditor.cpp @@ -0,0 +1,254 @@ +#include "customscriptseditor.h" +#include "ui_customscriptseditor.h" +#include "ui_customscriptslistitem.h" +#include "config.h" +#include "editor.h" +#include "shortcut.h" + +#include +#include + +CustomScriptsEditor::CustomScriptsEditor(QWidget *parent) : + QMainWindow(parent), + ui(new Ui::CustomScriptsEditor), + baseDir(userConfig.getProjectDir() + QDir::separator()) +{ + ui->setupUi(this); + setAttribute(Qt::WA_DeleteOnClose); + // This property seems to be reset if we don't set it programmatically + ui->list->setDragDropMode(QAbstractItemView::NoDragDrop); + + const QStringList paths = userConfig.getCustomScriptPaths(); + const QList enabled = userConfig.getCustomScriptsEnabled(); + for (int i = 0; i < paths.length(); i++) + this->displayScript(paths.at(i), enabled.at(i)); + + this->importDir = userConfig.getProjectDir(); + + connect(ui->button_AddNewScript, &QAbstractButton::clicked, this, &CustomScriptsEditor::addNewScript); + connect(ui->button_ReloadScripts, &QAbstractButton::clicked, this, &CustomScriptsEditor::reloadScripts); + connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &CustomScriptsEditor::dialogButtonClicked); + + this->initShortcuts(); + this->restoreWindowState(); +} + +CustomScriptsEditor::~CustomScriptsEditor() +{ + ui->list->clear(); + delete ui; +} + +void CustomScriptsEditor::initShortcuts() { + auto *shortcut_remove = new Shortcut({QKeySequence("Del"), QKeySequence("Backspace")}, this, SLOT(removeSelectedScripts())); + shortcut_remove->setObjectName("shortcut_remove"); + shortcut_remove->setWhatsThis("Remove Selected Scripts"); + + auto *shortcut_open = new Shortcut(QKeySequence(), this, SLOT(openSelectedScripts())); + shortcut_open->setObjectName("shortcut_open"); + shortcut_open->setWhatsThis("Open Selected Scripts"); + + auto *shortcut_addNew = new Shortcut(QKeySequence(), this, SLOT(addNewScript())); + shortcut_addNew->setObjectName("shortcut_addNew"); + shortcut_addNew->setWhatsThis("Add New Script..."); + + auto *shortcut_reload = new Shortcut(QKeySequence(), this, SLOT(reloadScripts())); + shortcut_reload->setObjectName("shortcut_reload"); + shortcut_reload->setWhatsThis("Reload Scripts"); + + shortcutsConfig.load(); + shortcutsConfig.setDefaultShortcuts(shortcutableObjects()); + applyUserShortcuts(); +} + +QObjectList CustomScriptsEditor::shortcutableObjects() const { + QObjectList shortcutable_objects; + + for (auto *action : findChildren()) + if (!action->objectName().isEmpty()) + shortcutable_objects.append(qobject_cast(action)); + for (auto *shortcut : findChildren()) + if (!shortcut->objectName().isEmpty()) + shortcutable_objects.append(qobject_cast(shortcut)); + + return shortcutable_objects; +} + +void CustomScriptsEditor::applyUserShortcuts() { + for (auto *action : findChildren()) + if (!action->objectName().isEmpty()) + action->setShortcuts(shortcutsConfig.userShortcuts(action)); + for (auto *shortcut : findChildren()) + if (!shortcut->objectName().isEmpty()) + shortcut->setKeys(shortcutsConfig.userShortcuts(shortcut)); +} + +void CustomScriptsEditor::restoreWindowState() { + logInfo("Restoring custom scripts editor geometry from previous session."); + const QMap geometry = porymapConfig.getCustomScriptsEditorGeometry(); + this->restoreGeometry(geometry.value("custom_scripts_editor_geometry")); + this->restoreState(geometry.value("custom_scripts_editor_state")); +} + +void CustomScriptsEditor::displayScript(const QString &filepath, bool enabled) { + auto item = new QListWidgetItem(); + auto widget = new CustomScriptsListItem(); + + widget->ui->checkBox_Enable->setChecked(enabled); + widget->ui->lineEdit_filepath->setText(filepath); + item->setSizeHint(widget->sizeHint()); + + connect(widget->ui->b_Choose, &QAbstractButton::clicked, [this, item](bool) { this->replaceScript(item); }); + connect(widget->ui->b_Edit, &QAbstractButton::clicked, [this, item](bool) { this->openScript(item); }); + connect(widget->ui->b_Delete, &QAbstractButton::clicked, [this, item](bool) { this->removeScript(item); }); + connect(widget->ui->checkBox_Enable, &QCheckBox::stateChanged, this, &CustomScriptsEditor::markEdited); + connect(widget->ui->lineEdit_filepath, &QLineEdit::textEdited, this, &CustomScriptsEditor::markEdited); + + // Per the Qt manual, for performance reasons QListWidget::setItemWidget shouldn't be used with non-static items. + // There's an assumption here that users won't have enough scripts for that to be a problem. + ui->list->addItem(item); + ui->list->setItemWidget(item, widget); +} + +void CustomScriptsEditor::markEdited() { + this->hasUnsavedChanges = true; +} + +QString CustomScriptsEditor::getScriptFilepath(QListWidgetItem * item, bool absolutePath) const { + auto widget = dynamic_cast(ui->list->itemWidget(item)); + if (!widget) return QString(); + + QString path = widget->ui->lineEdit_filepath->text(); + if (absolutePath) { + QFileInfo fileInfo(path); + if (fileInfo.isRelative()) + path.prepend(this->baseDir); + } + return path; +} + +void CustomScriptsEditor::setScriptFilepath(QListWidgetItem * item, QString filepath) const { + auto widget = dynamic_cast(ui->list->itemWidget(item)); + if (!widget) return; + + if (filepath.startsWith(this->baseDir)) + filepath.remove(0, this->baseDir.length()); + widget->ui->lineEdit_filepath->setText(filepath); +} + +bool CustomScriptsEditor::getScriptEnabled(QListWidgetItem * item) const { + auto widget = dynamic_cast(ui->list->itemWidget(item)); + return widget && widget->ui->checkBox_Enable->isChecked(); +} + +QString CustomScriptsEditor::chooseScript(QString dir) { + return QFileDialog::getOpenFileName(this, "Choose Custom Script File", dir, "JavaScript Files (*.js)"); +} + +void CustomScriptsEditor::addNewScript() { + QString filepath = this->chooseScript(this->importDir); + if (filepath.isEmpty()) + return; + this->importDir = filepath; + if (filepath.startsWith(this->baseDir)) + filepath.remove(0, this->baseDir.length()); + this->displayScript(filepath, true); + this->markEdited(); +} + +void CustomScriptsEditor::removeScript(QListWidgetItem * item) { + ui->list->takeItem(ui->list->row(item)); + this->markEdited(); +} + +void CustomScriptsEditor::removeSelectedScripts() { + QList items = ui->list->selectedItems(); + if (items.length() == 0) + return; + for (auto item : items) + this->removeScript(item); +} + +void CustomScriptsEditor::replaceScript(QListWidgetItem * item) { + const QString filepath = this->chooseScript(this->getScriptFilepath(item)); + if (filepath.isEmpty()) + return; + this->setScriptFilepath(item, filepath); + this->markEdited(); +} + +void CustomScriptsEditor::openScript(QListWidgetItem * item) { + const QString path = this->getScriptFilepath(item); + QFileInfo fileInfo(path); + if (!fileInfo.exists() || !fileInfo.isFile()){ + QMessageBox::warning(this, "", QString("Failed to open script '%1'").arg(path)); + return; + } + Editor::openInTextEditor(path); +} + +void CustomScriptsEditor::openSelectedScripts() { + for (auto item : ui->list->selectedItems()) + this->openScript(item); +} + +void CustomScriptsEditor::reloadScripts() { + if (this->hasUnsavedChanges) { + if (this->prompt("Scripts have been modified, save changes and reload the script engine?", QMessageBox::Yes) == QMessageBox::No) + return; + this->save(); + } + emit reloadScriptEngine(); +} + +void CustomScriptsEditor::save() { + if (!this->hasUnsavedChanges) + return; + + QStringList paths; + QList enabledStates; + for (int i = 0; i < ui->list->count(); i++) { + auto item = ui->list->item(i); + const QString path = this->getScriptFilepath(item, false); + if (!path.isEmpty()) { + paths.append(path); + enabledStates.append(this->getScriptEnabled(item)); + } + } + + userConfig.setCustomScripts(paths, enabledStates); + this->hasUnsavedChanges = false; + this->reloadScripts(); +} + +int CustomScriptsEditor::prompt(const QString &text, QMessageBox::StandardButton defaultButton) { + QMessageBox messageBox(this); + messageBox.setText(text); + messageBox.setIcon(QMessageBox::Question); + messageBox.setStandardButtons(QMessageBox::Yes | QMessageBox::No | defaultButton); + messageBox.setDefaultButton(defaultButton); + return messageBox.exec(); +} + +void CustomScriptsEditor::dialogButtonClicked(QAbstractButton *button) { + if (ui->buttonBox->buttonRole(button) == QDialogButtonBox::AcceptRole) + this->save(); + close(); // All buttons (OK and Cancel) close the window +} + +void CustomScriptsEditor::closeEvent(QCloseEvent* event) { + if (this->hasUnsavedChanges) { + int result = this->prompt("Scripts have been modified, save changes?", QMessageBox::Cancel); + if (result == QMessageBox::Cancel) { + event->ignore(); + return; + } + if (result == QMessageBox::Yes) + this->save(); + } + + porymapConfig.setCustomScriptsEditorGeometry( + this->saveGeometry(), + this->saveState() + ); +} diff --git a/src/ui/customscriptslistitem.cpp b/src/ui/customscriptslistitem.cpp new file mode 100644 index 00000000..e1452425 --- /dev/null +++ b/src/ui/customscriptslistitem.cpp @@ -0,0 +1,14 @@ +#include "customscriptslistitem.h" +#include "ui_customscriptslistitem.h" + +CustomScriptsListItem::CustomScriptsListItem(QWidget *parent) : + QFrame(parent), + ui(new Ui::CustomScriptsListItem) +{ + ui->setupUi(this); +} + +CustomScriptsListItem::~CustomScriptsListItem() +{ + delete ui; +} diff --git a/src/ui/encountertabledelegates.cpp b/src/ui/encountertabledelegates.cpp index c381fcae..44229822 100644 --- a/src/ui/encountertabledelegates.cpp +++ b/src/ui/encountertabledelegates.cpp @@ -61,12 +61,8 @@ QWidget *SpinBoxDelegate::createEditor(QWidget *parent, const QStyleOptionViewIt editor->setFrame(false); int col = index.column(); - if (col == EncounterTableModel::ColumnType::MinLevel) { + if (col == EncounterTableModel::ColumnType::MinLevel || col == EncounterTableModel::ColumnType::MaxLevel) { editor->setMinimum(this->project->miscConstants.value("min_level_define").toInt()); - editor->setMaximum(index.siblingAtColumn(EncounterTableModel::ColumnType::MaxLevel).data(Qt::EditRole).toInt()); - } - else if (col == EncounterTableModel::ColumnType::MaxLevel) { - editor->setMinimum(index.siblingAtColumn(EncounterTableModel::ColumnType::MinLevel).data(Qt::EditRole).toInt()); editor->setMaximum(this->project->miscConstants.value("max_level_define").toInt()); } else if (col == EncounterTableModel::ColumnType::EncounterRate) { diff --git a/src/ui/encountertablemodel.cpp b/src/ui/encountertablemodel.cpp index 51658ff7..b38529af 100644 --- a/src/ui/encountertablemodel.cpp +++ b/src/ui/encountertablemodel.cpp @@ -155,13 +155,21 @@ bool EncounterTableModel::setData(const QModelIndex &index, const QVariant &valu this->monInfo.wildPokemon[row].species = value.toString(); break; - case ColumnType::MinLevel: - this->monInfo.wildPokemon[row].minLevel = value.toInt(); + case ColumnType::MinLevel: { + int minLevel = value.toInt(); + this->monInfo.wildPokemon[row].minLevel = minLevel; + if (minLevel > this->monInfo.wildPokemon[row].maxLevel) + this->monInfo.wildPokemon[row].maxLevel = minLevel; break; + } - case ColumnType::MaxLevel: - this->monInfo.wildPokemon[row].maxLevel = value.toInt(); + case ColumnType::MaxLevel: { + int maxLevel = value.toInt(); + this->monInfo.wildPokemon[row].maxLevel = maxLevel; + if (maxLevel < this->monInfo.wildPokemon[row].minLevel) + this->monInfo.wildPokemon[row].minLevel = maxLevel; break; + } case ColumnType::EncounterRate: this->monInfo.encounterRate = value.toInt(); diff --git a/src/ui/imageproviders.cpp b/src/ui/imageproviders.cpp index 838f4db7..891cfbba 100644 --- a/src/ui/imageproviders.cpp +++ b/src/ui/imageproviders.cpp @@ -169,3 +169,12 @@ QImage getPalettedTileImage(uint16_t tileId, Tileset *primaryTileset, Tileset *s QImage getGreyscaleTileImage(uint16_t tileId, Tileset *primaryTileset, Tileset *secondaryTileset) { return getColoredTileImage(tileId, primaryTileset, secondaryTileset, greyscalePalette); } + +// gbagfx allows 4bpp image data to be represented with 8bpp .png files by considering only the lower 4 bits of each pixel. +// Reproduce that here to support this type of image use. +void flattenTo4bppImage(QImage * image) { + if (!image) return; + uchar * pixel = image->bits(); + for (int i = 0; i < image->sizeInBytes(); i++, pixel++) + *pixel %= 16; +} diff --git a/src/ui/noscrollcombobox.cpp b/src/ui/noscrollcombobox.cpp index f0e89e4c..ecf2ba3a 100644 --- a/src/ui/noscrollcombobox.cpp +++ b/src/ui/noscrollcombobox.cpp @@ -29,11 +29,30 @@ void NoScrollComboBox::wheelEvent(QWheelEvent *event) QComboBox::wheelEvent(event); } +void NoScrollComboBox::setItem(int index, const QString &text) +{ + if (index >= 0) { + // Valid item + this->setCurrentIndex(index); + } else if (this->isEditable()) { + // Invalid item in editable box, just display the text + this->setCurrentText(text); + } else { + // Invalid item in uneditable box, display text as placeholder + // On Qt < 5.15 this will display an empty box +#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)) + this->setPlaceholderText(text); +#endif + this->setCurrentIndex(index); + } +} + void NoScrollComboBox::setTextItem(const QString &text) { - int index = this->findText(text); - if (index >= 0) - this->setCurrentIndex(index); - else - this->setCurrentText(text); + this->setItem(this->findText(text), text); +} + +void NoScrollComboBox::setNumberItem(int value) +{ + this->setItem(this->findData(value), QString::number(value)); } diff --git a/src/ui/prefab.cpp b/src/ui/prefab.cpp index 87bae518..9ad154f2 100644 --- a/src/ui/prefab.cpp +++ b/src/ui/prefab.cpp @@ -19,9 +19,11 @@ using OrderedJson = poryjson::Json; using OrderedJsonDoc = poryjson::JsonDoc; +const QString defaultFilepath = "prefabs.json"; + void Prefab::loadPrefabs() { this->items.clear(); - QString filepath = projectConfig.getPrefabFilepath(false); + QString filepath = projectConfig.getPrefabFilepath(); if (filepath.isEmpty()) return; ParseUtil parser; @@ -85,8 +87,11 @@ void Prefab::loadPrefabs() { } void Prefab::savePrefabs() { - QString filepath = projectConfig.getPrefabFilepath(true); - if (filepath.isEmpty()) return; + QString filepath = projectConfig.getPrefabFilepath(); + if (filepath.isEmpty()) { + filepath = defaultFilepath; + projectConfig.setPrefabFilepath(filepath); + } QFileInfo info(filepath); if (info.isRelative()) { @@ -269,48 +274,58 @@ void Prefab::addPrefab(MetatileSelection selection, Map *map, QString name) { this->updatePrefabUi(map); } -void Prefab::tryImportDefaultPrefabs(Map *map) { - BaseGameVersion version = projectConfig.getBaseGameVersion(); +bool Prefab::tryImportDefaultPrefabs(QWidget * parent, BaseGameVersion version, QString filepath) { // Ensure we have default prefabs for the project's game version. if (version != BaseGameVersion::pokeruby && version != BaseGameVersion::pokeemerald && version != BaseGameVersion::pokefirered) - return; + return false; - // Exit early if the user has already setup prefabs. - if (!projectConfig.getPrefabFilepath(false).isEmpty()) - return; + if (filepath.isEmpty()) + filepath = defaultFilepath; - // Exit early if the user has already gone through this import prompt before. - if (projectConfig.getPrefabImportPrompted()) - return; + // Get the absolute filepath for writing/warnings + QString absFilepath; + QFileInfo fileInfo(filepath); + if (fileInfo.suffix().isEmpty()) + filepath += ".json"; + if (fileInfo.isRelative()) { + absFilepath = QDir::cleanPath(projectConfig.getProjectDir() + QDir::separator() + filepath); + } else { + absFilepath = filepath; + } + + // The warning message when importing defaults changes if there's a pre-existing file. + QString fileWarning; + if (!QFileInfo::exists(absFilepath)) { + fileWarning = QString("This will create a file called '%1'").arg(absFilepath); + } else { + fileWarning = QString("This will overwrite any existing prefabs in '%1'").arg(absFilepath); + } // Display a dialog box to the user, asking if the default prefabs should be imported // into their project. QMessageBox::StandardButton prompt = - QMessageBox::question(nullptr, + QMessageBox::question(parent, "Import Default Prefabs", - QString("Would you like to import the default prefabs for %1? This will create a file called 'prefabs.json' in your project directory.") - .arg(projectConfig.getBaseGameVersionString()), + QString("Would you like to import the default prefabs for %1? %2.") + .arg(projectConfig.getBaseGameVersionString(version)) + .arg(fileWarning), QMessageBox::Yes | QMessageBox::No); - if (prompt == QMessageBox::Yes) { + bool acceptedImport = (prompt == QMessageBox::Yes); + if (acceptedImport) { // Sets up the default prefabs.json filepath. - QString filepath = projectConfig.getPrefabFilepath(true); - - QFileInfo info(filepath); - if (info.isRelative()) { - filepath = QDir::cleanPath(projectConfig.getProjectDir() + QDir::separator() + filepath); - } - QFile prefabsFile(filepath); + projectConfig.setPrefabFilepath(filepath); + QFile prefabsFile(absFilepath); if (!prefabsFile.open(QIODevice::WriteOnly)) { projectConfig.setPrefabFilepath(QString()); - logError(QString("Error: Could not open %1 for writing").arg(filepath)); - QMessageBox messageBox; + logError(QString("Error: Could not open %1 for writing").arg(absFilepath)); + QMessageBox messageBox(parent); messageBox.setText("Failed to import default prefabs file!"); - messageBox.setInformativeText(QString("Could not open \"%1\" for writing").arg(filepath)); + messageBox.setInformativeText(QString("Could not open \"%1\" for writing").arg(absFilepath)); messageBox.setIcon(QMessageBox::Warning); messageBox.exec(); - return; + return false; } ParseUtil parser; @@ -330,10 +345,10 @@ void Prefab::tryImportDefaultPrefabs(Map *map) { prefabsFile.write(content.toUtf8()); prefabsFile.close(); this->loadPrefabs(); - this->updatePrefabUi(map); } projectConfig.setPrefabImportPrompted(true); + return acceptedImport; } Prefab prefab; diff --git a/src/ui/preferenceeditor.cpp b/src/ui/preferenceeditor.cpp index 0ef62dce..f94c07da 100644 --- a/src/ui/preferenceeditor.cpp +++ b/src/ui/preferenceeditor.cpp @@ -17,11 +17,14 @@ PreferenceEditor::PreferenceEditor(QWidget *parent) : ui->setupUi(this); auto *formLayout = new QFormLayout(ui->groupBox_Themes); themeSelector = new NoScrollComboBox(ui->groupBox_Themes); + themeSelector->setEditable(false); + themeSelector->setMinimumContentsLength(0); formLayout->addRow("Themes", themeSelector); setAttribute(Qt::WA_DeleteOnClose); connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &PreferenceEditor::dialogButtonClicked); - populateFields(); + initFields(); + updateFields(); } PreferenceEditor::~PreferenceEditor() @@ -29,7 +32,7 @@ PreferenceEditor::~PreferenceEditor() delete ui; } -void PreferenceEditor::populateFields() { +void PreferenceEditor::initFields() { QStringList themes = { "default" }; static const QRegularExpression re(":/themes/([A-z0-9_-]+).qss"); QDirIterator it(":/themes", QDirIterator::Subdirectories); @@ -38,11 +41,14 @@ void PreferenceEditor::populateFields() { themes.append(themeName); } themeSelector->addItems(themes); +} + +void PreferenceEditor::updateFields() { themeSelector->setCurrentText(porymapConfig.getTheme()); - ui->lineEdit_TextEditorOpenFolder->setText(porymapConfig.getTextEditorOpenFolder()); - ui->lineEdit_TextEditorGotoLine->setText(porymapConfig.getTextEditorGotoLine()); + ui->checkBox_MonitorProjectFiles->setChecked(porymapConfig.getMonitorFiles()); + ui->checkBox_OpenRecentProject->setChecked(porymapConfig.getReopenOnLaunch()); } void PreferenceEditor::saveFields() { @@ -53,8 +59,9 @@ void PreferenceEditor::saveFields() { } porymapConfig.setTextEditorOpenFolder(ui->lineEdit_TextEditorOpenFolder->text()); - porymapConfig.setTextEditorGotoLine(ui->lineEdit_TextEditorGotoLine->text()); + porymapConfig.setMonitorFiles(ui->checkBox_MonitorProjectFiles->isChecked()); + porymapConfig.setReopenOnLaunch(ui->checkBox_OpenRecentProject->isChecked()); emit preferencesSaved(); } diff --git a/src/ui/projectsettingseditor.cpp b/src/ui/projectsettingseditor.cpp new file mode 100644 index 00000000..45f79579 --- /dev/null +++ b/src/ui/projectsettingseditor.cpp @@ -0,0 +1,401 @@ +#include "projectsettingseditor.h" +#include "ui_projectsettingseditor.h" +#include "config.h" +#include "noscrollcombobox.h" +#include "prefab.h" + +#include +#include + +/* + Editor for the settings in a user's porymap.project.cfg file (and 'use_encounter_json' in porymap.user.cfg). +*/ + +ProjectSettingsEditor::ProjectSettingsEditor(QWidget *parent, Project *project) : + QMainWindow(parent), + ui(new Ui::ProjectSettingsEditor), + project(project), + baseDir(userConfig.getProjectDir() + QDir::separator()) +{ + ui->setupUi(this); + setAttribute(Qt::WA_DeleteOnClose); + this->initUi(); + this->createProjectPathsTable(); + this->connectSignals(); + this->refresh(); + this->restoreWindowState(); +} + +ProjectSettingsEditor::~ProjectSettingsEditor() +{ + delete ui; +} + +void ProjectSettingsEditor::connectSignals() { + connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &ProjectSettingsEditor::dialogButtonClicked); + connect(ui->button_ChoosePrefabs, &QAbstractButton::clicked, this, &ProjectSettingsEditor::choosePrefabsFileClicked); + connect(ui->button_ImportDefaultPrefabs, &QAbstractButton::clicked, this, &ProjectSettingsEditor::importDefaultPrefabsClicked); + connect(ui->comboBox_BaseGameVersion, &QComboBox::currentTextChanged, this, &ProjectSettingsEditor::promptRestoreDefaults); + connect(ui->comboBox_AttributesSize, &QComboBox::currentTextChanged, this, &ProjectSettingsEditor::updateAttributeLimits); + connect(ui->checkBox_EnableCustomBorderSize, &QCheckBox::stateChanged, [this](int state) { + bool customSize = (state == Qt::Checked); + // When switching between the spin boxes or line edit for border metatiles we set + // the newly-shown UI using the values from the hidden UI. + this->setBorderMetatileIds(customSize, this->getBorderMetatileIds(!customSize)); + this->setBorderMetatilesUi(customSize); + }); + + // Record that there are unsaved changes if any of the settings are modified + for (auto combo : ui->centralwidget->findChildren()) + connect(combo, &QComboBox::currentTextChanged, this, &ProjectSettingsEditor::markEdited); + for (auto checkBox : ui->centralwidget->findChildren()) + connect(checkBox, &QCheckBox::stateChanged, this, &ProjectSettingsEditor::markEdited); + for (auto lineEdit : ui->centralwidget->findChildren()) + connect(lineEdit, &QLineEdit::textEdited, this, &ProjectSettingsEditor::markEdited); + for (auto spinBox : ui->centralwidget->findChildren()) + connect(spinBox, &QSpinBox::textChanged, this, &ProjectSettingsEditor::markEdited); + for (auto spinBox : ui->centralwidget->findChildren()) + connect(spinBox, &UIntSpinBox::textChanged, this, &ProjectSettingsEditor::markEdited); +} + +void ProjectSettingsEditor::markEdited() { + // Don't treat signals emitted while the UI is refreshing as edits + if (!this->refreshing) + this->hasUnsavedChanges = true; +} + +void ProjectSettingsEditor::initUi() { + // Populate combo boxes + if (project) ui->comboBox_DefaultPrimaryTileset->addItems(project->primaryTilesetLabels); + if (project) ui->comboBox_DefaultSecondaryTileset->addItems(project->secondaryTilesetLabels); + ui->comboBox_BaseGameVersion->addItems(ProjectConfig::versionStrings); + ui->comboBox_AttributesSize->addItems({"1", "2", "4"}); + + // Validate that the border metatiles text is a comma-separated list of metatile values + const QString regex_Hex = "(0[xX])?[A-Fa-f0-9]+"; + static const QRegularExpression expression(QString("^(%1,)*%1$").arg(regex_Hex)); // Comma-separated list of hex values + QRegularExpressionValidator *validator = new QRegularExpressionValidator(expression); + ui->lineEdit_BorderMetatiles->setValidator(validator); + this->setBorderMetatilesUi(projectConfig.getUseCustomBorderSize()); + + int maxMetatileId = Project::getNumMetatilesTotal() - 1; + ui->spinBox_FillMetatile->setMaximum(maxMetatileId); + ui->spinBox_BorderMetatile1->setMaximum(maxMetatileId); + ui->spinBox_BorderMetatile2->setMaximum(maxMetatileId); + ui->spinBox_BorderMetatile3->setMaximum(maxMetatileId); + ui->spinBox_BorderMetatile4->setMaximum(maxMetatileId); + ui->spinBox_Elevation->setMaximum(15); +} + +void ProjectSettingsEditor::setBorderMetatilesUi(bool customSize) { + ui->widget_DefaultSizeBorderMetatiles->setVisible(!customSize); + ui->widget_CustomSizeBorderMetatiles->setVisible(customSize); +} + +void ProjectSettingsEditor::setBorderMetatileIds(bool customSize, QList metatileIds) { + if (customSize) { + ui->lineEdit_BorderMetatiles->setText(Metatile::getMetatileIdStringList(metatileIds)); + } else { + ui->spinBox_BorderMetatile1->setValue(metatileIds.value(0, 0x0)); + ui->spinBox_BorderMetatile2->setValue(metatileIds.value(1, 0x0)); + ui->spinBox_BorderMetatile3->setValue(metatileIds.value(2, 0x0)); + ui->spinBox_BorderMetatile4->setValue(metatileIds.value(3, 0x0)); + } +} + +QList ProjectSettingsEditor::getBorderMetatileIds(bool customSize) { + QList metatileIds; + if (customSize) { + // Custom border size, read metatiles from line edit + for (auto s : ui->lineEdit_BorderMetatiles->text().split(",")) { + uint16_t metatileId = s.toUInt(nullptr, 0); + metatileIds.append(qMin(metatileId, static_cast(Project::getNumMetatilesTotal() - 1))); + } + } else { + // Default border size, read metatiles from spin boxes + metatileIds.append(ui->spinBox_BorderMetatile1->value()); + metatileIds.append(ui->spinBox_BorderMetatile2->value()); + metatileIds.append(ui->spinBox_BorderMetatile3->value()); + metatileIds.append(ui->spinBox_BorderMetatile4->value()); + } + return metatileIds; +} + +void ProjectSettingsEditor::updateAttributeLimits(const QString &attrSize) { + QMap limits { + {"1", 0xFF}, + {"2", 0xFFFF}, + {"4", 0xFFFFFFFF}, + }; + uint32_t max = limits.value(attrSize, UINT_MAX); + ui->spinBox_BehaviorMask->setMaximum(max); + ui->spinBox_EncounterTypeMask->setMaximum(max); + ui->spinBox_LayerTypeMask->setMaximum(max); + ui->spinBox_TerrainTypeMask->setMaximum(max); +} + +void ProjectSettingsEditor::createProjectPathsTable() { + auto pathPairs = ProjectConfig::defaultPaths.values(); + for (auto pathPair : pathPairs) { + // Name of the path + auto name = new QLabel(); + name->setAlignment(Qt::AlignBottom); + name->setText(pathPair.first); + + // Filepath line edit + auto lineEdit = new QLineEdit(); + lineEdit->setObjectName(pathPair.first); // Used when saving the paths + lineEdit->setPlaceholderText(pathPair.second); + lineEdit->setClearButtonEnabled(true); + + // "Choose file" button + auto button = new QToolButton(); + button->setIcon(QIcon(":/icons/folder.ico")); + connect(button, &QAbstractButton::clicked, [this, lineEdit](bool) { + const QString path = this->chooseProjectFile(lineEdit->placeholderText()); + if (!path.isEmpty()) { + lineEdit->setText(path); + this->markEdited(); + } + }); + + // Add to list + auto editArea = new QWidget(); + auto layout = new QHBoxLayout(editArea); + layout->addWidget(lineEdit); + layout->addWidget(button); + ui->layout_ProjectPaths->addRow(name, editArea); + } +} + +QString ProjectSettingsEditor::chooseProjectFile(const QString &defaultFilepath) { + const QString startDir = this->baseDir + defaultFilepath; + + QString path; + if (defaultFilepath.endsWith("/")){ + // Default filepath is a folder, choose a new folder + path = QFileDialog::getExistingDirectory(this, "Choose Project File Folder", startDir) + QDir::separator(); + } else{ + // Default filepath is not a folder, choose a new file + path = QFileDialog::getOpenFileName(this, "Choose Project File", startDir); + } + + if (!path.startsWith(this->baseDir)){ + // Most of Porymap's file-parsing code for project files will assume that filepaths + // are relative to the root project folder, so we enforce that here. + QMessageBox::warning(this, "Failed to set custom filepath", + QString("Custom filepaths must be inside the root project folder '%1'").arg(this->baseDir)); + return QString(); + } + return path.remove(0, this->baseDir.length()); +} + +void ProjectSettingsEditor::restoreWindowState() { + logInfo("Restoring project settings editor geometry from previous session."); + const QMap geometry = porymapConfig.getProjectSettingsEditorGeometry(); + this->restoreGeometry(geometry.value("project_settings_editor_geometry")); + this->restoreState(geometry.value("project_settings_editor_state")); +} + +// Set UI states using config data +void ProjectSettingsEditor::refresh() { + this->refreshing = true; // Block signals + + // Set combo box texts + ui->comboBox_DefaultPrimaryTileset->setTextItem(projectConfig.getDefaultPrimaryTileset()); + ui->comboBox_DefaultSecondaryTileset->setTextItem(projectConfig.getDefaultSecondaryTileset()); + ui->comboBox_BaseGameVersion->setTextItem(projectConfig.getBaseGameVersionString()); + ui->comboBox_AttributesSize->setTextItem(QString::number(projectConfig.getMetatileAttributesSize())); + this->updateAttributeLimits(ui->comboBox_AttributesSize->currentText()); + + // Set check box states + ui->checkBox_UsePoryscript->setChecked(projectConfig.getUsePoryScript()); + ui->checkBox_ShowWildEncounterTables->setChecked(userConfig.getEncounterJsonActive()); + ui->checkBox_CreateTextFile->setChecked(projectConfig.getCreateMapTextFileEnabled()); + ui->checkBox_EnableTripleLayerMetatiles->setChecked(projectConfig.getTripleLayerMetatilesEnabled()); + ui->checkBox_EnableRequiresItemfinder->setChecked(projectConfig.getHiddenItemRequiresItemfinderEnabled()); + ui->checkBox_EnableQuantity->setChecked(projectConfig.getHiddenItemQuantityEnabled()); + ui->checkBox_EnableCloneObjects->setChecked(projectConfig.getEventCloneObjectEnabled()); + ui->checkBox_EnableWeatherTriggers->setChecked(projectConfig.getEventWeatherTriggerEnabled()); + ui->checkBox_EnableSecretBases->setChecked(projectConfig.getEventSecretBaseEnabled()); + ui->checkBox_EnableRespawn->setChecked(projectConfig.getHealLocationRespawnDataEnabled()); + ui->checkBox_EnableAllowFlags->setChecked(projectConfig.getMapAllowFlagsEnabled()); + ui->checkBox_EnableFloorNumber->setChecked(projectConfig.getFloorNumberEnabled()); + ui->checkBox_EnableCustomBorderSize->setChecked(projectConfig.getUseCustomBorderSize()); + ui->checkBox_OutputCallback->setChecked(projectConfig.getTilesetsHaveCallback()); + ui->checkBox_OutputIsCompressed->setChecked(projectConfig.getTilesetsHaveIsCompressed()); + + // Set spin box values + ui->spinBox_Elevation->setValue(projectConfig.getNewMapElevation()); + ui->spinBox_FillMetatile->setValue(projectConfig.getNewMapMetatileId()); + ui->spinBox_BehaviorMask->setValue(projectConfig.getMetatileBehaviorMask()); + ui->spinBox_EncounterTypeMask->setValue(projectConfig.getMetatileEncounterTypeMask()); + ui->spinBox_LayerTypeMask->setValue(projectConfig.getMetatileLayerTypeMask()); + ui->spinBox_TerrainTypeMask->setValue(projectConfig.getMetatileTerrainTypeMask()); + + // Set (and sync) border metatile IDs + auto metatileIds = projectConfig.getNewMapBorderMetatileIds(); + this->setBorderMetatileIds(false, metatileIds); + this->setBorderMetatileIds(true, metatileIds); + + // Set line edit texts + ui->lineEdit_PrefabsPath->setText(projectConfig.getPrefabFilepath()); + for (auto lineEdit : ui->scrollAreaContents_ProjectPaths->findChildren()) + lineEdit->setText(projectConfig.getFilePath(lineEdit->objectName(), true)); + + this->refreshing = false; // Allow signals +} + +void ProjectSettingsEditor::save() { + if (!this->hasUnsavedChanges) + return; + + // Prevent a call to save() for each of the config settings + projectConfig.setSaveDisabled(true); + + projectConfig.setDefaultPrimaryTileset(ui->comboBox_DefaultPrimaryTileset->currentText()); + projectConfig.setDefaultSecondaryTileset(ui->comboBox_DefaultSecondaryTileset->currentText()); + projectConfig.setBaseGameVersion(projectConfig.stringToBaseGameVersion(ui->comboBox_BaseGameVersion->currentText())); + projectConfig.setMetatileAttributesSize(ui->comboBox_AttributesSize->currentText().toInt()); + projectConfig.setUsePoryScript(ui->checkBox_UsePoryscript->isChecked()); + userConfig.setEncounterJsonActive(ui->checkBox_ShowWildEncounterTables->isChecked()); + projectConfig.setCreateMapTextFileEnabled(ui->checkBox_CreateTextFile->isChecked()); + projectConfig.setTripleLayerMetatilesEnabled(ui->checkBox_EnableTripleLayerMetatiles->isChecked()); + projectConfig.setHiddenItemRequiresItemfinderEnabled(ui->checkBox_EnableRequiresItemfinder->isChecked()); + projectConfig.setHiddenItemQuantityEnabled(ui->checkBox_EnableQuantity->isChecked()); + projectConfig.setEventCloneObjectEnabled(ui->checkBox_EnableCloneObjects->isChecked()); + projectConfig.setEventWeatherTriggerEnabled(ui->checkBox_EnableWeatherTriggers->isChecked()); + projectConfig.setEventSecretBaseEnabled(ui->checkBox_EnableSecretBases->isChecked()); + projectConfig.setHealLocationRespawnDataEnabled(ui->checkBox_EnableRespawn->isChecked()); + projectConfig.setMapAllowFlagsEnabled(ui->checkBox_EnableAllowFlags->isChecked()); + projectConfig.setFloorNumberEnabled(ui->checkBox_EnableFloorNumber->isChecked()); + projectConfig.setUseCustomBorderSize(ui->checkBox_EnableCustomBorderSize->isChecked()); + projectConfig.setTilesetsHaveCallback(ui->checkBox_OutputCallback->isChecked()); + projectConfig.setTilesetsHaveIsCompressed(ui->checkBox_OutputIsCompressed->isChecked()); + projectConfig.setNewMapElevation(ui->spinBox_Elevation->value()); + projectConfig.setNewMapMetatileId(ui->spinBox_FillMetatile->value()); + projectConfig.setMetatileBehaviorMask(ui->spinBox_BehaviorMask->value()); + projectConfig.setMetatileTerrainTypeMask(ui->spinBox_TerrainTypeMask->value()); + projectConfig.setMetatileEncounterTypeMask(ui->spinBox_EncounterTypeMask->value()); + projectConfig.setMetatileLayerTypeMask(ui->spinBox_LayerTypeMask->value()); + projectConfig.setPrefabFilepath(ui->lineEdit_PrefabsPath->text()); + for (auto lineEdit : ui->scrollAreaContents_ProjectPaths->findChildren()) + projectConfig.setFilePath(lineEdit->objectName(), lineEdit->text()); + projectConfig.setNewMapBorderMetatileIds(this->getBorderMetatileIds(ui->checkBox_EnableCustomBorderSize->isChecked())); + + projectConfig.setSaveDisabled(false); + projectConfig.save(); + this->hasUnsavedChanges = false; + + // Technically, a reload is not required for several of the config settings. + // For simplicity we prompt the user to reload when any setting is changed regardless. + this->projectNeedsReload = true; +} + +// Pick a file to use as the new prefabs file path +void ProjectSettingsEditor::choosePrefabsFileClicked(bool) { + QString startPath = this->project->importExportPath; + QFileInfo fileInfo(ui->lineEdit_PrefabsPath->text()); + if (fileInfo.exists() && fileInfo.isFile() && fileInfo.suffix() == "json") { + // Current setting is a valid JSON file. Start the file dialog there + startPath = fileInfo.dir().absolutePath(); + } + QString filepath = QFileDialog::getOpenFileName(this, "Choose Prefabs File", startPath, "JSON Files (*.json)"); + if (filepath.isEmpty()) + return; + this->project->setImportExportPath(filepath); + + // Display relative path if this file is in the project folder + if (filepath.startsWith(this->baseDir)) + filepath.remove(0, this->baseDir.length()); + ui->lineEdit_PrefabsPath->setText(filepath); + this->hasUnsavedChanges = true; +} + +void ProjectSettingsEditor::importDefaultPrefabsClicked(bool) { + // If the prompt is accepted the prefabs file will be created and its filepath will be saved in the config. + // No need to set hasUnsavedChanges here. + BaseGameVersion version = projectConfig.stringToBaseGameVersion(ui->comboBox_BaseGameVersion->currentText()); + if (prefab.tryImportDefaultPrefabs(this, version, ui->lineEdit_PrefabsPath->text())) { + ui->lineEdit_PrefabsPath->setText(projectConfig.getPrefabFilepath()); // Refresh with new filepath + this->projectNeedsReload = true; + } +} + +int ProjectSettingsEditor::prompt(const QString &text, QMessageBox::StandardButton defaultButton) { + QMessageBox messageBox(this); + messageBox.setText(text); + messageBox.setIcon(QMessageBox::Question); + messageBox.setStandardButtons(QMessageBox::Yes | QMessageBox::No | defaultButton); + messageBox.setDefaultButton(defaultButton); + return messageBox.exec(); +} + +bool ProjectSettingsEditor::promptSaveChanges() { + if (!this->hasUnsavedChanges) + return true; + + int result = this->prompt("Settings have been modified, save changes?", QMessageBox::Cancel); + if (result == QMessageBox::Cancel) + return false; + + if (result == QMessageBox::Yes) + this->save(); + else if (result == QMessageBox::No) + this->hasUnsavedChanges = false; // Discarding changes + + return true; +} + +bool ProjectSettingsEditor::promptRestoreDefaults() { + if (this->refreshing) + return false; + + const QString versionText = ui->comboBox_BaseGameVersion->currentText(); + if (this->prompt(QString("Restore default config settings for %1?").arg(versionText)) == QMessageBox::No) + return false; + + // Restore defaults by resetting config in memory, refreshing the UI, then restoring the config. + // Don't want to save changes until user accepts them. + ProjectConfig tempProject = projectConfig; + projectConfig.reset(projectConfig.stringToBaseGameVersion(versionText)); + this->refresh(); + projectConfig = tempProject; + + this->hasUnsavedChanges = true; + return true; +} + +void ProjectSettingsEditor::dialogButtonClicked(QAbstractButton *button) { + auto buttonRole = ui->buttonBox->buttonRole(button); + if (buttonRole == QDialogButtonBox::AcceptRole) { + // "OK" button + this->save(); + close(); + } else if (buttonRole == QDialogButtonBox::RejectRole) { + // "Cancel" button + if (!this->promptSaveChanges()) + return; + close(); + } else if (buttonRole == QDialogButtonBox::ResetRole) { + // "Restore Defaults" button + this->promptRestoreDefaults(); + } +} + +void ProjectSettingsEditor::closeEvent(QCloseEvent* event) { + if (!this->promptSaveChanges()) { + event->ignore(); + return; + } + + if (this->projectNeedsReload) { + if (this->prompt("Settings changed, reload project to apply changes?") == QMessageBox::Yes) + emit this->reloadProject(); + } + + porymapConfig.setProjectSettingsEditorGeometry( + this->saveGeometry(), + this->saveState() + ); +} diff --git a/src/ui/tileseteditor.cpp b/src/ui/tileseteditor.cpp index c16aeaf4..fe6b6736 100644 --- a/src/ui/tileseteditor.cpp +++ b/src/ui/tileseteditor.cpp @@ -68,7 +68,8 @@ void TilesetEditor::updateTilesets(QString primaryTilesetLabel, QString secondar } bool TilesetEditor::selectMetatile(uint16_t metatileId) { - if (!Tileset::metatileIsValid(metatileId, this->primaryTileset, this->secondaryTileset)) return false; + if (!Tileset::metatileIsValid(metatileId, this->primaryTileset, this->secondaryTileset) || this->lockSelection) + return false; this->metatileSelector->select(metatileId); QPoint pos = this->metatileSelector->getMetatileIdCoordsOnWidget(metatileId); this->ui->scrollArea_Metatiles->ensureVisible(pos.x(), pos.y()); @@ -116,6 +117,7 @@ void TilesetEditor::setAttributesUi() { for (int num : project->metatileBehaviorMapInverse.keys()) { this->ui->comboBox_metatileBehaviors->addItem(project->metatileBehaviorMapInverse[num], num); } + this->ui->comboBox_metatileBehaviors->setMinimumContentsLength(0); } else { this->ui->comboBox_metatileBehaviors->setVisible(false); this->ui->label_metatileBehavior->setVisible(false); @@ -127,6 +129,8 @@ void TilesetEditor::setAttributesUi() { this->ui->comboBox_terrainType->addItem("Grass", TERRAIN_GRASS); this->ui->comboBox_terrainType->addItem("Water", TERRAIN_WATER); this->ui->comboBox_terrainType->addItem("Waterfall", TERRAIN_WATERFALL); + this->ui->comboBox_terrainType->setEditable(false); + this->ui->comboBox_terrainType->setMinimumContentsLength(0); } else { this->ui->comboBox_terrainType->setVisible(false); this->ui->label_terrainType->setVisible(false); @@ -137,6 +141,8 @@ void TilesetEditor::setAttributesUi() { this->ui->comboBox_encounterType->addItem("None", ENCOUNTER_NONE); this->ui->comboBox_encounterType->addItem("Land", ENCOUNTER_LAND); this->ui->comboBox_encounterType->addItem("Water", ENCOUNTER_WATER); + this->ui->comboBox_encounterType->setEditable(false); + this->ui->comboBox_encounterType->setMinimumContentsLength(0); } else { this->ui->comboBox_encounterType->setVisible(false); this->ui->label_encounterType->setVisible(false); @@ -147,6 +153,8 @@ void TilesetEditor::setAttributesUi() { this->ui->comboBox_layerType->addItem("Normal - Middle/Top", METATILE_LAYER_MIDDLE_TOP); this->ui->comboBox_layerType->addItem("Covered - Bottom/Middle", METATILE_LAYER_BOTTOM_MIDDLE); this->ui->comboBox_layerType->addItem("Split - Bottom/Top", METATILE_LAYER_BOTTOM_TOP); + this->ui->comboBox_layerType->setEditable(false); + this->ui->comboBox_layerType->setMinimumContentsLength(0); if (!Metatile::getLayerTypeMask()) { // User doesn't have triple layer metatiles, but has no layer type attribute. // Porymap is still using the layer type value to render metatiles, and with @@ -344,8 +352,7 @@ void TilesetEditor::drawSelectedTiles() { void TilesetEditor::onHoveredMetatileChanged(uint16_t metatileId) { QString label = Tileset::getMetatileLabel(metatileId, this->primaryTileset, this->secondaryTileset); - QString hexString = QString("%1").arg(metatileId, 3, 16, QChar('0')).toUpper(); - QString message = QString("Metatile: 0x%1").arg(hexString); + QString message = QString("Metatile: %1").arg(Metatile::getMetatileIdString(metatileId)); if (label.size() != 0) { message += QString(" \"%1\"").arg(label); } @@ -356,24 +363,6 @@ void TilesetEditor::onHoveredMetatileCleared() { this->ui->statusbar->clearMessage(); } -void TilesetEditor::setComboValue(QComboBox * combo, int value) { - int index = combo->findData(value); - if (index >= 0) { - // Valid item - combo->setCurrentIndex(index); - } else if (combo->isEditable()) { - // Invalid item in editable box, just display the text - combo->setCurrentText(QString::number(value)); - } else { - // Invalid item in uneditable box, display text as placeholder - // On Qt < 5.15 this will display an empty box -#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)) - combo->setPlaceholderText(QString::number(value)); -#endif - combo->setCurrentIndex(index); - } -} - void TilesetEditor::onSelectedMetatileChanged(uint16_t metatileId) { this->metatile = Tileset::getMetatile(metatileId, this->primaryTileset, this->secondaryTileset); this->metatileLayersItem->setMetatile(metatile); @@ -384,10 +373,10 @@ void TilesetEditor::onSelectedMetatileChanged(uint16_t metatileId) { this->ui->lineEdit_metatileLabel->setText(labels.owned); this->ui->lineEdit_metatileLabel->setPlaceholderText(labels.shared); - setComboValue(this->ui->comboBox_metatileBehaviors, this->metatile->behavior); - setComboValue(this->ui->comboBox_layerType, this->metatile->layerType); - setComboValue(this->ui->comboBox_encounterType, this->metatile->encounterType); - setComboValue(this->ui->comboBox_terrainType, this->metatile->terrainType); + this->ui->comboBox_metatileBehaviors->setNumberItem(this->metatile->behavior); + this->ui->comboBox_layerType->setNumberItem(this->metatile->layerType); + this->ui->comboBox_encounterType->setNumberItem(this->metatile->encounterType); + this->ui->comboBox_terrainType->setNumberItem(this->metatile->terrainType); } void TilesetEditor::onHoveredTileChanged(uint16_t tile) { @@ -592,18 +581,17 @@ void TilesetEditor::on_comboBox_terrainType_activated(int terrainType) void TilesetEditor::on_actionSave_Tileset_triggered() { - // need this temporary metatile ID to reset selection after saving - // when the tilesetsSaved signal is sent, it will be reset to the current map metatile - uint16_t reselectMetatileID = this->metatileSelector->getSelectedMetatileId(); - + // Need this temporary flag to stop selection resetting after saving. + // This is a workaround; redrawing the map's metatile selector shouldn't emit the same signal as when it's selected. + this->lockSelection = true; this->project->saveTilesets(this->primaryTileset, this->secondaryTileset); emit this->tilesetsSaved(this->primaryTileset->name, this->secondaryTileset->name); - this->metatileSelector->select(reselectMetatileID); if (this->paletteEditor) { this->paletteEditor->setTilesets(this->primaryTileset, this->secondaryTileset); } this->ui->statusbar->showMessage(QString("Saved primary and secondary Tilesets!"), 5000); this->hasUnsavedChanges = false; + this->lockSelection = false; } void TilesetEditor::on_actionImport_Primary_Tiles_triggered() @@ -701,15 +689,6 @@ void TilesetEditor::importTilesetTiles(Tileset *tileset, bool primary) { msgBox.setIcon(QMessageBox::Icon::Critical); msgBox.exec(); return; - } else if (palette.length() != 16) { - QMessageBox msgBox(this); - msgBox.setText("Failed to import palette."); - QString message = QString("The palette must have exactly 16 colors, but it has %1.").arg(palette.length()); - msgBox.setInformativeText(message); - msgBox.setDefaultButton(QMessageBox::Ok); - msgBox.setIcon(QMessageBox::Icon::Critical); - msgBox.exec(); - return; } QVector colorTable = palette.toVector(); @@ -717,20 +696,21 @@ void TilesetEditor::importTilesetTiles(Tileset *tileset, bool primary) { } // Validate image is properly indexed to 16 colors. - if (image.colorCount() != 16) { - QMessageBox msgBox(this); - msgBox.setText("Failed to import tiles."); - msgBox.setInformativeText(QString("The image must be indexed and contain 16 total colors, or it must be un-indexed. The provided image has %1 indexed colors.") - .arg(image.colorCount())); - msgBox.setDefaultButton(QMessageBox::Ok); - msgBox.setIcon(QMessageBox::Icon::Critical); - msgBox.exec(); - return; + int colorCount = image.colorCount(); + if (colorCount > 16) { + flattenTo4bppImage(&image); + } else if (colorCount < 16) { + QVector colorTable = image.colorTable(); + for (int i = colorTable.length(); i < 16; i++) { + colorTable.append(Qt::black); + } + image.setColorTable(colorTable); } this->project->loadTilesetTiles(tileset, image); this->refresh(); this->hasUnsavedChanges = true; + tileset->hasUnsavedTilesImage = true; } void TilesetEditor::closeEvent(QCloseEvent *event) diff --git a/src/ui/uintspinbox.cpp b/src/ui/uintspinbox.cpp new file mode 100644 index 00000000..31f66ba9 --- /dev/null +++ b/src/ui/uintspinbox.cpp @@ -0,0 +1,178 @@ +#include "uintspinbox.h" + +UIntSpinBox::UIntSpinBox(QWidget *parent) + : QAbstractSpinBox(parent) +{ + // Don't let scrolling hijack focus. + setFocusPolicy(Qt::StrongFocus); + + m_minimum = 0; + m_maximum = 99; + m_value = m_minimum; + m_displayIntegerBase = 10; + m_numChars = 2; + m_hasPadding = false; + + this->updateEdit(); + connect(lineEdit(), SIGNAL(textEdited(QString)), this, SLOT(onEditFinished())); +}; + +void UIntSpinBox::setValue(uint32_t val) { + if (m_value != val) { + m_value = val; + emit valueChanged(m_value); + } + this->updateEdit(); +} + +uint32_t UIntSpinBox::valueFromText(const QString &text) const { + return this->stripped(text).toUInt(nullptr, m_displayIntegerBase); +} + +QString UIntSpinBox::textFromValue(uint32_t val) const { + if (m_hasPadding) + return m_prefix + QString("%1").arg(val, m_numChars, m_displayIntegerBase, QChar('0')).toUpper(); + + return m_prefix + QString::number(val, m_displayIntegerBase).toUpper(); +} + +void UIntSpinBox::setMinimum(uint32_t min) { + this->setRange(min, qMax(min, m_maximum)); +} + +void UIntSpinBox::setMaximum(uint32_t max) { + this->setRange(qMin(m_minimum, max), max); +} + +void UIntSpinBox::setRange(uint32_t min, uint32_t max) { + max = qMax(min, max); + + if (m_maximum == max && m_minimum == min) + return; + + if (m_maximum != max) { + // Update number of characters for padding + m_numChars = 0; + for (uint32_t i = max; i != 0; i /= m_displayIntegerBase) + m_numChars++; + } + + m_minimum = min; + m_maximum = max; + + if (m_value < min) + m_value %= min; + else if (m_value > max) + m_value %= max; + this->updateEdit(); +} + +void UIntSpinBox::setPrefix(const QString &prefix) { + if (m_prefix != prefix) { + m_prefix = prefix; + this->updateEdit(); + } +} + +void UIntSpinBox::setDisplayIntegerBase(int base) { + if (base < 2 || base > 36) + base = 10; + if (m_displayIntegerBase != base) { + m_displayIntegerBase = base; + this->updateEdit(); + } +} + +void UIntSpinBox::setHasPadding(bool enabled) { + if (m_hasPadding != enabled) { + m_hasPadding = enabled; + this->updateEdit(); + } +} + +void UIntSpinBox::updateEdit() { + const QString text = this->textFromValue(m_value); + if (text != this->lineEdit()->text()) { + this->lineEdit()->setText(text); + emit textChanged(this->lineEdit()->text()); + } +} + +void UIntSpinBox::onEditFinished() { + int pos = this->lineEdit()->cursorPosition(); + QString input = this->lineEdit()->text(); + + auto state = this->validate(input, pos); + if (state == QValidator::Acceptable) { + // Valid input + m_value = this->valueFromText(input); + } else if (state == QValidator::Intermediate) { + // User has deleted all the number text. + // If they did this by selecting all text and then hitting delete + // make sure to put the cursor back in front of the prefix. + m_value = m_minimum; + this->lineEdit()->setCursorPosition(m_prefix.length()); + } +} + +void UIntSpinBox::stepBy(int steps) +{ + auto new_value = m_value; + if (steps < 0 && new_value + steps > new_value) { + new_value = 0; + } else if (steps > 0 && new_value + steps < new_value) { + new_value = UINT_MAX; + } else { + new_value += steps; + } + this->setValue(new_value); +} + +QString UIntSpinBox::stripped(QString input) const { + if (m_prefix.length() && input.startsWith(m_prefix)) + input.remove(0, m_prefix.length()); + return input.trimmed(); +} + +QValidator::State UIntSpinBox::validate(QString &input, int &pos) const { + QString copy(input); + input = m_prefix; + + // Adjust for prefix + copy = this->stripped(copy); + if (copy.isEmpty()) + return QValidator::Intermediate; + + // Editing the prefix (if not deleting all text) is not allowed + if (pos < m_prefix.length()) + return QValidator::Invalid; + + bool ok; + uint32_t num = copy.toUInt(&ok, m_displayIntegerBase); + if (!ok || num < m_minimum || num > m_maximum) + return QValidator::Invalid; + + input += copy.toUpper(); + return QValidator::Acceptable; +} + +QAbstractSpinBox::StepEnabled UIntSpinBox::stepEnabled() const { + QAbstractSpinBox::StepEnabled flags = QAbstractSpinBox::StepNone; + if (m_value < m_maximum) + flags |= QAbstractSpinBox::StepUpEnabled; + if (m_value > m_minimum) + flags |= QAbstractSpinBox::StepDownEnabled; + + return flags; +} + +void UIntSpinBox::wheelEvent(QWheelEvent *event) { + // Only allow scrolling to modify contents when it explicitly has focus. + if (hasFocus()) + QAbstractSpinBox::wheelEvent(event); +} + +void UIntSpinBox::focusOutEvent(QFocusEvent *event) { + this->updateEdit(); + QAbstractSpinBox::focusOutEvent(event); +}