├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── assets ├── OpenSans-Regular.ttf ├── agent_campaign_info.ron ├── campaign_01.ron ├── objects.ron ├── scenario_01.ron ├── settings.ron └── sprites.ron ├── assets_src ├── atlas.svg └── export_ids ├── src ├── assets.rs ├── core.rs ├── core │ ├── battle.rs │ ├── battle │ │ ├── ability.rs │ │ ├── ai.rs │ │ ├── check.rs │ │ ├── command.rs │ │ ├── component.rs │ │ ├── effect.rs │ │ ├── event.rs │ │ ├── execute.rs │ │ ├── movement.rs │ │ ├── scenario.rs │ │ ├── state.rs │ │ ├── state │ │ │ ├── apply.rs │ │ │ └── private.rs │ │ └── tests.rs │ ├── campaign.rs │ ├── map.rs │ └── utils.rs ├── error.rs ├── geom.rs ├── main.rs ├── screen.rs ├── screen │ ├── agent_info.rs │ ├── battle.rs │ ├── battle │ │ ├── view.rs │ │ └── visualize.rs │ ├── campaign.rs │ ├── confirm.rs │ ├── general_info.rs │ └── main_menu.rs └── utils.rs ├── utils ├── assets_export.sh └── wasm │ ├── build.sh │ └── index.html ├── zcomponents ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md └── src │ └── lib.rs ├── zemeroth.svg ├── zgui ├── Cargo.toml ├── README.md ├── assets │ ├── Karla-Regular.ttf │ └── fire.png ├── examples │ ├── absolute_coordinates.rs │ ├── common │ │ └── mod.rs │ ├── layers_layout.rs │ ├── nested.rs │ ├── pixel_coordinates.rs │ ├── remove.rs │ └── text_button.rs └── src │ └── lib.rs └── zscene ├── Cargo.toml ├── README.md ├── assets ├── Karla-Regular.ttf └── fire.png ├── examples └── action.rs └── src ├── action.rs ├── action ├── change_color_to.rs ├── custom.rs ├── empty.rs ├── fork.rs ├── hide.rs ├── move_by.rs ├── sequence.rs ├── set_color.rs ├── set_facing.rs ├── set_frame.rs ├── show.rs └── sleep.rs ├── lib.rs └── sprite.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: ozkriff 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | lints: 7 | name: Lints 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Install packages 15 | run: sudo apt -yq --no-install-suggests --no-install-recommends install libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev 16 | 17 | - name: Install rust 18 | uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: stable 21 | components: clippy, rustfmt 22 | profile: minimal 23 | override: true 24 | 25 | - run: cargo fmt --all -- --check 26 | 27 | - run: cargo clippy --all --tests -- -D warnings 28 | 29 | build: 30 | name: ${{ matrix.build }} 31 | runs-on: ${{ matrix.os }} 32 | 33 | strategy: 34 | matrix: 35 | build: [Linux, macOS, Win32, Win64, Linux-beta] 36 | 37 | include: 38 | - build: Linux 39 | os: ubuntu-latest 40 | - build: macOS 41 | os: macOS-latest 42 | - build: Win32 43 | os: windows-latest 44 | rust: stable-i686-pc-windows-msvc 45 | target: i686-pc-windows-msvc 46 | - build: Win64 47 | os: windows-latest 48 | - build: Linux-beta 49 | os: ubuntu-latest 50 | rust: beta 51 | 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v2 55 | 56 | - name: Cache 57 | uses: actions/cache@v2 58 | with: 59 | path: | 60 | ~/.cargo/registry 61 | ~/.cargo/git 62 | ~/.cargo/bin/resvg 63 | target 64 | key: ${{ runner.os }}-${{ matrix.build }}-cargo-${{ hashFiles('**/Cargo.lock') }} 65 | 66 | - name: Install rust 67 | uses: actions-rs/toolchain@v1 68 | with: 69 | toolchain: ${{ matrix.rust || 'stable' }} 70 | target: ${{ matrix.target }} 71 | profile: minimal 72 | override: true 73 | 74 | - name: Install packages (Linux) 75 | if: runner.os == 'Linux' 76 | run: | 77 | sudo apt-get -yq --no-install-suggests --no-install-recommends install libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev 78 | 79 | - name: Install resvg 80 | shell: bash 81 | run: | 82 | if ! command -v resvg &> /dev/null; then 83 | if [ "$RUNNER_OS" == "Windows" ]; then 84 | curl -sL https://github.com/RazrFalcon/resvg/releases/download/v0.11.0/viewsvg-win.zip -O 85 | 7z x viewsvg-win.zip 86 | mv resvg ~/.cargo/bin 87 | else 88 | cargo install resvg 89 | fi 90 | fi 91 | 92 | - name: Export assets 93 | shell: bash 94 | run: | 95 | ./utils/assets_export.sh 96 | ls -lR assets 97 | 98 | - name: Build 99 | uses: actions-rs/cargo@v1 100 | with: 101 | command: build 102 | args: --examples --all 103 | 104 | - name: Test 105 | uses: actions-rs/cargo@v1 106 | with: 107 | command: test 108 | args: --all 109 | 110 | wasm: 111 | name: WASM 112 | runs-on: ubuntu-latest 113 | 114 | steps: 115 | - name: Checkout 116 | uses: actions/checkout@v2 117 | 118 | - name: Install rust 119 | uses: actions-rs/toolchain@v1 120 | with: 121 | toolchain: stable 122 | target: wasm32-unknown-unknown 123 | profile: minimal 124 | override: true 125 | 126 | - name: Build 127 | run: ./utils/wasm/build.sh 128 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | build: [linux, macos, win32, win64] 14 | 15 | include: 16 | - build: linux 17 | os: ubuntu-latest 18 | target: x86_64-unknown-linux-gnu 19 | bin: zemeroth 20 | archive: .tar.gz 21 | type: application/gzip 22 | - build: macos 23 | os: macOS-latest 24 | target: x86_64-apple-darwin 25 | bin: zemeroth 26 | archive: .tar.gz 27 | type: application/gzip 28 | - build: win32 29 | os: windows-latest 30 | rust: stable-i686-pc-windows-msvc 31 | target: i686-pc-windows-msvc 32 | bin: zemeroth.exe 33 | archive: .zip 34 | type: application/zip 35 | - build: win64 36 | os: windows-latest 37 | target: x86_64-pc-windows-msvc 38 | bin: zemeroth.exe 39 | archive: .zip 40 | type: application/zip 41 | 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v2 45 | 46 | - name: Install packages (Linux) 47 | if: runner.os == 'Linux' 48 | run: | 49 | sudo apt-get -yq --no-install-suggests --no-install-recommends install libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev 50 | 51 | - name: Install rust 52 | uses: actions-rs/toolchain@v1 53 | with: 54 | toolchain: ${{ matrix.rust || 'stable' }} 55 | target: ${{ matrix.target }} 56 | profile: minimal 57 | override: true 58 | 59 | - name: Install resvg 60 | shell: bash 61 | run: | 62 | if [ "$RUNNER_OS" == "Linux" ]; then 63 | cargo install resvg 64 | elif [ "$RUNNER_OS" == "macOS" ]; then 65 | cargo install resvg 66 | elif [ "$RUNNER_OS" == "Windows" ]; then 67 | curl -sL https://github.com/RazrFalcon/resvg/releases/download/v0.11.0/viewsvg-win.zip -O 68 | 7z x viewsvg-win.zip 69 | mv resvg ~/.cargo/bin 70 | else 71 | echo "$RUNNER_OS not supported" 72 | exit 1 73 | fi 74 | 75 | - name: Export assets 76 | shell: bash 77 | run: | 78 | ./utils/assets_export.sh 79 | ls -lR assets 80 | 81 | - name: Build 82 | uses: actions-rs/cargo@v1 83 | with: 84 | command: build 85 | args: --release --target ${{ matrix.target }} 86 | 87 | - name: Package 88 | id: package 89 | shell: bash 90 | env: 91 | BUILD_NAME: ${{ matrix.build }} 92 | ARCHIVE_EXT: ${{ matrix.archive }} 93 | run: | 94 | name=zemeroth 95 | tag=$(git describe --tags --abbrev=0) 96 | release_name="$name-$tag-$BUILD_NAME" 97 | release_file="${release_name}${ARCHIVE_EXT}" 98 | mkdir "$release_name" 99 | 100 | if [ "${{ runner.os }}" = "Linux" ]; then 101 | strip -s "target/${{ matrix.target }}/release/${{ matrix.bin }}" 102 | elif [ "${{ runner.os }}" = "macOS" ]; then 103 | strip "target/${{ matrix.target }}/release/${{ matrix.bin }}" 104 | fi 105 | 106 | cp target/${{ matrix.target }}/release/${{ matrix.bin }} "$release_name" 107 | cp -r README.md assets "$release_name" 108 | 109 | if [ "${{ runner.os }}" = "Windows" ]; then 110 | 7z a "$release_file" "$release_name" 111 | else 112 | tar czvf "$release_file" "$release_name" 113 | fi 114 | 115 | echo "::set-output name=asset_name::$release_file" 116 | echo "::set-output name=asset_path::$release_file" 117 | 118 | - name: Upload 119 | uses: actions/upload-release-asset@v1.0.1 120 | env: 121 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 122 | with: 123 | upload_url: ${{ github.event.release.upload_url }} 124 | asset_name: ${{ steps.package.outputs.asset_name }} 125 | asset_path: ${{ steps.package.outputs.asset_path }} 126 | asset_content_type: ${{ matrix.type }} 127 | 128 | wasm: 129 | name: build (web) 130 | runs-on: ubuntu-latest 131 | 132 | steps: 133 | - name: Checkout 134 | uses: actions/checkout@v2 135 | 136 | - name: Install Rust 137 | uses: actions-rs/toolchain@v1 138 | with: 139 | toolchain: stable 140 | target: wasm32-unknown-unknown 141 | profile: minimal 142 | override: true 143 | 144 | - name: Install resvg 145 | run: cargo install resvg 146 | 147 | - name: Export assets 148 | run: ./utils/assets_export.sh 149 | 150 | - name: Build 151 | run: ./utils/wasm/build.sh 152 | 153 | - name: Package 154 | id: package 155 | run: | 156 | name=zemeroth 157 | tag=$(git describe --tags --abbrev=0) 158 | release_name="$name-$tag-web" 159 | release_file="${release_name}.zip" 160 | mkdir "$release_name" 161 | 162 | cp README.md static/* "$release_name" 163 | 164 | 7z a "$release_file" "$release_name" 165 | 166 | echo "::set-output name=asset_name::$release_file" 167 | echo "::set-output name=asset_path::$release_file" 168 | 169 | - name: Upload 170 | uses: actions/upload-release-asset@v1.0.1 171 | env: 172 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 173 | with: 174 | upload_url: ${{ github.event.release.upload_url }} 175 | asset_name: ${{ steps.package.outputs.asset_name }} 176 | asset_path: ${{ steps.package.outputs.asset_path }} 177 | asset_content_type: application/zip 178 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /assets/img 3 | /static 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # This project uses rustfmt with default options. 2 | 3 | # Let's just make sure that files use `\n` line endings. 4 | newline_style = "Unix" 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zemeroth" 3 | version = "0.7.0-snapshot" 4 | authors = ["Andrey Lesnikov "] 5 | edition = "2018" 6 | license = "MIT/Apache-2.0" 7 | description = "A 2D turn-based hexagonal tactical game." 8 | 9 | [profile.dev.package."*"] 10 | opt-level = 3 11 | 12 | [workspace] 13 | members = ["zcomponents", "zgui", "zscene"] 14 | 15 | [package.metadata.android] 16 | assets = "assets/" 17 | 18 | [dependencies] 19 | ron = "0.6" 20 | log = "0.4" 21 | env_logger = "0.9" 22 | derive_more = { version = "0.99", features = ["from"] } 23 | serde = { version = "1.0", features = ["derive"] } 24 | num = { version = "0.4", default-features = false } 25 | ui = { path = "zgui", package = "zgui" } 26 | zscene = { path = "zscene" } 27 | zcomponents = { path = "zcomponents" } 28 | rand = { version = "0.8", default-features = false, features = ["alloc"] } 29 | quad-rand = { version = "0.2", features = ["rand"] } 30 | mq = { package = "macroquad", version = "0.3" } 31 | heck = "0.3" 32 | once_cell = "1.6" 33 | 34 | [dev-dependencies] 35 | pretty_assertions = "0.7" 36 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT/X Consortium License 2 | 3 | @ 2017-2021 Andrey Lesnikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 18 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![title image](zemeroth.svg) 2 | 3 | [![Github Actions][img_gh-actions]][gh-actions] 4 | [![dependency status][img_deps-rs]][deps-rs] 5 | [![mit license][img_license]](#license) 6 | [![line count][img_loc]][loc] 7 | 8 | [img_license]: https://img.shields.io/badge/License-MIT_or_Apache_2.0-blue.svg 9 | [img_loc]: https://tokei.rs/b1/github/ozkriff/zemeroth 10 | [img_gh-actions]: https://github.com/ozkriff/zemeroth/workflows/CI/badge.svg 11 | [img_deps-rs]: https://deps.rs/repo/github/ozkriff/zemeroth/status.svg 12 | 13 | [loc]: https://github.com/Aaronepower/tokei 14 | [gh-actions]: https://github.com/ozkriff/zemeroth/actions?query=workflow%3ACI 15 | [deps-rs]: https://deps.rs/repo/github/ozkriff/zemeroth 16 | 17 | Zemeroth is a turn-based hexagonal tactical game written in [Rust]. 18 | 19 | [Rust]: https://www.rust-lang.org 20 | 21 | **Support**: [**patreon.com/ozkriff**](https://patreon.com/ozkriff) 22 | 23 | **News**: [@ozkriff on twitter](https://twitter.com/ozkriff) | 24 | [ozkriff.games](https://ozkriff.games) | 25 | [facebook](https://fb.com/ozkriff.games) | 26 | [devlog on imgur](https://imgur.com/a/SMVqO) 27 | 28 | ## Online Version 29 | 30 | You can play an online WebAssembly version of Zemeroth at 31 | [ozkriff.itch.io/zemeroth](https://ozkriff.itch.io/zemeroth) 32 | 33 | ## Precompiled Binaries 34 | 35 | Precompiled binaries for Linux, Windows and macOS: 36 | [github.com/ozkriff/zemeroth/releases](https://github.com/ozkriff/zemeroth/releases) 37 | 38 | ## Screenshots 39 | 40 | !["big" screenshot](https://i.imgur.com/iUkyqIq.png) 41 | 42 | !["campaign" screenshot](https://i.imgur.com/rQkH5y2.png) 43 | 44 | ![web version of a phone](https://i.imgur.com/cviYFkY.jpg) 45 | 46 | ## Gifs 47 | 48 | ![main gameplay animation](https://i.imgur.com/HqgHmOH.gif) 49 | 50 | ## Videos 51 | 52 | [youtube.com/c/andreylesnikov/videos](https://youtube.com/c/andreylesnikov/videos) 53 | 54 | ## Vision 55 | 56 | [The initial vision](https://ozkriff.github.io/2017-08-17--devlog/index.html#zemeroth) 57 | of the project is: 58 | 59 | - Random-based skirmish-level digital tabletop game; 60 | - Single player only; 61 | - 3-6 fighters under player’s control; 62 | - Small unscrollable maps; 63 | - Relatively short game session (under an hour); 64 | - Simple vector 2d graphics with just 3-5 sprites per unit; 65 | - Reaction attacks and action’s interruption; 66 | - Highly dynamic (lots of small unit moves as a side effect of other events); 67 | - Intentionally stupid and predictable AI; 68 | 69 | ## Roadmap 70 | 71 | - [ ] Phase One: Linear Campaign Mode 72 | 73 | An extended prototype focused just on tactical battles. 74 | 75 | - [x] [v0.4](https://github.com/ozkriff/zemeroth/projects/1) 76 | - [x] Basic gameplay with reaction attacks 77 | - [x] Minimal text-based GUI 78 | - [x] Basic agent abilities: jumps, bombs, dashes, etc 79 | - [x] [v0.5](https://github.com/ozkriff/zemeroth/projects/2) 80 | - [x] Basic campaign mode 81 | - [x] Armor and Break stats ([#70](https://github.com/ozkriff/zemeroth/issues/70)) 82 | - [x] Dynamic blood splatters ([#86](https://github.com/ozkriff/zemeroth/issues/86)) 83 | - [x] Web version 84 | - [x] Tests 85 | - [x] Hit chances 86 | - [x] [v0.6](https://github.com/ozkriff/zemeroth/projects/3) 87 | - [x] Agent upgrades ([#399](https://github.com/ozkriff/zemeroth/issues/399)) 88 | - [x] Flip agent sprites horizontally when needed ([#115](https://github.com/ozkriff/zemeroth/issues/115)) 89 | - [x] Multiple sprites per agent type ([#114](https://github.com/ozkriff/zemeroth/issues/114)) 90 | - [ ] GUI icons ([#276](https://github.com/ozkriff/zemeroth/issues/276)) 91 | - [ ] Sound & Music ([#221](https://github.com/ozkriff/zemeroth/issues/221)) 92 | - [ ] Reduce text overlapping ([#214](https://github.com/ozkriff/zemeroth/issues/214)) 93 | - [ ] Move back after a successful dodge ([#117](https://github.com/ozkriff/zemeroth/issues/117)) 94 | - [ ] Easing ([#26](https://github.com/ozkriff/zemeroth/issues/26)) 95 | - [ ] Path selection ([#280](https://github.com/ozkriff/zemeroth/issues/280), 96 | [#219](https://github.com/ozkriff/zemeroth/issues/219)) 97 | - [ ] Intermediate bosses 98 | - [ ] Main boss 99 | - [ ] Neutral agents ([#393](https://github.com/ozkriff/zemeroth/issues/393)) 100 | - [ ] Weight component ([#291](https://github.com/ozkriff/zemeroth/issues/291)) 101 | - [ ] Basic inventory system: slots for artifacts 102 | - [ ] Ranged units 103 | - [ ] More agent types 104 | - [ ] More passive abilities that allow agents to make actions 105 | during enemy's turn 106 | ([#354](https://github.com/ozkriff/zemeroth/issues/354)) 107 | - [ ] More complex multieffect abilities/actions 108 | - [ ] Guide ([#451](https://github.com/ozkriff/zemeroth/issues/451)) 109 | - [ ] Save/load ([#28](https://github.com/ozkriff/zemeroth/issues/28)) 110 | - [ ] Android version 111 | 112 | - [ ] Phase Two: Strategy Mode 113 | 114 | A not-so-linear strategic layer will be added on top of tactical battles. 115 | Simple non-linear story and meta-gameplay. 116 | 117 | - [ ] Global map 118 | - [ ] Dialog system 119 | - [ ] Quest system 120 | - [ ] NPC/Agent/Masters system 121 | 122 | ## Inspiration 123 | 124 | Tactical battle mechanics are mostly inspired by these games: 125 | 126 | - [ENYO](https://play.google.com/store/apps/details?id=com.tinytouchtales.enyo) 127 | - [Hoplite](https://play.google.com/store/apps/details?id=com.magmafortress.hoplite) 128 | - [Into the Breach](https://store.steampowered.com/app/590380/Into_the_Breach) 129 | - [Banner Saga](https://store.steampowered.com/app/237990/The_Banner_Saga) 130 | (Survival Mode) 131 | - [Auro](https://store.steampowered.com/app/459680/Auro_A_MonsterBumping_Adventure) 132 | - [Minos Strategos](https://store.steampowered.com/app/577490/Minos_Strategos) 133 | - [Battle Brothers](https://store.steampowered.com/app/365360/Battle_Brothers) 134 | 135 | ## Building from Source 136 | 137 | Install all [miniquad's system dependencies][mq_sys_deps]. 138 | 139 | ```bash 140 | cargo install resvg 141 | ./utils/assets_export.sh 142 | cargo run 143 | ``` 144 | 145 | [mq_sys_deps]: https://github.com/not-fl3/miniquad/tree/b8c347b1bbuilding-examples 146 | 147 | ## WebAssembly 148 | 149 | ```bash 150 | cargo install resvg 151 | ./utils/assets_export.sh 152 | rustup target add wasm32-unknown-unknown 153 | ./utils/wasm/build.sh 154 | cargo install basic-http-server 155 | basic-http-server static 156 | ``` 157 | 158 | Then open `http://localhost:4000` in your browser. 159 | 160 | ## Dependencies 161 | 162 | The key external dependency of Zemeroth is [macroquad]/[miniquad]. 163 | 164 | This repo contains a bunch of helper crates: 165 | 166 | - [zcomponents] is a simple component storage 167 | - [zgui] is a simple and opinionated GUI library 168 | - [zscene] is a simple scene and declarative animation manager 169 | 170 | Also, [resvg] is used for exporting sprites from svg. 171 | 172 | [macroquad]: https://github.com/not-fl3/macroquad 173 | [miniquad]: https://github.com/not-fl3/miniquad 174 | [zcomponents]: ./zcomponents 175 | [zscene]: zscene 176 | [zgui]: zgui 177 | [resvg]: https://github.com/RazrFalcon/resvg 178 | 179 | ## Contribute 180 | 181 | If you want to help take a look at issues with `help-wanted` label attached: 182 | 183 | [github.com/ozkriff/zemeroth/labels/help-wanted](https://github.com/ozkriff/zemeroth/labels/help-wanted) 184 | 185 | Unless you explicitly state otherwise, any contribution intentionally submitted 186 | for inclusion in the work by you, as defined in the Apache-2.0 license, 187 | shall be dual licensed as above, without any additional terms or conditions. 188 | 189 | ## License 190 | 191 | Zemeroth is distributed under the terms of both 192 | the MIT license and the Apache License (Version 2.0). 193 | See [LICENSE-APACHE] and [LICENSE-MIT] for details. 194 | 195 | Zemeroth's text logo is based on the "Old London" font 196 | by [Dieter Steffmann](http://www.steffmann.de). 197 | 198 | [LICENSE-MIT]: LICENSE-MIT 199 | [LICENSE-APACHE]: LICENSE-APACHE 200 | -------------------------------------------------------------------------------- /assets/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozkriff/zemeroth/fae7d89abe9702b25729453395e46fce105debbd/assets/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /assets/agent_campaign_info.ron: -------------------------------------------------------------------------------- 1 | { 2 | "swordsman": ( 3 | cost: 10, 4 | upgrades: ["heavy_swordsman", "elite_swordsman"], 5 | ), 6 | "elite_swordsman": ( 7 | cost: 15, 8 | ), 9 | "heavy_swordsman": ( 10 | cost: 14, 11 | ), 12 | "spearman": ( 13 | cost: 11, 14 | upgrades: ["heavy_spearman", "elite_spearman"], 15 | ), 16 | "elite_spearman": ( 17 | cost: 15, 18 | ), 19 | "heavy_spearman": ( 20 | cost: 14, 21 | ), 22 | "hammerman": ( 23 | cost: 11, 24 | upgrades: ["heavy_hammerman"], 25 | ), 26 | "heavy_hammerman": ( 27 | cost: 15, 28 | ), 29 | "alchemist": ( 30 | cost: 12, 31 | upgrades: ["healer", "firer"], 32 | ), 33 | "healer": ( 34 | cost: 16, 35 | ), 36 | "firer": ( 37 | cost: 16, 38 | ), 39 | } 40 | -------------------------------------------------------------------------------- /assets/campaign_01.ron: -------------------------------------------------------------------------------- 1 | Plan( 2 | initial_agents: [ 3 | "swordsman", 4 | "spearman", 5 | ], 6 | nodes: [ 7 | ( 8 | scenario: ( 9 | rocky_tiles_count: 0, 10 | randomized_objects: [ 11 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 3), 12 | ], 13 | ), 14 | award: ( 15 | recruits: ["hammerman", "alchemist"], 16 | renown: 17, 17 | ), 18 | ), 19 | ( 20 | scenario: ( 21 | rocky_tiles_count: 5, 22 | randomized_objects: [ 23 | (owner: None, typename: "boulder", line: None, count: 1), 24 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 3), 25 | (owner: Some((1)), typename: "imp_bomber", line: Some(Middle), count: 2), 26 | ], 27 | ), 28 | award: ( 29 | recruits: ["spearman", "alchemist"], 30 | renown: 18, 31 | ), 32 | ), 33 | ( 34 | scenario: ( 35 | rocky_tiles_count: 5, 36 | randomized_objects: [ 37 | (owner: None, typename: "boulder", line: None, count: 2), 38 | (owner: None, typename: "spike_trap", line: None, count: 1), 39 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 3), 40 | (owner: Some((1)), typename: "toxic_imp", line: Some(Front), count: 1), 41 | (owner: Some((1)), typename: "imp_bomber", line: Some(Middle), count: 1), 42 | (owner: Some((1)), typename: "imp_summoner", line: Some(Back), count: 1), 43 | ], 44 | ), 45 | award: ( 46 | recruits: ["swordsman", "alchemist"], 47 | renown: 20, 48 | ), 49 | ), 50 | ( 51 | scenario: ( 52 | rocky_tiles_count: 5, 53 | randomized_objects: [ 54 | (owner: None, typename: "boulder", line: None, count: 2), 55 | (owner: None, typename: "spike_trap", line: None, count: 2), 56 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 4), 57 | (owner: Some((1)), typename: "toxic_imp", line: Some(Front), count: 2), 58 | (owner: Some((1)), typename: "imp_bomber", line: Some(Front), count: 1), 59 | (owner: Some((1)), typename: "imp_summoner", line: Some(Middle), count: 1), 60 | (owner: Some((1)), typename: "imp_summoner", line: Some(Back), count: 1), 61 | ], 62 | ), 63 | award: ( 64 | recruits: ["spearman", "hammerman"], 65 | renown: 21, 66 | ), 67 | ), 68 | ( 69 | scenario: ( 70 | rocky_tiles_count: 5, 71 | randomized_objects: [ 72 | (owner: None, typename: "boulder", line: None, count: 2), 73 | (owner: None, typename: "spike_trap", line: None, count: 2), 74 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 5), 75 | (owner: Some((1)), typename: "toxic_imp", line: Some(Front), count: 3), 76 | (owner: Some((1)), typename: "imp_bomber", line: Some(Front), count: 2), 77 | (owner: Some((1)), typename: "imp_summoner", line: Some(Middle), count: 1), 78 | (owner: Some((1)), typename: "imp_summoner", line: Some(Back), count: 1), 79 | ], 80 | ), 81 | award: ( 82 | recruits: ["swordsman", "spearman", "alchemist"], 83 | renown: 22, 84 | ), 85 | ), 86 | ( 87 | scenario: ( 88 | rocky_tiles_count: 5, 89 | randomized_objects: [ 90 | (owner: None, typename: "boulder", line: None, count: 2), 91 | (owner: None, typename: "spike_trap", line: None, count: 2), 92 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 6), 93 | (owner: Some((1)), typename: "toxic_imp", line: Some(Front), count: 3), 94 | (owner: Some((1)), typename: "imp_bomber", line: Some(Front), count: 2), 95 | (owner: Some((1)), typename: "imp_summoner", line: Some(Middle), count: 2), 96 | (owner: Some((1)), typename: "imp_summoner", line: Some(Back), count: 1), 97 | ], 98 | ), 99 | award: ( 100 | renown: 100, 101 | ), 102 | ), 103 | ], 104 | ) 105 | -------------------------------------------------------------------------------- /assets/objects.ron: -------------------------------------------------------------------------------- 1 | #![enable(unwrap_newtypes)] 2 | 3 | { 4 | "swordsman": [ 5 | Blocker(()), 6 | Strength(( 7 | strength: 3, 8 | )), 9 | Agent(( 10 | moves: 1, 11 | attacks: 1, 12 | jokers: 1, 13 | reactive_attacks: 1, 14 | attack_distance: 1, 15 | attack_strength: 2, 16 | attack_accuracy: 4, 17 | attack_break: 1, 18 | weapon_type: Slash, 19 | move_points: 3, 20 | )), 21 | Abilities([Jump]), 22 | ], 23 | "elite_swordsman": [ 24 | Blocker(()), 25 | Strength(( 26 | strength: 4, 27 | )), 28 | Agent(( 29 | moves: 1, 30 | attacks: 1, 31 | jokers: 1, 32 | reactive_attacks: 1, 33 | attack_distance: 1, 34 | attack_strength: 2, 35 | attack_accuracy: 5, 36 | attack_break: 1, 37 | weapon_type: Slash, 38 | move_points: 3, 39 | )), 40 | Abilities([Jump, Rage, Dash]), 41 | ], 42 | "heavy_swordsman": [ 43 | Blocker(( 44 | weight: Heavy, 45 | )), 46 | Strength(( 47 | strength: 6, 48 | )), 49 | Agent(( 50 | moves: 0, 51 | attacks: 1, 52 | jokers: 1, 53 | reactive_attacks: 1, 54 | attack_distance: 1, 55 | attack_strength: 3, 56 | attack_accuracy: 6, 57 | attack_break: 1, 58 | weapon_type: Slash, 59 | move_points: 2, 60 | )), 61 | ], 62 | "alchemist": [ 63 | Blocker(()), 64 | Strength(( 65 | strength: 3, 66 | )), 67 | Agent(( 68 | moves: 1, 69 | attacks: 1, 70 | jokers: 0, 71 | reactive_attacks: 0, 72 | attack_distance: 0, 73 | attack_strength: 1, 74 | attack_accuracy: 4, 75 | weapon_type: Slash, 76 | dodge: 1, 77 | move_points: 3, 78 | )), 79 | Abilities([BombPush, Heal]), 80 | ], 81 | "healer": [ 82 | Blocker(()), 83 | Strength(( 84 | strength: 4, 85 | )), 86 | Agent(( 87 | moves: 1, 88 | attacks: 0, 89 | jokers: 1, 90 | reactive_attacks: 0, 91 | attack_distance: 0, 92 | attack_strength: 1, 93 | attack_accuracy: 4, 94 | weapon_type: Slash, 95 | dodge: 1, 96 | move_points: 3, 97 | )), 98 | Abilities([BombPush, BombPoison, GreatHeal]), 99 | ], 100 | "firer": [ 101 | Blocker(()), 102 | Strength(( 103 | strength: 4, 104 | )), 105 | Agent(( 106 | moves: 0, 107 | attacks: 1, 108 | jokers: 1, 109 | reactive_attacks: 0, 110 | attack_distance: 0, 111 | attack_strength: 1, 112 | attack_accuracy: 4, 113 | weapon_type: Slash, 114 | dodge: 1, 115 | move_points: 3, 116 | )), 117 | Abilities([BombPush, BombFire, Bomb]), 118 | ], 119 | "hammerman": [ 120 | Blocker(()), 121 | Strength(( 122 | strength: 4, 123 | )), 124 | Agent(( 125 | moves: 1, 126 | attacks: 2, 127 | jokers: 0, 128 | reactive_attacks: 1, 129 | attack_strength: 3, 130 | attack_accuracy: 3, 131 | attack_distance: 1, 132 | attack_break: 1, 133 | weapon_type: Smash, 134 | move_points: 3, 135 | )), 136 | Abilities([Knockback, Club]), 137 | ], 138 | "heavy_hammerman": [ 139 | Blocker(( 140 | weight: Heavy, 141 | )), 142 | Strength(( 143 | strength: 6, 144 | )), 145 | Agent(( 146 | moves: 0, 147 | attacks: 1, 148 | jokers: 1, 149 | reactive_attacks: 0, 150 | attack_strength: 5, 151 | attack_accuracy: 6, 152 | attack_distance: 1, 153 | attack_break: 3, 154 | weapon_type: Smash, 155 | move_points: 2, 156 | )), 157 | Abilities([Knockback, Club]), 158 | PassiveAbilities([ 159 | HeavyImpact, 160 | ]), 161 | ], 162 | "spearman": [ 163 | Blocker(()), 164 | Strength(( 165 | strength: 3, 166 | )), 167 | Agent(( 168 | moves: 0, 169 | attacks: 0, 170 | jokers: 1, 171 | reactive_attacks: 2, 172 | attack_distance: 2, 173 | attack_strength: 1, 174 | attack_accuracy: 4, 175 | weapon_type: Pierce, 176 | move_points: 3, 177 | )), 178 | Abilities([LongJump]), 179 | ], 180 | "elite_spearman": [ 181 | Blocker(()), 182 | Strength(( 183 | strength: 4, 184 | )), 185 | Agent(( 186 | moves: 0, 187 | attacks: 1, 188 | jokers: 1, 189 | reactive_attacks: 2, 190 | attack_distance: 2, 191 | attack_strength: 1, 192 | attack_accuracy: 6, 193 | weapon_type: Pierce, 194 | dodge: 1, 195 | move_points: 3, 196 | )), 197 | Abilities([LongJump]), 198 | ], 199 | "heavy_spearman": [ 200 | Blocker(( 201 | weight: Heavy, 202 | )), 203 | Strength(( 204 | strength: 5, 205 | )), 206 | Agent(( 207 | moves: 0, 208 | attacks: 0, 209 | jokers: 1, 210 | reactive_attacks: 2, 211 | attack_distance: 2, 212 | attack_strength: 2, 213 | attack_accuracy: 6, 214 | weapon_type: Pierce, 215 | move_points: 2, 216 | )), 217 | ], 218 | "imp": [ 219 | Blocker(()), 220 | Strength(( 221 | strength: 3, 222 | )), 223 | Agent(( 224 | moves: 1, 225 | attacks: 1, 226 | jokers: 0, 227 | reactive_attacks: 1, 228 | attack_strength: 1, 229 | attack_accuracy: 3, 230 | attack_distance: 1, 231 | weapon_type: Claw, 232 | move_points: 3, 233 | )), 234 | ], 235 | "toxic_imp": [ 236 | Blocker(()), 237 | Strength(( 238 | strength: 2, 239 | )), 240 | Agent(( 241 | moves: 1, 242 | attacks: 1, 243 | jokers: 0, 244 | reactive_attacks: 0, 245 | attack_strength: 0, 246 | attack_accuracy: 3, 247 | attack_distance: 1, 248 | weapon_type: Claw, 249 | move_points: 3, 250 | )), 251 | PassiveAbilities([ 252 | PoisonAttack, 253 | ]), 254 | ], 255 | "imp_bomber": [ 256 | Blocker(()), 257 | Strength(( 258 | strength: 2, 259 | )), 260 | Agent(( 261 | moves: 1, 262 | attacks: 1, 263 | jokers: 0, 264 | reactive_attacks: 0, 265 | attack_strength: 1, 266 | attack_accuracy: 2, 267 | attack_distance: 1, 268 | weapon_type: Claw, 269 | move_points: 3, 270 | )), 271 | Abilities([BombDemonic]), 272 | ], 273 | "imp_summoner": [ 274 | Blocker(()), 275 | Strength(( 276 | strength: 7, 277 | )), 278 | Armor(( 279 | armor: 1, 280 | )), 281 | Agent(( 282 | moves: 0, 283 | attacks: 0, 284 | jokers: 1, 285 | reactive_attacks: 1, 286 | attack_distance: 1, 287 | attack_strength: 2, 288 | attack_accuracy: 4, 289 | weapon_type: Smash, 290 | move_points: 3, 291 | )), 292 | Summoner(( 293 | count: 2, 294 | )), 295 | Abilities([Summon, Bloodlust]), 296 | PassiveAbilities([ 297 | HeavyImpact, 298 | Regenerate, 299 | ]), 300 | ], 301 | "boulder": [ 302 | Blocker(( 303 | weight: Heavy, 304 | )), 305 | ], 306 | "bomb_damage": [ 307 | Blocker(()), 308 | ], 309 | "bomb_push": [ 310 | Blocker(()), 311 | ], 312 | "bomb_poison": [ 313 | Blocker(()), 314 | ], 315 | "bomb_fire": [ 316 | Blocker(()), 317 | ], 318 | "bomb_demonic": [ 319 | Blocker(()), 320 | ], 321 | "fire": [ 322 | PassiveAbilities([ 323 | Burn, 324 | ]), 325 | ], 326 | "poison_cloud": [ 327 | PassiveAbilities([ 328 | Poison, 329 | ]), 330 | ], 331 | "spike_trap": [ 332 | PassiveAbilities([ 333 | SpikeTrap, 334 | ]), 335 | ], 336 | } 337 | -------------------------------------------------------------------------------- /assets/scenario_01.ron: -------------------------------------------------------------------------------- 1 | ( 2 | rocky_tiles_count: 10, 3 | randomized_objects: [ 4 | (owner: None, typename: "boulder", line: None, count: 3), 5 | (owner: None, typename: "spike_trap", line: None, count: 3), 6 | (owner: Some((0)), typename: "swordsman", line: Some(Front), count: 1), 7 | (owner: Some((0)), typename: "hammerman", line: Some(Front), count: 1), 8 | (owner: Some((0)), typename: "spearman", line: Some(Middle), count: 1), 9 | (owner: Some((0)), typename: "alchemist", line: Some(Middle), count: 1), 10 | (owner: Some((1)), typename: "imp", line: Some(Front), count: 4), 11 | (owner: Some((1)), typename: "toxic_imp", line: Some(Middle), count: 1), 12 | (owner: Some((1)), typename: "imp_bomber", line: Some(Back), count: 1), 13 | (owner: Some((1)), typename: "imp_summoner", line: Some(Middle), count: 2), 14 | ], 15 | ) 16 | -------------------------------------------------------------------------------- /assets/settings.ron: -------------------------------------------------------------------------------- 1 | ( 2 | font: "OpenSans-Regular.ttf", 3 | ) 4 | -------------------------------------------------------------------------------- /assets/sprites.ron: -------------------------------------------------------------------------------- 1 | { 2 | "swordsman": ( 3 | paths: { 4 | "": "img/swordsman.png", 5 | }, 6 | offset_x: 0.15, 7 | offset_y: 0.1, 8 | shadow_size_coefficient: 1.0, 9 | ), 10 | "elite_swordsman": ( 11 | paths: { 12 | "": "img/elite_swordsman.png", 13 | "rage": "img/elite_swordsman_rage.png", 14 | }, 15 | offset_x: 0.15, 16 | offset_y: 0.1, 17 | shadow_size_coefficient: 1.0, 18 | ), 19 | "heavy_swordsman": ( 20 | paths: { 21 | "": "img/heavy_swordsman.png", 22 | }, 23 | offset_x: 0.15, 24 | offset_y: 0.1, 25 | shadow_size_coefficient: 1.0, 26 | ), 27 | "spearman": ( 28 | paths: { 29 | "": "img/spearman.png", 30 | }, 31 | offset_x: 0.2, 32 | offset_y: 0.05, 33 | shadow_size_coefficient: 1.0, 34 | ), 35 | "elite_spearman": ( 36 | paths: { 37 | "": "img/elite_spearman.png", 38 | }, 39 | offset_x: 0.2, 40 | offset_y: 0.05, 41 | shadow_size_coefficient: 1.0, 42 | ), 43 | "heavy_spearman": ( 44 | paths: { 45 | "": "img/heavy_spearman.png", 46 | }, 47 | offset_x: 0.2, 48 | offset_y: 0.05, 49 | shadow_size_coefficient: 1.0, 50 | ), 51 | "hammerman": ( 52 | paths: { 53 | "": "img/hammerman.png", 54 | }, 55 | offset_x: 0.05, 56 | offset_y: 0.1, 57 | shadow_size_coefficient: 1.0, 58 | ), 59 | "heavy_hammerman": ( 60 | paths: { 61 | "": "img/heavy_hammerman.png", 62 | }, 63 | offset_x: 0.05, 64 | offset_y: 0.1, 65 | shadow_size_coefficient: 1.0, 66 | ), 67 | "alchemist": ( 68 | paths: { 69 | "": "img/alchemist.png", 70 | "throw": "img/alchemist_throw.png", 71 | "heal": "img/alchemist_heal.png", 72 | }, 73 | offset_x: 0.05, 74 | offset_y: 0.1, 75 | shadow_size_coefficient: 1.0, 76 | ), 77 | "healer": ( 78 | paths: { 79 | "": "img/healer.png", 80 | "throw": "img/healer_throw.png", 81 | "heal": "img/healer_heal.png", 82 | }, 83 | offset_x: 0.05, 84 | offset_y: 0.1, 85 | shadow_size_coefficient: 1.0, 86 | ), 87 | "firer": ( 88 | paths: { 89 | "": "img/firer.png", 90 | "throw": "img/firer_throw.png", 91 | }, 92 | offset_x: 0.05, 93 | offset_y: 0.1, 94 | shadow_size_coefficient: 1.0, 95 | ), 96 | "imp": ( 97 | paths: { 98 | "": "img/imp.png", 99 | }, 100 | offset_x: 0.0, 101 | offset_y: 0.15, 102 | shadow_size_coefficient: 1.3, 103 | ), 104 | "toxic_imp": ( 105 | paths: { 106 | "": "img/toxic_imp.png", 107 | }, 108 | offset_x: 0.0, 109 | offset_y: 0.15, 110 | shadow_size_coefficient: 1.2, 111 | ), 112 | "imp_bomber": ( 113 | paths: { 114 | "": "img/imp_bomber.png", 115 | "throw": "img/imp_bomber_throw.png", 116 | }, 117 | offset_x: 0.0, 118 | offset_y: 0.15, 119 | shadow_size_coefficient: 1.2, 120 | ), 121 | "imp_summoner": ( 122 | paths: { 123 | "": "img/imp_summoner.png", 124 | "summon": "img/imp_summoner_cast.png", 125 | "bloodlust": "img/imp_summoner_cast.png", 126 | }, 127 | offset_x: 0.0, 128 | offset_y: 0.15, 129 | shadow_size_coefficient: 1.3, 130 | ), 131 | "boulder": ( 132 | paths: { 133 | "": "img/boulder.png", 134 | }, 135 | offset_x: 0.0, 136 | offset_y: 0.4, 137 | shadow_size_coefficient: 1.9, 138 | ), 139 | "bomb_damage": ( 140 | paths: { 141 | "": "img/bomb.png", 142 | }, 143 | offset_x: 0.0, 144 | offset_y: 0.2, 145 | shadow_size_coefficient: 0.7, 146 | ), 147 | "bomb_push": ( 148 | paths: { 149 | "": "img/bomb.png", 150 | }, 151 | offset_x: 0.0, 152 | offset_y: 0.2, 153 | shadow_size_coefficient: 0.7, 154 | ), 155 | "bomb_fire": ( 156 | paths: { 157 | "": "img/bomb_fire.png", 158 | }, 159 | offset_x: 0.0, 160 | offset_y: 0.2, 161 | shadow_size_coefficient: 0.7, 162 | ), 163 | "bomb_poison": ( 164 | paths: { 165 | "": "img/bomb_poison.png", 166 | }, 167 | offset_x: 0.0, 168 | offset_y: 0.2, 169 | shadow_size_coefficient: 0.7, 170 | ), 171 | "bomb_demonic": ( 172 | paths: { 173 | "": "img/bomb_demonic.png", 174 | }, 175 | offset_x: 0.0, 176 | offset_y: 0.2, 177 | shadow_size_coefficient: 0.7, 178 | ), 179 | "fire": ( 180 | paths: { 181 | "": "img/fire.png", 182 | }, 183 | offset_x: 0.0, 184 | offset_y: 0.2, 185 | shadow_size_coefficient: 0.001, 186 | sub_tile_z: 0.1, 187 | ), 188 | "poison_cloud": ( 189 | paths: { 190 | "": "img/poison_cloud.png", 191 | }, 192 | offset_x: 0.0, 193 | offset_y: 0.2, 194 | shadow_size_coefficient: 2.0, 195 | sub_tile_z: 0.2, 196 | ), 197 | "spike_trap": ( 198 | paths: { 199 | "": "img/spike_trap.png", 200 | }, 201 | offset_x: 0.0, 202 | offset_y: 0.5, 203 | shadow_size_coefficient: 1.4, 204 | sub_tile_z: -0.1, 205 | ), 206 | } 207 | -------------------------------------------------------------------------------- /assets_src/export_ids: -------------------------------------------------------------------------------- 1 | tile 2 | tile_rocks 3 | poison_cloud 4 | imp 5 | toxic_imp 6 | imp_summoner 7 | imp_summoner_cast 8 | imp_bomber 9 | imp_bomber_throw 10 | grass 11 | shadow 12 | fire 13 | boulder 14 | bomb 15 | bomb_fire 16 | bomb_poison 17 | bomb_demonic 18 | explosion_ground_mark 19 | blood 20 | slash 21 | smash 22 | pierce 23 | claw 24 | spike_trap 25 | dot 26 | selection 27 | white_hex 28 | hammerman 29 | heavy_hammerman 30 | spearman 31 | elite_spearman 32 | heavy_spearman 33 | alchemist 34 | alchemist_throw 35 | alchemist_heal 36 | healer 37 | healer_throw 38 | healer_heal 39 | firer 40 | firer_throw 41 | swordsman 42 | elite_swordsman 43 | elite_swordsman_rage 44 | heavy_swordsman 45 | effect_poison 46 | effect_stun 47 | effect_bloodlust 48 | icon_ability_club 49 | icon_ability_knockback 50 | icon_ability_jump 51 | icon_ability_long_jump 52 | icon_ability_dash 53 | icon_ability_rage 54 | icon_ability_heal 55 | icon_ability_great_heal 56 | icon_ability_bomb_push 57 | icon_ability_bomb 58 | icon_ability_bomb_fire 59 | icon_ability_bomb_poison 60 | icon_ability_bomb_demonic 61 | icon_ability_summon 62 | icon_ability_bloodlust 63 | icon_info 64 | icon_end_turn 65 | icon_menu 66 | -------------------------------------------------------------------------------- /src/assets.rs: -------------------------------------------------------------------------------- 1 | //! This module groups all the async loading stuff. 2 | 3 | use std::{collections::HashMap, hash::Hash}; 4 | 5 | use mq::{ 6 | file::load_file, 7 | text::{self, Font}, 8 | texture::{load_texture, Texture2D}, 9 | }; 10 | use once_cell::sync::OnceCell; 11 | use serde::{de::DeserializeOwned, Deserialize}; 12 | 13 | use crate::{ 14 | core::{ 15 | battle::{ 16 | ability::Ability, 17 | component::{ObjType, Prototypes, WeaponType}, 18 | effect, 19 | scenario::Scenario, 20 | }, 21 | campaign, 22 | }, 23 | error::ZError, 24 | ZResult, 25 | }; 26 | 27 | static INSTANCE: OnceCell = OnceCell::new(); 28 | 29 | pub async fn load() -> ZResult { 30 | assert!(INSTANCE.get().is_none()); 31 | let assets = Assets::load().await?; 32 | INSTANCE.set(assets).expect("Can't set assets instance"); 33 | Ok(()) 34 | } 35 | 36 | pub fn get() -> &'static Assets { 37 | INSTANCE.get().expect("Assets weren't loaded") 38 | } 39 | 40 | /// Read a file to a string. 41 | async fn read_file(path: &str) -> ZResult { 42 | let data = load_file(path).await?; 43 | Ok(String::from_utf8_lossy(&data[..]).to_string()) 44 | } 45 | 46 | async fn deserialize_from_file(path: &str) -> ZResult { 47 | let s = read_file(path).await?; 48 | ron::de::from_str(&s).map_err(|e| ZError::from_ron_de_error(e, path.into())) 49 | } 50 | 51 | async fn load_map( 52 | table: &[(Key, &str)], 53 | expand_path: fn(&str) -> String, 54 | ) -> ZResult> { 55 | let mut map = HashMap::new(); 56 | for &(key, path) in table { 57 | map.insert(key, load_texture(&expand_path(path)).await?); 58 | } 59 | Ok(map) 60 | } 61 | 62 | #[derive(Debug, Clone, Deserialize)] 63 | pub struct SpriteInfo { 64 | pub paths: HashMap, 65 | pub offset_x: f32, 66 | pub offset_y: f32, 67 | pub shadow_size_coefficient: f32, 68 | 69 | #[serde(default = "default_sub_tile_z")] 70 | pub sub_tile_z: f32, 71 | } 72 | 73 | fn default_sub_tile_z() -> f32 { 74 | 0.0 75 | } 76 | 77 | type SpritesInfo = HashMap; 78 | 79 | #[derive(Debug)] 80 | pub struct Assets { 81 | pub textures: Textures, 82 | pub font: Font, 83 | pub sprites_info: SpritesInfo, 84 | pub sprite_frames: HashMap>, 85 | pub prototypes: Prototypes, 86 | pub demo_scenario: Scenario, 87 | pub campaign_plan: campaign::Plan, 88 | pub agent_campaign_info: HashMap, 89 | } 90 | 91 | impl Assets { 92 | pub async fn load() -> ZResult { 93 | let sprites_info: SpritesInfo = deserialize_from_file("sprites.ron").await?; 94 | let sprite_frames = { 95 | let mut sprite_frames = HashMap::new(); 96 | for (obj_type, SpriteInfo { paths, .. }) in sprites_info.iter() { 97 | let mut frames = HashMap::new(); 98 | for (frame_name, path) in paths { 99 | frames.insert(frame_name.to_string(), load_texture(path).await?); 100 | } 101 | sprite_frames.insert(obj_type.clone(), frames); 102 | } 103 | sprite_frames 104 | }; 105 | Ok(Self { 106 | textures: Textures::load().await?, 107 | font: text::load_ttf_font("OpenSans-Regular.ttf").await?, 108 | sprites_info, 109 | sprite_frames, 110 | prototypes: Prototypes::from_str(&read_file("objects.ron").await?), 111 | demo_scenario: deserialize_from_file("scenario_01.ron").await?, 112 | campaign_plan: deserialize_from_file("campaign_01.ron").await?, 113 | agent_campaign_info: deserialize_from_file("agent_campaign_info.ron").await?, 114 | }) 115 | } 116 | } 117 | 118 | #[derive(Debug)] 119 | pub struct Textures { 120 | pub map: MapObjectTextures, 121 | pub weapon_flashes: HashMap, 122 | pub icons: IconTextures, 123 | pub dot: Texture2D, 124 | } 125 | 126 | impl Textures { 127 | async fn load() -> ZResult { 128 | Ok(Self { 129 | map: MapObjectTextures::load().await?, 130 | weapon_flashes: load_weapon_flashes().await?, 131 | icons: IconTextures::load().await?, 132 | dot: load_texture("img/dot.png").await?, 133 | }) 134 | } 135 | } 136 | 137 | #[derive(Debug)] 138 | pub struct MapObjectTextures { 139 | pub selection: Texture2D, 140 | pub white_hex: Texture2D, 141 | pub tile: Texture2D, 142 | pub tile_rocks: Texture2D, 143 | pub grass: Texture2D, 144 | pub blood: Texture2D, 145 | pub explosion_ground_mark: Texture2D, 146 | pub shadow: Texture2D, 147 | } 148 | 149 | impl MapObjectTextures { 150 | async fn load() -> ZResult { 151 | Ok(Self { 152 | selection: load_texture("img/selection.png").await?, 153 | white_hex: load_texture("img/white_hex.png").await?, 154 | tile: load_texture("img/tile.png").await?, 155 | tile_rocks: load_texture("img/tile_rocks.png").await?, 156 | grass: load_texture("img/grass.png").await?, 157 | blood: load_texture("img/blood.png").await?, 158 | explosion_ground_mark: load_texture("img/explosion_ground_mark.png").await?, 159 | shadow: load_texture("img/shadow.png").await?, 160 | }) 161 | } 162 | } 163 | 164 | #[derive(Debug)] 165 | pub struct IconTextures { 166 | pub info: Texture2D, 167 | pub end_turn: Texture2D, 168 | pub main_menu: Texture2D, 169 | pub abilities: HashMap, 170 | pub lasting_effects: HashMap, 171 | } 172 | 173 | impl IconTextures { 174 | async fn load() -> ZResult { 175 | Ok(Self { 176 | info: load_texture("img/icon_info.png").await?, 177 | end_turn: load_texture("img/icon_end_turn.png").await?, 178 | main_menu: load_texture("img/icon_menu.png").await?, 179 | abilities: load_ability_icons().await?, 180 | lasting_effects: load_lasting_effects().await?, 181 | }) 182 | } 183 | } 184 | 185 | async fn load_weapon_flashes() -> ZResult> { 186 | let map = &[ 187 | (WeaponType::Slash, "slash"), 188 | (WeaponType::Smash, "smash"), 189 | (WeaponType::Pierce, "pierce"), 190 | (WeaponType::Claw, "claw"), 191 | ]; 192 | load_map(map, |s| format!("img/{}.png", s)).await 193 | } 194 | 195 | async fn load_ability_icons() -> ZResult> { 196 | let map = &[ 197 | (Ability::Knockback, "knockback"), 198 | (Ability::Club, "club"), 199 | (Ability::Jump, "jump"), 200 | (Ability::LongJump, "long_jump"), 201 | (Ability::Bomb, "bomb"), 202 | (Ability::BombPush, "bomb_push"), 203 | (Ability::BombFire, "bomb_fire"), 204 | (Ability::BombPoison, "bomb_poison"), 205 | (Ability::BombDemonic, "bomb_demonic"), 206 | (Ability::Summon, "summon"), 207 | (Ability::Dash, "dash"), 208 | (Ability::Rage, "rage"), 209 | (Ability::Heal, "heal"), 210 | (Ability::GreatHeal, "great_heal"), 211 | (Ability::Bloodlust, "bloodlust"), 212 | ]; 213 | load_map(map, |s| format!("img/icon_ability_{}.png", s)).await 214 | } 215 | 216 | async fn load_lasting_effects() -> ZResult> { 217 | let map = &[ 218 | (effect::Lasting::Stun, "stun"), 219 | (effect::Lasting::Poison, "poison"), 220 | (effect::Lasting::Bloodlust, "bloodlust"), 221 | ]; 222 | load_map(map, |s| format!("img/effect_{}.png", s)).await 223 | } 224 | -------------------------------------------------------------------------------- /src/core.rs: -------------------------------------------------------------------------------- 1 | pub mod battle; 2 | pub mod campaign; 3 | pub mod map; 4 | pub mod utils; 5 | -------------------------------------------------------------------------------- /src/core/battle.rs: -------------------------------------------------------------------------------- 1 | use std::{default::Default, fmt}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | pub use crate::core::{ 6 | battle::{check::check, execute::execute, movement::MovePoints, state::State}, 7 | map::PosHex, 8 | }; 9 | 10 | pub mod ability; 11 | pub mod ai; 12 | pub mod command; 13 | pub mod component; 14 | pub mod effect; 15 | pub mod event; 16 | pub mod execute; 17 | pub mod movement; 18 | pub mod scenario; 19 | pub mod state; 20 | 21 | mod check; 22 | 23 | #[cfg(test)] 24 | mod tests; 25 | 26 | #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] 27 | pub struct PlayerId(pub i32); 28 | 29 | /// An index of player's turn. 30 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 31 | pub struct Phase(i32); 32 | 33 | impl Phase { 34 | pub fn from_player_id(player_id: PlayerId) -> Self { 35 | Phase(player_id.0 as _) 36 | } 37 | } 38 | 39 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, derive_more::From)] 40 | #[serde(transparent)] 41 | pub struct Rounds(pub i32); 42 | 43 | impl Rounds { 44 | pub fn decrease(&mut self) { 45 | self.0 -= 1; 46 | } 47 | 48 | pub fn is_zero(&self) -> bool { 49 | self.0 == 0 50 | } 51 | } 52 | 53 | impl fmt::Display for Rounds { 54 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 55 | write!(f, "{}", self.0) 56 | } 57 | } 58 | 59 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 60 | #[serde(transparent)] 61 | pub struct Turns(pub i32); 62 | 63 | impl Turns { 64 | pub fn decrease(&mut self) { 65 | self.0 -= 1; 66 | } 67 | 68 | pub fn is_zero(&self) -> bool { 69 | self.0 == 0 70 | } 71 | } 72 | 73 | impl fmt::Display for Turns { 74 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 75 | write!(f, "{}", self.0) 76 | } 77 | } 78 | 79 | #[derive( 80 | Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq, Hash, 81 | )] 82 | pub struct Id(i32); 83 | 84 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 85 | pub struct Strength(pub i32); 86 | 87 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 88 | pub enum Weight { 89 | #[default] 90 | Normal = 0, 91 | 92 | Heavy = 1, 93 | 94 | Immovable = 2, 95 | } 96 | 97 | impl fmt::Display for Weight { 98 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 99 | match *self { 100 | Weight::Normal => write!(f, "Normal"), 101 | Weight::Heavy => write!(f, "Heavy"), 102 | Weight::Immovable => write!(f, "Immovable"), 103 | } 104 | } 105 | } 106 | 107 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 108 | pub struct PushStrength(pub Weight); 109 | 110 | impl PushStrength { 111 | pub fn can_push(self, weight: Weight) -> bool { 112 | weight != Weight::Immovable && self.0 >= weight 113 | } 114 | } 115 | 116 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 117 | pub struct Attacks(pub i32); 118 | 119 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 120 | pub struct Moves(pub i32); 121 | 122 | /// Move or Attack 123 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 124 | pub struct Jokers(pub i32); 125 | 126 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 127 | pub struct Accuracy(pub i32); 128 | 129 | #[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, PartialOrd)] 130 | pub struct Dodge(pub i32); 131 | 132 | #[derive(Serialize, Deserialize, Default, Debug, Clone, Copy, PartialEq, Eq)] 133 | pub enum TileType { 134 | #[default] 135 | Plain, 136 | 137 | Rocks, 138 | } 139 | -------------------------------------------------------------------------------- /src/core/battle/ability.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::core::battle::{Rounds, Weight}; 4 | 5 | /// Active ability. 6 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, derive_more::From)] 7 | pub enum Ability { 8 | Knockback, 9 | Club, 10 | Jump, 11 | LongJump, 12 | Poison, 13 | ExplodePush, 14 | ExplodeDamage, 15 | ExplodeFire, 16 | ExplodePoison, 17 | Bomb, 18 | BombPush, 19 | BombFire, 20 | BombPoison, 21 | BombDemonic, 22 | Summon, 23 | Vanish, 24 | Dash, 25 | Rage, 26 | Heal, 27 | GreatHeal, 28 | Bloodlust, 29 | } 30 | 31 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 32 | pub enum Status { 33 | Ready, 34 | Cooldown(Rounds), 35 | } 36 | 37 | impl Status { 38 | pub fn update(&mut self) { 39 | if let Status::Cooldown(ref mut rounds) = *self { 40 | rounds.decrease(); 41 | if rounds.is_zero() { 42 | *self = Status::Ready; 43 | } 44 | } 45 | } 46 | } 47 | 48 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 49 | #[serde(from = "Ability")] 50 | pub struct RechargeableAbility { 51 | pub ability: Ability, 52 | pub status: Status, 53 | } 54 | 55 | impl From for RechargeableAbility { 56 | fn from(ability: Ability) -> Self { 57 | RechargeableAbility { 58 | ability, 59 | status: Status::Ready, 60 | } 61 | } 62 | } 63 | 64 | impl Ability { 65 | pub fn title(&self) -> String { 66 | match self { 67 | Ability::Knockback => "Knockback".into(), 68 | Ability::Club => "Club".into(), 69 | Ability::Jump => "Jump".into(), 70 | Ability::LongJump => "Long Jump".into(), 71 | Ability::Poison => "Poison".into(), 72 | Ability::ExplodePush => "Explode Push".into(), 73 | Ability::ExplodeDamage => "Explode Damage".into(), 74 | Ability::ExplodeFire => "Explode Fire".into(), 75 | Ability::ExplodePoison => "Explode Poison".into(), 76 | Ability::Bomb => "Bomb".into(), 77 | Ability::BombPush => "Bomb Push".into(), 78 | Ability::BombFire => "Fire Bomb".into(), 79 | Ability::BombPoison => "Poison Bomb".into(), 80 | Ability::BombDemonic => "Demonic Bomb".into(), 81 | Ability::Vanish => "Vanish".into(), 82 | Ability::Summon => "Summon".into(), 83 | Ability::Dash => "Dash".into(), 84 | Ability::Rage => "Rage".into(), 85 | Ability::Heal => "Heal".into(), 86 | Ability::GreatHeal => "Great Heal".into(), 87 | Ability::Bloodlust => "Bloodlust".into(), 88 | } 89 | } 90 | 91 | pub fn base_cooldown(&self) -> Rounds { 92 | let n = match self { 93 | Ability::Knockback => 1, 94 | Ability::Club => 2, 95 | Ability::Jump => 2, 96 | Ability::LongJump => 3, 97 | Ability::Poison => 2, 98 | Ability::ExplodePush => 2, 99 | Ability::ExplodeDamage => 2, 100 | Ability::ExplodeFire => 2, 101 | Ability::ExplodePoison => 2, 102 | Ability::Bomb => 2, 103 | Ability::BombPush => 2, 104 | Ability::BombFire => 2, 105 | Ability::BombPoison => 2, 106 | Ability::BombDemonic => 2, 107 | Ability::Vanish => 2, 108 | Ability::Summon => 3, 109 | Ability::Dash => 1, 110 | Ability::Rage => 3, 111 | Ability::Heal => 3, 112 | Ability::GreatHeal => 2, 113 | Ability::Bloodlust => 3, 114 | }; 115 | Rounds(n) 116 | } 117 | 118 | pub fn description(&self) -> Vec { 119 | match *self { 120 | Ability::Knockback => vec![ 121 | "Push an adjusted object one tile away.".into(), 122 | "Can move objects with a weight up to Normal.".into(), 123 | ], 124 | Ability::Club => vec!["Stun an adjusted agent for one turn.".into()], 125 | Ability::Jump => vec![ 126 | "Jump for up to 2 tiles.".into(), 127 | "Note: Triggers reaction attacks on landing.".into(), 128 | ], 129 | Ability::LongJump => vec![ 130 | "Jump for up to 3 tiles.".into(), 131 | "Note: Triggers reaction attacks on landing.".into(), 132 | ], 133 | Ability::Bomb => vec![ 134 | "Throw a bomb that explodes on the next turn.".into(), 135 | "Damages all agents on the neighbour tiles.".into(), 136 | "Can be thrown for up to 3 tiles.".into(), 137 | ], 138 | Ability::BombPush => vec![ 139 | "Throw a bomb that explodes *instantly*.".into(), 140 | "Pushes all agents on the neighbour tiles.".into(), 141 | "Can be thrown for up to 3 tiles.".into(), 142 | format!("Can move objects with a weight up to {}.", Weight::Normal), 143 | ], 144 | Ability::BombFire => vec![ 145 | "Throw a bomb that explodes on the next turn.".into(), 146 | "Creates 7 fires.".into(), 147 | "Can be thrown for up to 3 tiles.".into(), 148 | ], 149 | Ability::BombPoison => vec![ 150 | "Throw a bomb that explodes on the next turn.".into(), 151 | "Creates 7 poison clouds.".into(), 152 | "Can be thrown for up to 3 tiles.".into(), 153 | ], 154 | Ability::BombDemonic => vec![ 155 | "Throw a demonic bomb".into(), 156 | "that explodes on the next turn.".into(), 157 | "Damages all agents on the neighbour tiles.".into(), 158 | "Can be thrown for up to 3 tiles.".into(), 159 | ], 160 | Ability::Dash => vec![ 161 | "Move one tile".into(), 162 | "without triggering any reaction attacks.".into(), 163 | ], 164 | Ability::Rage => vec!["Instantly receive 3 additional attacks.".into()], 165 | Ability::Heal => vec![ 166 | "Heal 2 strength points.".into(), 167 | "Also, removes 'Poison' and 'Stun' lasting effects.".into(), 168 | ], 169 | Ability::GreatHeal => vec![ 170 | "Heal 3 strength points.".into(), 171 | "Also, removes 'Poison' and 'Stun' lasting effects.".into(), 172 | ], 173 | Ability::Summon => vec![ 174 | "Summon a few lesser daemons.".into(), 175 | "The number of summoned daemons increases".into(), 176 | "by one with every use (up to six).".into(), 177 | ], 178 | Ability::Bloodlust => vec![ 179 | "Cast the 'Bloodlust' lasting effect on a friendly agent.".into(), 180 | "This agent will receive three additional Jokers".into(), 181 | "for a few turns.".into(), 182 | ], 183 | Ability::Poison 184 | | Ability::Vanish 185 | | Ability::ExplodePush 186 | | Ability::ExplodeDamage 187 | | Ability::ExplodeFire 188 | | Ability::ExplodePoison => vec!["".into()], 189 | } 190 | } 191 | } 192 | 193 | #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 194 | pub enum PassiveAbility { 195 | HeavyImpact, 196 | SpawnPoisonCloudOnDeath, // TODO: implement and employ it! 197 | Burn, 198 | Poison, 199 | SpikeTrap, 200 | PoisonAttack, 201 | Regenerate, 202 | } 203 | 204 | impl PassiveAbility { 205 | pub fn title(self) -> String { 206 | match self { 207 | PassiveAbility::HeavyImpact => "Heavy Impact".into(), 208 | PassiveAbility::SpawnPoisonCloudOnDeath => "Spawn Poison Cloud on Death".into(), 209 | PassiveAbility::Burn => "Burn".into(), 210 | PassiveAbility::Poison => "Poison".into(), 211 | PassiveAbility::SpikeTrap => "Spike Trap".into(), 212 | PassiveAbility::PoisonAttack => "Poison Attack".into(), 213 | PassiveAbility::Regenerate => "Regenerate".into(), 214 | } 215 | } 216 | 217 | pub fn description(self) -> Vec { 218 | match self { 219 | PassiveAbility::HeavyImpact => vec![ 220 | "Regular attack throws the target one tile away.".into(), 221 | format!( 222 | "Works on targets with a weight for up to {}.", 223 | Weight::Normal 224 | ), 225 | ], 226 | PassiveAbility::SpawnPoisonCloudOnDeath => vec!["Not implemented yet.".into()], 227 | PassiveAbility::Burn => { 228 | vec!["Damages agents that enter into or begin their turn in the same tile.".into()] 229 | } 230 | PassiveAbility::Poison => { 231 | vec!["Poisons agents that enter into or begin their turn in the same tile.".into()] 232 | } 233 | PassiveAbility::SpikeTrap => { 234 | vec!["Damages agents that enter into or begin their turn in the same tile.".into()] 235 | } 236 | PassiveAbility::PoisonAttack => vec!["Regular attack poisons the target.".into()], 237 | PassiveAbility::Regenerate => vec!["Regenerates 1 strength points every turn.".into()], 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/core/battle/command.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ 2 | battle::{ability::Ability, component::ObjType, movement::Path, Id, PlayerId}, 3 | map::PosHex, 4 | }; 5 | 6 | #[derive(Debug, Clone, derive_more::From)] 7 | pub enum Command { 8 | Create(Create), 9 | Attack(Attack), 10 | MoveTo(MoveTo), 11 | EndTurn(EndTurn), 12 | UseAbility(UseAbility), 13 | } 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct Create { 17 | pub owner: Option, 18 | pub pos: PosHex, 19 | pub prototype: ObjType, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct Attack { 24 | pub attacker_id: Id, 25 | pub target_id: Id, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub struct MoveTo { 30 | pub id: Id, 31 | pub path: Path, 32 | } 33 | 34 | #[derive(Debug, Clone)] 35 | pub struct EndTurn; 36 | 37 | #[derive(Debug, Clone)] 38 | pub struct UseAbility { 39 | pub id: Id, 40 | pub pos: PosHex, 41 | pub ability: Ability, 42 | } 43 | -------------------------------------------------------------------------------- /src/core/battle/component.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use zcomponents::zcomponents_storage; 3 | 4 | use crate::core::{ 5 | battle::{ 6 | self, 7 | ability::{Ability, PassiveAbility, RechargeableAbility}, 8 | effect::Timed, 9 | Attacks, Id, Jokers, MovePoints, Moves, Phase, PlayerId, Rounds, 10 | }, 11 | map, 12 | }; 13 | 14 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 15 | pub struct Pos(pub map::PosHex); 16 | 17 | /// Blocks the whole tile. Two blocker objects can't coexist in one tile. 18 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 19 | pub struct Blocker { 20 | #[serde(default)] 21 | pub weight: battle::Weight, 22 | } 23 | 24 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 25 | pub struct Strength { 26 | #[serde(default)] 27 | pub base_strength: battle::Strength, 28 | 29 | pub strength: battle::Strength, 30 | } 31 | 32 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 33 | pub struct Armor { 34 | pub armor: battle::Strength, 35 | } 36 | 37 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] 38 | #[serde(transparent)] 39 | pub struct ObjType(pub String); 40 | 41 | impl From<&str> for ObjType { 42 | fn from(s: &str) -> Self { 43 | ObjType(s.into()) 44 | } 45 | } 46 | 47 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 48 | pub struct Meta { 49 | pub name: ObjType, 50 | } 51 | 52 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 53 | pub struct BelongsTo(pub PlayerId); 54 | 55 | #[derive(Serialize, Deserialize, PartialEq, Clone, Copy, Debug, Eq, Hash)] 56 | pub enum WeaponType { 57 | Slash, 58 | Smash, 59 | Pierce, 60 | Claw, 61 | } 62 | 63 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 64 | pub struct Agent { 65 | // dynamic 66 | pub moves: Moves, 67 | pub attacks: Attacks, 68 | pub jokers: Jokers, 69 | 70 | // static 71 | pub attack_strength: battle::Strength, 72 | pub attack_distance: map::Distance, 73 | pub attack_accuracy: battle::Accuracy, 74 | pub weapon_type: WeaponType, 75 | 76 | #[serde(default)] 77 | pub attack_break: battle::Strength, 78 | 79 | #[serde(default)] 80 | pub dodge: battle::Dodge, 81 | 82 | pub move_points: MovePoints, 83 | pub reactive_attacks: Attacks, 84 | 85 | #[serde(default)] 86 | pub base_moves: Moves, 87 | 88 | #[serde(default)] 89 | pub base_attacks: Attacks, 90 | 91 | #[serde(default)] 92 | pub base_jokers: Jokers, 93 | } 94 | 95 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 96 | pub struct Abilities(pub Vec); 97 | 98 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 99 | pub struct PassiveAbilities(pub Vec); 100 | 101 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 102 | pub struct Effects(pub Vec); 103 | 104 | // TODO: Move to `ability` mod? 105 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 106 | pub struct PlannedAbility { 107 | pub rounds: Rounds, 108 | pub phase: Phase, 109 | pub ability: Ability, 110 | } 111 | 112 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] 113 | pub struct Schedule { 114 | pub planned: Vec, 115 | } 116 | 117 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 118 | pub struct Summoner { 119 | pub count: u32, 120 | } 121 | 122 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, derive_more::From)] 123 | pub enum Component { 124 | Pos(Pos), 125 | Strength(Strength), 126 | Armor(Armor), 127 | Meta(Meta), 128 | BelongsTo(BelongsTo), 129 | Agent(Agent), 130 | Blocker(Blocker), 131 | Abilities(Abilities), 132 | PassiveAbilities(PassiveAbilities), 133 | Effects(Effects), 134 | Schedule(Schedule), 135 | Summoner(Summoner), 136 | } 137 | 138 | zcomponents_storage!(Parts: { 139 | strength: Strength, 140 | armor: Armor, 141 | pos: Pos, 142 | meta: Meta, 143 | belongs_to: BelongsTo, 144 | agent: Agent, 145 | blocker: Blocker, 146 | abilities: Abilities, 147 | passive_abilities: PassiveAbilities, 148 | effects: Effects, 149 | schedule: Schedule, 150 | summoner: Summoner, 151 | }); 152 | 153 | #[derive(Clone, Debug, Serialize, Deserialize)] 154 | pub struct Prototypes(pub HashMap>); 155 | 156 | fn init_component(component: &mut Component) { 157 | match component { 158 | Component::Agent(agent) => { 159 | agent.base_moves = agent.moves; 160 | agent.base_attacks = agent.attacks; 161 | agent.base_jokers = agent.jokers; 162 | } 163 | Component::Strength(strength) => { 164 | strength.base_strength = strength.strength; 165 | } 166 | _ => {} 167 | } 168 | } 169 | 170 | impl Prototypes { 171 | pub fn from_str(s: &str) -> Self { 172 | let mut prototypes: Prototypes = ron::de::from_str(s).expect("Can't parse the prototypes"); 173 | prototypes.init_components(); 174 | prototypes 175 | } 176 | 177 | pub fn init_components(&mut self) { 178 | for components in self.0.values_mut() { 179 | for component in components { 180 | init_component(component); 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/core/battle/effect.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::core::battle::{ 4 | component::{Component, ObjType}, 5 | Phase, PosHex, PushStrength, Rounds, Strength, 6 | }; 7 | 8 | #[derive(Clone, Debug, Copy, PartialEq, Serialize, Deserialize)] 9 | pub enum Duration { 10 | Forever, 11 | Rounds(Rounds), 12 | } 13 | 14 | impl Duration { 15 | pub fn is_over(self) -> bool { 16 | match self { 17 | Duration::Rounds(n) => n.is_zero(), 18 | Duration::Forever => false, 19 | } 20 | } 21 | } 22 | 23 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 24 | pub struct Timed { 25 | pub duration: Duration, 26 | pub phase: Phase, 27 | pub effect: Lasting, 28 | } 29 | 30 | /// Instant effects 31 | #[derive(Clone, Debug, PartialEq, Deserialize, derive_more::From)] 32 | pub enum Effect { 33 | Create(Create), 34 | Kill(Kill), 35 | Vanish, 36 | Stun, 37 | Heal(Heal), 38 | Wound(Wound), 39 | Knockback(Knockback), 40 | FlyOff(FlyOff), // TODO: flying boulders should make some damage 41 | Throw(Throw), 42 | Dodge(Dodge), 43 | Bloodlust, 44 | } 45 | 46 | impl Effect { 47 | pub fn to_str(&self) -> &str { 48 | match *self { 49 | Effect::Create(_) => "Create", 50 | Effect::Kill(_) => "Kill", 51 | Effect::Vanish => "Vanish", 52 | Effect::Stun => "Stun", 53 | Effect::Heal(_) => "Heal", 54 | Effect::Wound(_) => "Wound", 55 | Effect::Knockback(_) => "Knockback", 56 | Effect::FlyOff(_) => "Fly off", 57 | Effect::Throw(_) => "Throw", 58 | Effect::Dodge(_) => "Dodge", 59 | Effect::Bloodlust => "Bloodlust", 60 | } 61 | } 62 | } 63 | 64 | #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] 65 | pub enum Lasting { 66 | Poison, 67 | Stun, 68 | Bloodlust, 69 | } 70 | 71 | impl Lasting { 72 | pub fn title(&self) -> &str { 73 | match *self { 74 | Lasting::Poison => "Poison", 75 | Lasting::Stun => "Stun", 76 | Lasting::Bloodlust => "Bloodlust", 77 | } 78 | } 79 | 80 | pub fn description(&self) -> Vec { 81 | match self { 82 | Lasting::Poison => vec![ 83 | "Removes one strength every turn.".into(), 84 | "Doesn't kill: ends if only one strength is left.".into(), 85 | ], 86 | Lasting::Stun => vec!["Removes all Actions/Moves/Jokers every turn.".into()], 87 | Lasting::Bloodlust => vec!["Gives three additional Jokers every turn.".into()], 88 | } 89 | } 90 | } 91 | 92 | // TODO: Move `armor_break` to a separate effect? 93 | #[derive(Clone, Debug, PartialEq, Deserialize)] 94 | pub struct Wound { 95 | pub damage: Strength, 96 | pub armor_break: Strength, 97 | pub attacker_pos: Option, 98 | } 99 | 100 | #[derive(Clone, Debug, PartialEq, Deserialize)] 101 | pub struct Kill { 102 | pub attacker_pos: Option, 103 | } 104 | 105 | #[derive(Clone, PartialEq, Debug, Deserialize)] 106 | pub struct Heal { 107 | pub strength: Strength, 108 | } 109 | 110 | #[derive(Clone, Debug, Deserialize, PartialEq)] 111 | pub struct Create { 112 | pub pos: PosHex, 113 | pub prototype: ObjType, 114 | pub components: Vec, 115 | pub is_teleported: bool, 116 | } 117 | 118 | #[derive(Clone, Debug, Deserialize, PartialEq)] 119 | pub struct FlyOff { 120 | pub from: PosHex, 121 | pub to: PosHex, 122 | pub strength: PushStrength, 123 | } 124 | 125 | #[derive(Clone, Debug, Deserialize, PartialEq)] 126 | pub struct Throw { 127 | pub from: PosHex, 128 | pub to: PosHex, 129 | } 130 | 131 | #[derive(Clone, Debug, Deserialize, PartialEq)] 132 | pub struct Dodge { 133 | pub attacker_pos: PosHex, 134 | } 135 | 136 | #[derive(Clone, Debug, Deserialize, PartialEq)] 137 | pub struct Knockback { 138 | pub from: PosHex, 139 | pub to: PosHex, 140 | pub strength: PushStrength, 141 | } 142 | -------------------------------------------------------------------------------- /src/core/battle/event.rs: -------------------------------------------------------------------------------- 1 | use crate::core::battle::{ 2 | ability::{Ability, PassiveAbility}, 3 | component::{PlannedAbility, WeaponType}, 4 | effect::{self, Effect}, 5 | movement::Path, 6 | state::BattleResult, 7 | Id, Moves, PlayerId, PosHex, 8 | }; 9 | 10 | #[derive(Clone, Debug, PartialEq)] 11 | pub struct Event { 12 | /// "Core" event 13 | pub active_event: ActiveEvent, 14 | 15 | /// These agent's stats must be updated 16 | pub actor_ids: Vec, 17 | 18 | pub instant_effects: Vec<(Id, Vec)>, 19 | 20 | /// If a lasting effect is applied to the same object twice 21 | /// then the new effect replaces the old one. 22 | pub timed_effects: Vec<(Id, Vec)>, 23 | 24 | /// If a scheduled ability is applied to the same object twice 25 | /// then the new planned ability replaces the old one. 26 | /// This can be used to reset bomb timers or to make fire last longer. 27 | pub scheduled_abilities: Vec<(Id, Vec)>, 28 | } 29 | 30 | #[derive(Debug, Clone, PartialEq, derive_more::From)] 31 | pub enum ActiveEvent { 32 | Create, 33 | EndBattle(EndBattle), 34 | EndTurn(EndTurn), 35 | BeginTurn(BeginTurn), 36 | UseAbility(UseAbility), 37 | UsePassiveAbility(UsePassiveAbility), 38 | MoveTo(MoveTo), 39 | Attack(Attack), 40 | EffectTick(EffectTick), 41 | EffectEnd(EffectEnd), 42 | } 43 | 44 | #[derive(Debug, Clone, PartialEq)] 45 | pub struct MoveTo { 46 | pub path: Path, 47 | pub cost: Moves, 48 | pub id: Id, 49 | } 50 | 51 | #[derive(PartialEq, Clone, Debug)] 52 | pub enum AttackMode { 53 | Active, 54 | Reactive, 55 | } 56 | 57 | #[derive(Debug, Clone, PartialEq)] 58 | pub struct Attack { 59 | pub attacker_id: Id, 60 | pub target_id: Id, 61 | pub mode: AttackMode, 62 | pub weapon_type: WeaponType, 63 | } 64 | 65 | #[derive(Debug, Clone, PartialEq)] 66 | pub struct EndBattle { 67 | pub result: BattleResult, 68 | } 69 | 70 | #[derive(Debug, Clone, PartialEq)] 71 | pub struct EndTurn { 72 | pub player_id: PlayerId, 73 | } 74 | 75 | #[derive(Debug, Clone, PartialEq)] 76 | pub struct BeginTurn { 77 | pub player_id: PlayerId, 78 | } 79 | 80 | #[derive(Debug, Clone, PartialEq)] 81 | pub struct UseAbility { 82 | pub id: Id, 83 | pub pos: PosHex, 84 | pub ability: Ability, 85 | } 86 | 87 | #[derive(Debug, Clone, PartialEq)] 88 | pub struct UsePassiveAbility { 89 | pub id: Id, 90 | pub pos: PosHex, 91 | pub ability: PassiveAbility, 92 | } 93 | 94 | #[derive(Debug, Clone, PartialEq)] 95 | pub struct EffectTick { 96 | pub id: Id, 97 | pub effect: effect::Lasting, 98 | } 99 | 100 | #[derive(Debug, Clone, PartialEq)] 101 | pub struct EffectEnd { 102 | pub id: Id, 103 | pub effect: effect::Lasting, 104 | } 105 | -------------------------------------------------------------------------------- /src/core/battle/movement.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, slice::Windows}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::core::{ 6 | battle::{ability::PassiveAbility, state, Id, State, TileType}, 7 | map::{dirs, Dir, Distance, HexMap, PosHex}, 8 | }; 9 | 10 | #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] 11 | pub struct MovePoints(pub i32); 12 | 13 | #[derive(Clone, Copy, Debug)] 14 | pub struct Tile { 15 | cost: MovePoints, 16 | parent_dir: Option, 17 | } 18 | 19 | impl Tile { 20 | pub fn parent(self) -> Option { 21 | self.parent_dir 22 | } 23 | 24 | pub fn cost(self) -> MovePoints { 25 | self.cost 26 | } 27 | } 28 | 29 | impl Default for Tile { 30 | fn default() -> Self { 31 | Self { 32 | cost: MovePoints(0), 33 | parent_dir: None, 34 | } 35 | } 36 | } 37 | 38 | pub const fn max_cost() -> MovePoints { 39 | MovePoints(i32::max_value()) 40 | } 41 | 42 | pub fn tile_cost(state: &State, _: Id, _: PosHex, pos: PosHex) -> MovePoints { 43 | // taking other dangerous objects in the tile into account 44 | for id in state.parts().passive_abilities.ids() { 45 | if state.parts().pos.get(id).0 != pos { 46 | continue; 47 | } 48 | for &ability in &state.parts().passive_abilities.get(id).0 { 49 | match ability { 50 | PassiveAbility::SpikeTrap | PassiveAbility::Burn | PassiveAbility::Poison => { 51 | return MovePoints(4); 52 | } 53 | _ => {} 54 | } 55 | } 56 | } 57 | // just tile's cost 58 | match state.map().tile(pos) { 59 | TileType::Plain => MovePoints(1), 60 | TileType::Rocks => MovePoints(3), 61 | } 62 | } 63 | 64 | #[derive(Clone, Debug, PartialEq)] 65 | pub struct Path { 66 | tiles: Vec, 67 | } 68 | 69 | impl Path { 70 | pub fn new(tiles: Vec) -> Self { 71 | Self { tiles } 72 | } 73 | 74 | pub fn tiles(&self) -> &[PosHex] { 75 | &self.tiles 76 | } 77 | 78 | pub fn from(&self) -> PosHex { 79 | self.tiles[0] 80 | } 81 | 82 | pub fn to(&self) -> PosHex { 83 | *self.tiles().last().unwrap() 84 | } 85 | 86 | pub fn truncate(&self, state: &State, id: Id) -> Option { 87 | let agent = state.parts().agent.get(id); 88 | let mut new_path = Vec::new(); 89 | let mut cost = MovePoints(0); 90 | new_path.push(self.tiles[0]); 91 | let move_points = agent.move_points; 92 | for Step { from, to } in self.steps() { 93 | cost.0 += tile_cost(state, id, from, to).0; 94 | if cost > move_points { 95 | break; 96 | } 97 | new_path.push(to); 98 | } 99 | if new_path.len() >= 2 { 100 | Some(Self::new(new_path)) 101 | } else { 102 | None 103 | } 104 | } 105 | 106 | pub fn cost_for(&self, state: &State, id: Id) -> MovePoints { 107 | let mut cost = MovePoints(0); 108 | for step in self.steps() { 109 | cost.0 += tile_cost(state, id, step.from, step.to).0; 110 | } 111 | cost 112 | } 113 | 114 | pub fn steps(&self) -> Steps { 115 | Steps { 116 | windows: self.tiles.windows(2), 117 | } 118 | } 119 | } 120 | 121 | #[derive(Clone, Copy, Debug, PartialEq)] 122 | pub struct Step { 123 | pub from: PosHex, 124 | pub to: PosHex, 125 | } 126 | 127 | #[derive(Clone, Debug)] 128 | pub struct Steps<'a> { 129 | windows: Windows<'a, PosHex>, 130 | } 131 | 132 | impl<'a> Iterator for Steps<'a> { 133 | type Item = Step; 134 | 135 | fn next(&mut self) -> Option { 136 | if let Some([from, to]) = self.windows.next() { 137 | Some(Step { 138 | from: *from, 139 | to: *to, 140 | }) 141 | } else { 142 | None 143 | } 144 | } 145 | } 146 | 147 | #[derive(Clone, Debug)] 148 | pub struct Pathfinder { 149 | queue: VecDeque, 150 | map: HexMap, 151 | } 152 | 153 | impl Pathfinder { 154 | pub fn new(map_radius: Distance) -> Self { 155 | Self { 156 | queue: VecDeque::new(), 157 | map: HexMap::new(map_radius), 158 | } 159 | } 160 | 161 | pub fn map(&self) -> &HexMap { 162 | &self.map 163 | } 164 | 165 | fn process_neighbor_pos( 166 | &mut self, 167 | state: &State, 168 | id: Id, 169 | original_pos: PosHex, 170 | neighbor_pos: PosHex, 171 | ) { 172 | let old_cost = self.map.tile(original_pos).cost; 173 | let tile_cost = tile_cost(state, id, original_pos, neighbor_pos); 174 | let new_cost = MovePoints(old_cost.0 + tile_cost.0); 175 | let tile = self.map.tile(neighbor_pos); 176 | if tile.cost > new_cost { 177 | let parent_dir = Dir::get_dir_from_to(neighbor_pos, original_pos); 178 | let updated_tile = Tile { 179 | cost: new_cost, 180 | parent_dir: Some(parent_dir), 181 | }; 182 | self.map.set_tile(neighbor_pos, updated_tile); 183 | self.queue.push_back(neighbor_pos); 184 | } 185 | } 186 | 187 | fn clean_map(&mut self) { 188 | for pos in self.map.iter() { 189 | let tile = Tile { 190 | cost: max_cost(), 191 | parent_dir: None, 192 | }; 193 | self.map.set_tile(pos, tile); 194 | } 195 | } 196 | 197 | fn try_to_push_neighbors(&mut self, state: &State, id: Id, pos: PosHex) { 198 | assert!(self.map.is_inboard(pos)); 199 | for dir in dirs() { 200 | let neighbor_pos = Dir::get_neighbor_pos(pos, dir); 201 | if self.map.is_inboard(neighbor_pos) && !state::is_tile_blocked(state, neighbor_pos) { 202 | self.process_neighbor_pos(state, id, pos, neighbor_pos); 203 | } 204 | } 205 | } 206 | 207 | fn push_start_pos_to_queue(&mut self, start_pos: PosHex) { 208 | let start_tile = Tile::default(); 209 | self.map.set_tile(start_pos, start_tile); 210 | self.queue.push_back(start_pos); 211 | } 212 | 213 | pub fn fill_map(&mut self, state: &State, id: Id) { 214 | let agent_pos = state.parts().pos.get(id).0; 215 | assert!(self.queue.is_empty()); 216 | self.clean_map(); 217 | self.push_start_pos_to_queue(agent_pos); 218 | while let Some(pos) = self.queue.pop_front() { 219 | self.try_to_push_neighbors(state, id, pos); 220 | } 221 | } 222 | 223 | pub fn path(&self, destination: PosHex) -> Option { 224 | if self.map.tile(destination).cost == max_cost() { 225 | return None; 226 | } 227 | let mut path = vec![destination]; 228 | let mut pos = destination; 229 | while self.map.tile(pos).cost != MovePoints(0) { 230 | assert!(self.map.is_inboard(pos)); 231 | let parent_dir = match self.map.tile(pos).parent() { 232 | Some(dir) => dir, 233 | None => return None, 234 | }; 235 | pos = Dir::get_neighbor_pos(pos, parent_dir); 236 | path.push(pos); 237 | } 238 | path.reverse(); 239 | if path.is_empty() { 240 | None 241 | } else { 242 | Some(Path::new(path)) 243 | } 244 | } 245 | } 246 | 247 | #[cfg(test)] 248 | mod tests { 249 | use crate::core::battle::{ 250 | movement::{Path, Step}, 251 | PosHex, 252 | }; 253 | 254 | const NODE_0: PosHex = PosHex { q: 0, r: 1 }; 255 | const NODE_1: PosHex = PosHex { q: 1, r: 0 }; 256 | const NODE_2: PosHex = PosHex { q: 2, r: 0 }; 257 | 258 | #[test] 259 | fn path_from_to() { 260 | let nodes = vec![NODE_0, NODE_1, NODE_2]; 261 | let path = Path::new(nodes); 262 | assert_eq!(path.from(), NODE_0); 263 | assert_eq!(path.to(), NODE_2); 264 | } 265 | 266 | #[test] 267 | fn path_tiles() { 268 | let nodes = vec![NODE_0, NODE_1, NODE_2]; 269 | let path = Path::new(nodes); 270 | let tiles = path.tiles(); 271 | assert_eq!(tiles.len(), 3); 272 | assert_eq!(tiles[0], NODE_0); 273 | assert_eq!(tiles[1], NODE_1); 274 | assert_eq!(tiles[2], NODE_2); 275 | } 276 | 277 | #[test] 278 | fn path_steps() { 279 | let nodes = vec![NODE_0, NODE_1, NODE_2]; 280 | let path = Path::new(nodes); 281 | let mut steps = path.steps(); 282 | assert_eq!( 283 | steps.next(), 284 | Some(Step { 285 | from: NODE_0, 286 | to: NODE_1, 287 | }) 288 | ); 289 | assert_eq!( 290 | steps.next(), 291 | Some(Step { 292 | from: NODE_1, 293 | to: NODE_2, 294 | }) 295 | ); 296 | assert_eq!(steps.next(), None); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/core/battle/scenario.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::core::{ 6 | battle::{ 7 | component::ObjType, 8 | state::{self, State}, 9 | PlayerId, TileType, 10 | }, 11 | map::{self, PosHex}, 12 | utils::roll_dice, 13 | }; 14 | 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | pub enum BattleType { 17 | Skirmish, 18 | CampaignNode, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct ObjectsGroup { 23 | pub owner: Option, 24 | pub typename: ObjType, 25 | pub line: Option, 26 | pub count: i32, 27 | } 28 | 29 | #[derive(Debug, Clone, Serialize, Deserialize)] 30 | pub struct Object { 31 | pub owner: Option, 32 | pub typename: ObjType, 33 | pub pos: PosHex, 34 | } 35 | 36 | // TODO: Split into `Scenario` (exact info) and `ScenarioTemplate`? 37 | #[derive(Debug, Clone, Serialize, Deserialize)] 38 | #[serde(default)] 39 | pub struct Scenario { 40 | pub map_radius: map::Distance, 41 | pub players_count: i32, 42 | 43 | // TODO: rename it to `randomized_tiles` later (not only `TileType::Rocks`) 44 | pub rocky_tiles_count: i32, 45 | 46 | pub tiles: HashMap, 47 | 48 | pub randomized_objects: Vec, 49 | 50 | pub objects: Vec, 51 | } 52 | 53 | #[derive(Clone, Debug, derive_more::From)] 54 | pub enum Error { 55 | MapIsTooSmall, 56 | PosOutsideOfMap(PosHex), 57 | NoPlayerAgents, 58 | NoEnemyAgents, 59 | UnsupportedPlayersCount(i32), 60 | } 61 | 62 | impl Scenario { 63 | pub fn check(&self) -> Result<(), Error> { 64 | if self.players_count != 2 { 65 | return Err(Error::UnsupportedPlayersCount(self.players_count)); 66 | } 67 | if self.map_radius.0 < 3 { 68 | return Err(Error::MapIsTooSmall); 69 | } 70 | let origin = PosHex { q: 0, r: 0 }; 71 | for obj in &self.objects { 72 | let dist = map::distance_hex(origin, obj.pos); 73 | if dist > self.map_radius { 74 | return Err(Error::PosOutsideOfMap(obj.pos)); 75 | } 76 | } 77 | let any_exact_player_agents = self 78 | .objects 79 | .iter() 80 | .any(|obj| obj.owner == Some(PlayerId(0))); 81 | let any_random_player_agents = self 82 | .randomized_objects 83 | .iter() 84 | .any(|obj| obj.owner == Some(PlayerId(0))); 85 | if !any_exact_player_agents && !any_random_player_agents { 86 | return Err(Error::NoPlayerAgents); 87 | } 88 | let any_exact_enemy_agents = self 89 | .objects 90 | .iter() 91 | .any(|obj| obj.owner == Some(PlayerId(1))); 92 | let any_random_enemy_agents = self 93 | .randomized_objects 94 | .iter() 95 | .any(|obj| obj.owner == Some(PlayerId(1))); 96 | if !any_exact_enemy_agents && !any_random_enemy_agents { 97 | return Err(Error::NoEnemyAgents); 98 | } 99 | Ok(()) 100 | } 101 | } 102 | 103 | impl Default for Scenario { 104 | fn default() -> Self { 105 | Self { 106 | map_radius: map::Distance(5), 107 | players_count: 2, 108 | rocky_tiles_count: 0, 109 | tiles: HashMap::new(), 110 | randomized_objects: Vec::new(), 111 | objects: Vec::new(), 112 | } 113 | } 114 | } 115 | 116 | pub fn random_free_pos(state: &State) -> Option { 117 | assert!(!state.deterministic_mode()); 118 | let attempts = 30; 119 | let radius = state.map().radius(); 120 | for _ in 0..attempts { 121 | let pos = PosHex { 122 | q: roll_dice(-radius.0, radius.0), 123 | r: roll_dice(-radius.0, radius.0), 124 | }; 125 | if state::is_tile_plain_and_completely_free(state, pos) { 126 | return Some(pos); 127 | } 128 | } 129 | None 130 | } 131 | 132 | fn middle_range(min: i32, max: i32) -> (i32, i32) { 133 | assert!(min <= max); 134 | let size = max - min; 135 | let half = size / 2; 136 | let forth = size / 4; 137 | let min = half - forth; 138 | let mut max = half + forth; 139 | if min == max { 140 | max += 1; 141 | } 142 | (min, max) 143 | } 144 | 145 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 146 | pub enum Line { 147 | Any, 148 | Front, 149 | Middle, 150 | Back, 151 | } 152 | 153 | impl Line { 154 | pub fn to_range(self, radius: map::Distance) -> (i32, i32) { 155 | let radius = radius.0; 156 | match self { 157 | Line::Front => (radius / 2, radius + 1), 158 | Line::Middle => middle_range(0, radius), 159 | Line::Back => (0, radius / 2), 160 | Line::Any => (0, radius + 1), 161 | } 162 | } 163 | } 164 | 165 | fn random_free_sector_pos(state: &State, player_id: PlayerId, line: Line) -> Option { 166 | assert!(!state.deterministic_mode()); 167 | let attempts = 30; 168 | let radius = state.map().radius(); 169 | let (min, max) = line.to_range(radius); 170 | for _ in 0..attempts { 171 | let q = radius.0 - roll_dice(min, max); 172 | let pos = PosHex { 173 | q: match player_id.0 { 174 | 0 => -q, 175 | 1 => q, 176 | _ => unimplemented!(), 177 | }, 178 | r: roll_dice(-radius.0, radius.0 + 1), 179 | }; 180 | let no_enemies_around = !state::check_enemies_around(state, pos, player_id); 181 | if state::is_tile_completely_free(state, pos) && no_enemies_around { 182 | return Some(pos); 183 | } 184 | } 185 | None 186 | } 187 | 188 | pub fn random_pos(state: &State, owner: Option, line: Option) -> Option { 189 | match (owner, line) { 190 | (Some(player_id), Some(line)) => random_free_sector_pos(state, player_id, line), 191 | _ => random_free_pos(state), 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | mod tests { 197 | use super::middle_range; 198 | 199 | #[test] 200 | fn test_middle_range() { 201 | assert_eq!(middle_range(0, 3), (1, 2)); 202 | assert_eq!(middle_range(0, 4), (1, 3)); 203 | assert_eq!(middle_range(0, 5), (1, 3)); 204 | assert_eq!(middle_range(0, 6), (2, 4)); 205 | assert_eq!(middle_range(0, 7), (2, 4)); 206 | assert_eq!(middle_range(0, 8), (2, 6)); 207 | assert_eq!(middle_range(0, 9), (2, 6)); 208 | assert_eq!(middle_range(0, 10), (3, 7)); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/core/battle/state.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ 2 | battle::{ 3 | self, 4 | ability::{self, Ability, PassiveAbility}, 5 | component::ObjType, 6 | effect, Id, PlayerId, Strength, TileType, 7 | }, 8 | map::{self, PosHex}, 9 | utils, 10 | }; 11 | 12 | pub use self::{ 13 | apply::apply, 14 | private::{BattleResult, State}, 15 | }; 16 | 17 | mod apply; 18 | mod private; 19 | 20 | pub fn is_agent_belong_to(state: &State, player_id: PlayerId, id: Id) -> bool { 21 | state.parts().belongs_to.get(id).0 == player_id 22 | } 23 | 24 | pub fn is_tile_blocked(state: &State, pos: PosHex) -> bool { 25 | assert!(state.map().is_inboard(pos)); 26 | for id in state.parts().blocker.ids() { 27 | if state.parts().pos.get(id).0 == pos { 28 | return true; 29 | } 30 | } 31 | false 32 | } 33 | 34 | pub fn is_tile_plain_and_completely_free(state: &State, pos: PosHex) -> bool { 35 | if !state.map().is_inboard(pos) || state.map().tile(pos) != TileType::Plain { 36 | return false; 37 | } 38 | for id in state.parts().pos.ids() { 39 | if state.parts().pos.get(id).0 == pos { 40 | return false; 41 | } 42 | } 43 | true 44 | } 45 | 46 | pub fn is_tile_completely_free(state: &State, pos: PosHex) -> bool { 47 | if !state.map().is_inboard(pos) { 48 | return false; 49 | } 50 | for id in state.parts().pos.ids() { 51 | if state.parts().pos.get(id).0 == pos { 52 | return false; 53 | } 54 | } 55 | true 56 | } 57 | 58 | pub fn is_lasting_effect_over(state: &State, id: Id, timed_effect: &effect::Timed) -> bool { 59 | if let effect::Lasting::Poison = timed_effect.effect { 60 | let strength = state.parts().strength.get(id).strength; 61 | if strength <= Strength(1) { 62 | return true; 63 | } 64 | } 65 | timed_effect.duration.is_over() 66 | } 67 | 68 | /// Are there any enemy agents on the adjacent tiles? 69 | pub fn check_enemies_around(state: &State, pos: PosHex, player_id: PlayerId) -> bool { 70 | for dir in map::dirs() { 71 | let neighbor_pos = map::Dir::get_neighbor_pos(pos, dir); 72 | if let Some(id) = agent_id_at_opt(state, neighbor_pos) { 73 | let neighbor_player_id = state.parts().belongs_to.get(id).0; 74 | if neighbor_player_id != player_id { 75 | return true; 76 | } 77 | } 78 | } 79 | false 80 | } 81 | 82 | pub fn ids_at(state: &State, pos: PosHex) -> Vec { 83 | let i = state.parts().pos.ids(); 84 | i.filter(|&id| state.parts().pos.get(id).0 == pos).collect() 85 | } 86 | 87 | pub fn obj_with_passive_ability_at( 88 | state: &State, 89 | pos: PosHex, 90 | ability: PassiveAbility, 91 | ) -> Option { 92 | for id in ids_at(state, pos) { 93 | if let Some(abilities) = state.parts().passive_abilities.get_opt(id) { 94 | for ¤t_ability in &abilities.0 { 95 | if current_ability == ability { 96 | return Some(id); 97 | } 98 | } 99 | } 100 | } 101 | None 102 | } 103 | 104 | pub fn blocker_id_at(state: &State, pos: PosHex) -> Id { 105 | blocker_id_at_opt(state, pos).unwrap() 106 | } 107 | 108 | pub fn blocker_id_at_opt(state: &State, pos: PosHex) -> Option { 109 | let ids = blocker_ids_at(state, pos); 110 | if ids.len() == 1 { 111 | Some(ids[0]) 112 | } else { 113 | None 114 | } 115 | } 116 | 117 | pub fn agent_id_at_opt(state: &State, pos: PosHex) -> Option { 118 | let ids = agent_ids_at(state, pos); 119 | if ids.len() == 1 { 120 | Some(ids[0]) 121 | } else { 122 | None 123 | } 124 | } 125 | 126 | pub fn agent_ids_at(state: &State, pos: PosHex) -> Vec { 127 | let i = state.parts().agent.ids(); 128 | i.filter(|&id| state.parts().pos.get(id).0 == pos).collect() 129 | } 130 | 131 | pub fn blocker_ids_at(state: &State, pos: PosHex) -> Vec { 132 | let i = state.parts().blocker.ids(); 133 | i.filter(|&id| state.parts().pos.get(id).0 == pos).collect() 134 | } 135 | 136 | pub fn players_agent_ids(state: &State, player_id: PlayerId) -> Vec { 137 | let i = state.parts().agent.ids(); 138 | i.filter(|&id| is_agent_belong_to(state, player_id, id)) 139 | .collect() 140 | } 141 | 142 | pub fn enemy_agent_ids(state: &State, player_id: PlayerId) -> Vec { 143 | let i = state.parts().agent.ids(); 144 | i.filter(|&id| !is_agent_belong_to(state, player_id, id)) 145 | .collect() 146 | } 147 | 148 | pub fn free_neighbor_positions(state: &State, origin: PosHex, count: i32) -> Vec { 149 | let mut positions = Vec::new(); 150 | for dir in utils::shuffle_vec(map::dirs().collect()) { 151 | let pos = map::Dir::get_neighbor_pos(origin, dir); 152 | if state.map().is_inboard(pos) && !is_tile_blocked(state, pos) { 153 | positions.push(pos); 154 | if positions.len() == count as usize { 155 | break; 156 | } 157 | } 158 | } 159 | positions 160 | } 161 | 162 | pub fn sort_agent_ids_by_distance_to_enemies(state: &State, ids: &mut [Id]) { 163 | ids.sort_unstable_by_key(|&id| { 164 | let agent_player_id = state.parts().belongs_to.get(id).0; 165 | let agent_pos = state.parts().pos.get(id).0; 166 | let mut min_distance = state.map().height(); 167 | for enemy_id in enemy_agent_ids(state, agent_player_id) { 168 | let enemy_pos = state.parts().pos.get(enemy_id).0; 169 | let distance = map::distance_hex(agent_pos, enemy_pos); 170 | if distance < min_distance { 171 | min_distance = distance; 172 | } 173 | } 174 | min_distance 175 | }); 176 | } 177 | 178 | pub fn get_armor(state: &State, id: Id) -> Strength { 179 | let parts = state.parts(); 180 | let default = Strength(0); 181 | parts.armor.get_opt(id).map(|v| v.armor).unwrap_or(default) 182 | } 183 | 184 | pub fn players_agent_types(state: &State, player_id: PlayerId) -> Vec { 185 | players_agent_ids(state, player_id) 186 | .into_iter() 187 | .map(|id| state.parts().meta.get(id).name.clone()) 188 | .collect() 189 | } 190 | 191 | pub fn can_agent_use_ability(state: &State, id: Id, ability: &Ability) -> bool { 192 | let parts = state.parts(); 193 | let agent_player_id = parts.belongs_to.get(id).0; 194 | let agent = parts.agent.get(id); 195 | let has_actions = agent.attacks > battle::Attacks(0) || agent.jokers > battle::Jokers(0); 196 | let is_player_agent = agent_player_id == state.player_id(); 197 | let abilities = &parts.abilities.get(id).0; 198 | let r_ability = abilities.iter().find(|r| &r.ability == ability).unwrap(); 199 | let is_ready = r_ability.status == ability::Status::Ready; 200 | is_player_agent && is_ready && has_actions 201 | } 202 | -------------------------------------------------------------------------------- /src/core/battle/state/private.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | 3 | use crate::core::{ 4 | battle::{ 5 | command, 6 | component::{Component, ObjType, Parts, Prototypes}, 7 | event::Event, 8 | execute, 9 | scenario::{self, Scenario}, 10 | state::apply::apply, 11 | Id, PlayerId, TileType, 12 | }, 13 | map, 14 | }; 15 | 16 | #[derive(Clone, Debug, PartialEq)] 17 | pub struct BattleResult { 18 | pub winner_id: PlayerId, 19 | pub survivor_types: Vec, 20 | } 21 | 22 | #[derive(Clone, Debug)] 23 | pub struct State { 24 | parts: Parts, 25 | map: map::HexMap, 26 | scenario: Scenario, 27 | player_id: PlayerId, 28 | prototypes: Prototypes, 29 | battle_result: Option, 30 | 31 | /// Enables panics when non-deterministic functions are called. 32 | deterministic_mode: bool, 33 | } 34 | 35 | impl State { 36 | pub fn new(prototypes: Prototypes, scenario: Scenario, cb: execute::Cb) -> Self { 37 | scenario.check().expect("Bad scenario"); 38 | assert!(scenario.map_radius.0 >= 3); 39 | let mut this = Self { 40 | map: map::HexMap::new(scenario.map_radius), 41 | player_id: PlayerId(0), 42 | scenario, 43 | parts: Parts::new(), 44 | prototypes, 45 | battle_result: None, 46 | deterministic_mode: false, 47 | }; 48 | this.create_terrain(); 49 | this.create_objects(cb); 50 | this 51 | } 52 | 53 | pub fn deterministic_mode(&self) -> bool { 54 | self.deterministic_mode 55 | } 56 | 57 | pub fn scenario(&self) -> &Scenario { 58 | &self.scenario 59 | } 60 | 61 | // TODO: Handle Scenario::exact_tiles 62 | fn create_terrain(&mut self) { 63 | for _ in 0..self.scenario.rocky_tiles_count { 64 | let pos = match scenario::random_free_pos(self) { 65 | Some(pos) => pos, 66 | None => continue, 67 | }; 68 | self.map.set_tile(pos, TileType::Rocks); 69 | } 70 | } 71 | 72 | // TODO: Handle Scenario::objects 73 | fn create_objects(&mut self, cb: execute::Cb) { 74 | let player_id_initial = self.player_id(); 75 | // TODO: Merge the cycles. Generate `objects` based on `randomized_objects`. 76 | for group in self.scenario.randomized_objects.clone() { 77 | if let Some(player_id) = group.owner { 78 | self.set_player_id(player_id); 79 | } 80 | for _ in 0..group.count { 81 | let pos = match scenario::random_pos(self, group.owner, group.line) { 82 | Some(pos) => pos, 83 | None => { 84 | error!("Can't find the position"); 85 | continue; 86 | } 87 | }; 88 | let command = command::Create { 89 | prototype: group.typename.clone(), 90 | pos, 91 | owner: group.owner, 92 | } 93 | .into(); 94 | execute::execute(self, &command, cb).expect("Can't create an object"); 95 | } 96 | } 97 | for group in self.scenario.objects.clone() { 98 | if let Some(player_id) = group.owner { 99 | self.set_player_id(player_id); 100 | } 101 | let command = command::Create { 102 | prototype: group.typename.clone(), 103 | pos: group.pos, 104 | owner: group.owner, 105 | } 106 | .into(); 107 | execute::execute(self, &command, cb).expect("Can't create an object"); 108 | } 109 | self.set_player_id(player_id_initial); 110 | } 111 | 112 | pub fn player_id(&self) -> PlayerId { 113 | self.player_id 114 | } 115 | 116 | pub fn next_player_id(&self) -> PlayerId { 117 | let current_player_id = PlayerId(self.player_id().0 + 1); 118 | if current_player_id.0 < self.scenario.players_count { 119 | current_player_id 120 | } else { 121 | PlayerId(0) 122 | } 123 | } 124 | 125 | pub fn parts(&self) -> &Parts { 126 | &self.parts 127 | } 128 | 129 | pub fn map(&self) -> &map::HexMap { 130 | &self.map 131 | } 132 | 133 | pub(in crate::core) fn prototype_for(&self, name: &ObjType) -> Vec { 134 | let prototypes = &self.prototypes.0; 135 | prototypes[name].clone() 136 | } 137 | 138 | pub fn battle_result(&self) -> &Option { 139 | &self.battle_result 140 | } 141 | } 142 | 143 | /// Public mutators. Be careful with them! 144 | impl State { 145 | pub(super) fn parts_mut(&mut self) -> &mut Parts { 146 | &mut self.parts 147 | } 148 | 149 | pub(in crate::core) fn set_player_id(&mut self, new_value: PlayerId) { 150 | self.player_id = new_value; 151 | } 152 | 153 | pub(super) fn set_battle_result(&mut self, result: BattleResult) { 154 | self.battle_result = Some(result); 155 | } 156 | 157 | #[allow(dead_code)] 158 | pub fn set_deterministic_mode(&mut self, value: bool) { 159 | self.deterministic_mode = value; 160 | } 161 | 162 | pub(in crate::core) fn alloc_id(&mut self) -> Id { 163 | self.parts.alloc_id() 164 | } 165 | 166 | pub fn apply(&mut self, event: &Event) { 167 | apply(self, event); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/core/map.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, iter::repeat}; 2 | 3 | use num::{Num, Signed}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 7 | pub struct Distance(pub i32); 8 | 9 | /// Cube coordinates 10 | /// 11 | #[derive(Debug, Clone, Copy, PartialEq)] 12 | pub struct PosCube { 13 | pub x: T, 14 | pub y: T, 15 | pub z: T, 16 | } 17 | 18 | /// Axial coordinates 19 | /// 20 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash, Eq)] 21 | pub struct PosHex { 22 | /// column 23 | pub q: T, 24 | 25 | /// row 26 | pub r: T, 27 | } 28 | 29 | pub fn hex_to_cube(hex: PosHex) -> PosCube { 30 | PosCube { 31 | x: hex.q, 32 | y: -hex.q - hex.r, 33 | z: hex.r, 34 | } 35 | } 36 | 37 | pub fn cube_to_hex(cube: PosCube) -> PosHex { 38 | PosHex { 39 | q: cube.x, 40 | r: cube.z, 41 | } 42 | } 43 | 44 | pub fn hex_round(hex: PosHex) -> PosHex { 45 | cube_to_hex(cube_round(hex_to_cube(hex))) 46 | } 47 | 48 | /// 49 | pub fn cube_round(cube: PosCube) -> PosCube { 50 | let mut rx = cube.x.round(); 51 | let mut ry = cube.y.round(); 52 | let mut rz = cube.z.round(); 53 | let x_diff = (rx - cube.x).abs(); 54 | let y_diff = (ry - cube.y).abs(); 55 | let z_diff = (rz - cube.z).abs(); 56 | if x_diff > y_diff && x_diff > z_diff { 57 | rx = -ry - rz; 58 | } else if y_diff > z_diff { 59 | ry = -rx - rz; 60 | } else { 61 | rz = -rx - ry; 62 | } 63 | PosCube { 64 | x: rx as i32, 65 | y: ry as i32, 66 | z: rz as i32, 67 | } 68 | } 69 | 70 | pub fn distance_cube(a: PosCube, b: PosCube) -> Distance { 71 | let n = ((a.x - b.x).abs() + (a.y - b.y).abs() + (a.z - b.z).abs()) / 2; 72 | Distance(n) 73 | } 74 | 75 | pub fn distance_hex(a: PosHex, b: PosHex) -> Distance { 76 | distance_cube(hex_to_cube(a), hex_to_cube(b)) 77 | } 78 | 79 | fn is_inboard(radius: Distance, pos: PosHex) -> bool { 80 | let origin = PosHex { q: 0, r: 0 }; 81 | distance_hex(origin, pos) <= radius 82 | } 83 | 84 | #[derive(Clone, Debug)] 85 | pub struct HexIter { 86 | cursor: PosHex, 87 | radius: Distance, 88 | } 89 | 90 | impl HexIter { 91 | fn new(radius: Distance) -> Self { 92 | let mut iter = Self { 93 | cursor: PosHex { 94 | q: -radius.0, 95 | r: -radius.0, 96 | }, 97 | radius, 98 | }; 99 | iter.inc_cursor_with_hex_bounds(); 100 | iter 101 | } 102 | 103 | fn inc_cursor(&mut self) { 104 | self.cursor.q += 1; 105 | if self.cursor.q > self.radius.0 { 106 | self.cursor.q = -self.radius.0; 107 | self.cursor.r += 1; 108 | } 109 | } 110 | 111 | fn inc_cursor_with_hex_bounds(&mut self) { 112 | self.inc_cursor(); 113 | while !is_inboard(self.radius, self.cursor) && self.cursor.r < self.radius.0 + 1 { 114 | self.inc_cursor(); 115 | } 116 | } 117 | } 118 | 119 | impl Iterator for HexIter { 120 | type Item = PosHex; 121 | 122 | fn next(&mut self) -> Option { 123 | if self.cursor.r > self.radius.0 { 124 | None 125 | } else { 126 | let current = self.cursor; 127 | self.inc_cursor_with_hex_bounds(); 128 | Some(current) 129 | } 130 | } 131 | } 132 | 133 | /// Helper function to dump some kind of a map's state as an ascii image. 134 | /// 135 | /// Output example: 136 | /// 137 | /// ```plain 138 | /// _ A A A o _ 139 | /// _ o A A A _ _ 140 | /// _ _ A A _ _ _ _ 141 | /// _ _ _ o _ A _ _ _ 142 | /// _ A _ _ _ _ A A _ _ 143 | /// _ _ _ _ _ _ A _ _ _ _ 144 | /// _ _ _ _ _ _ _ A _ _ 145 | /// _ A _ _ A o _ _ _ 146 | /// _ _ _ _ _ _ _ _ 147 | /// _ _ _ A A _ _ 148 | /// _ _ _ _ _ _ 149 | /// ``` 150 | /// 151 | #[allow(dead_code)] 152 | pub fn dump_map char>(radius: Distance, f: F) { 153 | let s = radius.0; 154 | for r in -s..=s { 155 | for _ in -s..r { 156 | print!(" "); 157 | } 158 | for q in -s..=s { 159 | let pos = PosHex { q, r }; 160 | if is_inboard(radius, pos) { 161 | print!("{} ", f(pos)); 162 | } else { 163 | print!(" "); 164 | } 165 | } 166 | println!(); 167 | } 168 | println!(); 169 | } 170 | 171 | pub fn radius_to_diameter(radius: Distance) -> Distance { 172 | Distance(radius.0 * 2 + 1) 173 | } 174 | 175 | /// 176 | /// [-1, 0] [0, -1] 177 | /// [-1, 1] [0, 0] [1, -1] 178 | /// [ 0, 1] [ 1, 0] 179 | /// 180 | #[derive(Debug, Clone)] 181 | pub struct HexMap { 182 | tiles: Vec, 183 | size: Distance, 184 | radius: Distance, 185 | } 186 | 187 | impl HexMap { 188 | pub fn new(radius: Distance) -> Self { 189 | let size = Distance(radius.0 * 2 + 1); 190 | let tiles_count = (size.0 * size.0) as usize; 191 | let tiles = repeat(Default::default()).take(tiles_count).collect(); 192 | Self { 193 | tiles, 194 | size, 195 | radius, 196 | } 197 | } 198 | 199 | pub fn radius(&self) -> Distance { 200 | self.radius 201 | } 202 | 203 | pub fn height(&self) -> Distance { 204 | radius_to_diameter(self.radius()) 205 | } 206 | 207 | pub fn iter(&self) -> HexIter { 208 | HexIter::new(self.radius) 209 | } 210 | 211 | pub fn is_inboard(&self, pos: PosHex) -> bool { 212 | is_inboard(self.radius, pos) 213 | } 214 | 215 | fn hex_to_index(&self, hex: PosHex) -> usize { 216 | let i = (hex.r + self.radius.0) + (hex.q + self.radius.0) * self.size.0; 217 | i as usize 218 | } 219 | 220 | pub fn tile(&self, pos: PosHex) -> T { 221 | assert!(self.is_inboard(pos)); 222 | self.tiles[self.hex_to_index(pos)] 223 | } 224 | 225 | pub fn set_tile(&mut self, pos: PosHex, tile: T) { 226 | assert!(self.is_inboard(pos)); 227 | let index = self.hex_to_index(pos); 228 | self.tiles[index] = tile; 229 | } 230 | } 231 | 232 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] 233 | pub enum Dir { 234 | SouthEast, 235 | East, 236 | NorthEast, 237 | NorthWest, 238 | West, 239 | SouthWest, 240 | } 241 | 242 | /// 243 | const DIR_TO_POS_DIFF: [[i32; 2]; 6] = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]]; 244 | 245 | impl Dir { 246 | pub fn from_int(n: i32) -> Self { 247 | assert!((0..6).contains(&n)); 248 | let dirs = [ 249 | Dir::SouthEast, 250 | Dir::East, 251 | Dir::NorthEast, 252 | Dir::NorthWest, 253 | Dir::West, 254 | Dir::SouthWest, 255 | ]; 256 | dirs[n as usize] 257 | } 258 | 259 | pub fn to_int(self) -> i32 { 260 | match self { 261 | Dir::SouthEast => 0, 262 | Dir::East => 1, 263 | Dir::NorthEast => 2, 264 | Dir::NorthWest => 3, 265 | Dir::West => 4, 266 | Dir::SouthWest => 5, 267 | } 268 | } 269 | 270 | pub fn get_dir_from_to(from: PosHex, to: PosHex) -> Self { 271 | assert_eq!(distance_hex(from, to), Distance(1)); 272 | let diff = [to.q - from.q, to.r - from.r]; 273 | for dir in dirs() { 274 | if diff == DIR_TO_POS_DIFF[dir.to_int() as usize] { 275 | return dir; 276 | } 277 | } 278 | panic!("impossible positions: {:?}, {:?}", from, to); // TODO: implement Display for PosHex 279 | } 280 | 281 | pub fn get_neighbor_pos(pos: PosHex, dir: Self) -> PosHex { 282 | let diff = DIR_TO_POS_DIFF[dir.to_int() as usize]; 283 | PosHex { 284 | q: pos.q + diff[0], 285 | r: pos.r + diff[1], 286 | } 287 | } 288 | } 289 | 290 | #[derive(Clone, Debug)] 291 | pub struct DirIter { 292 | index: i32, 293 | } 294 | 295 | pub fn dirs() -> DirIter { 296 | DirIter { index: 0 } 297 | } 298 | 299 | impl Iterator for DirIter { 300 | type Item = Dir; 301 | 302 | fn next(&mut self) -> Option { 303 | let max = DIR_TO_POS_DIFF.len() as i32; 304 | let next_dir = if self.index >= max { 305 | None 306 | } else { 307 | Some(Dir::from_int(self.index)) 308 | }; 309 | self.index += 1; 310 | next_dir 311 | } 312 | } 313 | 314 | #[cfg(test)] 315 | mod tests { 316 | use crate::core::map::{Distance, HexMap}; 317 | 318 | #[test] 319 | fn test_map_height() { 320 | let map: HexMap = HexMap::new(Distance(3)); 321 | let height = map.height(); 322 | assert_eq!(height, Distance(7)); 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/core/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use quad_rand::compat::QuadRand; 4 | use rand::{distributions::uniform::SampleUniform, seq::SliceRandom, Rng}; 5 | 6 | pub fn zrng() -> impl rand::Rng { 7 | QuadRand 8 | } 9 | 10 | pub fn roll_dice(low: T, high: T) -> T { 11 | zrng().gen_range(low..high) 12 | } 13 | 14 | pub fn shuffle_vec(mut vec: Vec) -> Vec { 15 | vec.shuffle(&mut zrng()); 16 | vec 17 | } 18 | 19 | /// Remove an element from a vector. 20 | pub fn try_remove_item(vec: &mut Vec, e: &T) -> bool { 21 | vec.iter() 22 | .position(|current| current == e) 23 | .map(|e| vec.remove(e)) 24 | .is_some() 25 | } 26 | 27 | pub fn clamp_min(value: T, min: T) -> T { 28 | if value < min { 29 | min 30 | } else { 31 | value 32 | } 33 | } 34 | 35 | pub fn clamp_max(value: T, max: T) -> T { 36 | if value > max { 37 | max 38 | } else { 39 | value 40 | } 41 | } 42 | 43 | pub fn clamp(value: T, min: T, max: T) -> T { 44 | debug_assert!(min <= max, "min must be less than or equal to max"); 45 | if value < min { 46 | min 47 | } else if value > max { 48 | max 49 | } else { 50 | value 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | #[test] 57 | fn test_clamp_min() { 58 | assert_eq!(super::clamp_min(1, 0), 1); 59 | assert_eq!(super::clamp_min(0, 0), 0); 60 | assert_eq!(super::clamp_min(-1, 0), 0); 61 | } 62 | 63 | #[test] 64 | fn test_clamp_max() { 65 | assert_eq!(super::clamp_max(1, 2), 1); 66 | assert_eq!(super::clamp_max(2, 2), 2); 67 | assert_eq!(super::clamp_max(3, 2), 2); 68 | } 69 | 70 | #[test] 71 | fn test_clamp() { 72 | let min = 0; 73 | let max = 2; 74 | assert_eq!(super::clamp(1, min, max), 1); 75 | assert_eq!(super::clamp(0, min, max), 0); 76 | assert_eq!(super::clamp(-1, min, max), 0); 77 | assert_eq!(super::clamp(1, min, max), 1); 78 | assert_eq!(super::clamp(2, min, max), 2); 79 | assert_eq!(super::clamp(3, min, max), 2); 80 | } 81 | 82 | #[test] 83 | fn test_try_remove_item() { 84 | let mut a = vec![1, 2, 3]; 85 | assert!(super::try_remove_item(&mut a, &1)); 86 | assert_eq!(&a, &[2, 3]); 87 | assert!(!super::try_remove_item(&mut a, &666)); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt, io, path::PathBuf}; 2 | 3 | #[derive(Debug, derive_more::From)] 4 | pub enum ZError { 5 | Ui(ui::Error), 6 | Scene(zscene::Error), 7 | RonDeserialize { 8 | error: ron::de::Error, 9 | path: PathBuf, 10 | }, 11 | IO(io::Error), 12 | MqFile(mq::file::FileError), 13 | MqFont(mq::text::FontError), 14 | } 15 | 16 | impl ZError { 17 | pub fn from_ron_de_error(error: ron::de::Error, path: PathBuf) -> Self { 18 | ZError::RonDeserialize { error, path } 19 | } 20 | } 21 | 22 | impl fmt::Display for ZError { 23 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 24 | match self { 25 | ZError::Ui(ref e) => write!(f, "ZGUI Error: {}", e), 26 | ZError::Scene(ref e) => write!(f, "ZScene Error: {}", e), 27 | ZError::RonDeserialize { error, path } => { 28 | let s = path.to_str().unwrap_or(""); 29 | write!(f, "Can't deserialize '{}': {}", s, error) 30 | } 31 | ZError::IO(ref e) => write!(f, "IO Error: {}", e), 32 | ZError::MqFile(ref e) => write!(f, "Macroquad File error: {}", e), 33 | ZError::MqFont(ref e) => write!(f, "Macroquad Font error: {}", e), 34 | } 35 | } 36 | } 37 | 38 | impl error::Error for ZError { 39 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 40 | match self { 41 | ZError::Ui(ref e) => Some(e), 42 | ZError::Scene(ref e) => Some(e), 43 | ZError::RonDeserialize { error, .. } => Some(error), 44 | ZError::IO(ref e) => Some(e), 45 | ZError::MqFile(ref e) => Some(e), 46 | ZError::MqFont(ref e) => Some(e), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/geom.rs: -------------------------------------------------------------------------------- 1 | use mq::math::Vec2; 2 | 3 | use crate::core::{ 4 | map::{hex_round, PosHex}, 5 | utils::roll_dice, 6 | }; 7 | 8 | const SQRT_OF_3: f32 = 1.732_05; 9 | 10 | pub const FLATNESS_COEFFICIENT: f32 = 0.8125; // should fit the tile sprite's geometry 11 | 12 | /// 13 | pub fn hex_to_point(size: f32, hex: PosHex) -> Vec2 { 14 | let x = size * SQRT_OF_3 * (hex.q as f32 + hex.r as f32 / 2.0); 15 | let y = size * 3.0 / 2.0 * hex.r as f32; 16 | Vec2::new(x, y * FLATNESS_COEFFICIENT) 17 | } 18 | 19 | /// 20 | pub fn point_to_hex(size: f32, mut point: Vec2) -> PosHex { 21 | point.y /= FLATNESS_COEFFICIENT; 22 | let q = (point.x * SQRT_OF_3 / 3.0 - point.y / 3.0) / size; 23 | let r = point.y * 2.0 / 3.0 / size; 24 | hex_round(PosHex { q, r }) 25 | } 26 | 27 | pub fn rand_tile_offset(size: f32, radius: f32) -> Vec2 { 28 | assert!(radius >= 0.0); 29 | let r = size * radius; 30 | Vec2::new(roll_dice(-r, r), roll_dice(-r, r) * FLATNESS_COEFFICIENT) 31 | } 32 | 33 | #[derive(Clone, Copy, Debug)] 34 | pub enum Facing { 35 | Left, 36 | Right, 37 | } 38 | 39 | impl Facing { 40 | pub fn from_positions(tile_size: f32, from: PosHex, to: PosHex) -> Option { 41 | if from == to { 42 | return None; 43 | } 44 | let from = hex_to_point(tile_size, from); 45 | let to = hex_to_point(tile_size, to); 46 | Some(if to.x > from.x { 47 | Facing::Right 48 | } else { 49 | Facing::Left 50 | }) 51 | } 52 | 53 | pub fn to_scene_facing(self) -> zscene::Facing { 54 | match self { 55 | Facing::Left => zscene::Facing::Left, 56 | Facing::Right => zscene::Facing::Right, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | 3 | use std::time::Duration; 4 | 5 | use mq::{input, window}; 6 | 7 | mod assets; 8 | mod core; 9 | mod error; 10 | mod geom; 11 | mod screen; 12 | mod utils; 13 | 14 | type ZResult = Result; 15 | 16 | struct MainState { 17 | screens: screen::ScreenStack, 18 | } 19 | 20 | impl MainState { 21 | fn new() -> ZResult { 22 | let start_screen = Box::new(screen::MainMenu::new()?); 23 | let screens = screen::ScreenStack::new(start_screen)?; 24 | Ok(Self { screens }) 25 | } 26 | 27 | fn tick(&mut self) -> ZResult { 28 | // Handle possible window resize and create a camera. 29 | let aspect_ratio = utils::aspect_ratio(); 30 | let camera = utils::make_and_set_camera(aspect_ratio); 31 | self.screens.resize(aspect_ratio)?; 32 | // Handle user input events. 33 | let pos = utils::get_world_mouse_pos(&camera); 34 | self.screens.move_mouse(pos)?; 35 | if input::is_mouse_button_pressed(input::MouseButton::Left) { 36 | self.screens.click(pos)?; 37 | } 38 | // Update the game state. 39 | let dtime = Duration::from_secs_f32(mq::time::get_frame_time()); 40 | self.screens.update(dtime)?; 41 | // Draw everything. 42 | mq::window::clear_background(screen::COLOR_SCREEN_BG); 43 | self.screens.draw()?; 44 | Ok(()) 45 | } 46 | } 47 | 48 | fn window_conf() -> window::Conf { 49 | window::Conf { 50 | window_title: "Zemeroth".to_owned(), 51 | high_dpi: true, 52 | ..Default::default() 53 | } 54 | } 55 | 56 | #[mq::main(window_conf)] 57 | #[macroquad(crate_rename = "mq")] 58 | async fn main() -> ZResult { 59 | // std::env isn't supported on WASM. 60 | #[cfg(not(target_arch = "wasm32"))] 61 | if std::env::var("RUST_BACKTRACE").is_err() { 62 | std::env::set_var("RUST_BACKTRACE", "1"); 63 | } 64 | env_logger::init(); 65 | quad_rand::srand(mq::miniquad::date::now() as _); 66 | mq::file::set_pc_assets_folder("assets"); 67 | assets::load().await.expect("Can't load assets"); 68 | let mut state = MainState::new().expect("Can't create the main state"); 69 | loop { 70 | state.tick().expect("Tick failed"); 71 | window::next_frame().await; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/screen.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, time::Duration}; 2 | 3 | use log::info; 4 | use mq::{ 5 | color::Color, 6 | math::{Rect, Vec2}, 7 | }; 8 | 9 | use crate::{utils, ZResult}; 10 | 11 | mod agent_info; 12 | mod battle; 13 | mod campaign; 14 | mod confirm; 15 | mod general_info; 16 | mod main_menu; 17 | 18 | pub use self::{ 19 | agent_info::AgentInfo, battle::Battle, campaign::Campaign, confirm::Confirm, 20 | general_info::GeneralInfo, main_menu::MainMenu, 21 | }; 22 | 23 | pub const COLOR_SCREEN_BG: Color = Color::new(0.9, 0.9, 0.8, 1.0); 24 | pub const COLOR_POPUP_BG: Color = Color::new(0.9, 0.9, 0.8, 0.9); 25 | 26 | #[derive(Debug)] 27 | pub enum StackCommand { 28 | None, 29 | PushScreen(Box), 30 | PushPopup(Box), 31 | Pop, 32 | } 33 | 34 | pub trait Screen: Debug { 35 | fn update(&mut self, dtime: Duration) -> ZResult; 36 | fn draw(&self) -> ZResult; 37 | fn click(&mut self, pos: Vec2) -> ZResult; 38 | fn resize(&mut self, aspect_ratio: f32); 39 | 40 | fn move_mouse(&mut self, _pos: Vec2) -> ZResult { 41 | Ok(()) 42 | } 43 | } 44 | 45 | const ERR_MSG_STACK_EMPTY: &str = "Screen stack is empty"; 46 | 47 | struct ScreenWithPopups { 48 | screen: Box, 49 | popups: Vec>, 50 | } 51 | 52 | impl ScreenWithPopups { 53 | fn new(screen: Box) -> Self { 54 | Self { 55 | screen, 56 | popups: Vec::new(), 57 | } 58 | } 59 | 60 | fn top_mut(&mut self) -> &mut dyn Screen { 61 | match self.popups.last_mut() { 62 | Some(popup) => popup.as_mut(), 63 | None => self.screen.as_mut(), 64 | } 65 | } 66 | } 67 | 68 | pub struct ScreenStack { 69 | screens: Vec, 70 | } 71 | 72 | impl ScreenStack { 73 | pub fn new(start_screen: Box) -> ZResult { 74 | Ok(Self { 75 | screens: vec![ScreenWithPopups::new(start_screen)], 76 | }) 77 | } 78 | 79 | pub fn update(&mut self, dtime: Duration) -> ZResult { 80 | let command = self.screen_mut().top_mut().update(dtime)?; 81 | self.handle_command(command) 82 | } 83 | 84 | pub fn draw(&self) -> ZResult { 85 | let screen = self.screen(); 86 | screen.screen.draw()?; 87 | for popup in &screen.popups { 88 | self.draw_popup_bg(); 89 | popup.draw()?; 90 | } 91 | Ok(()) 92 | } 93 | 94 | pub fn click(&mut self, pos: Vec2) -> ZResult { 95 | let command = self.screen_mut().top_mut().click(pos)?; 96 | self.handle_command(command) 97 | } 98 | 99 | pub fn move_mouse(&mut self, pos: Vec2) -> ZResult { 100 | self.screen_mut().top_mut().move_mouse(pos) 101 | } 102 | 103 | pub fn resize(&mut self, aspect_ratio: f32) -> ZResult { 104 | for screen in &mut self.screens { 105 | screen.screen.resize(aspect_ratio); 106 | for popup in &mut screen.popups { 107 | popup.resize(aspect_ratio); 108 | } 109 | } 110 | Ok(()) 111 | } 112 | 113 | pub fn handle_command(&mut self, command: StackCommand) -> ZResult { 114 | match command { 115 | StackCommand::None => {} 116 | StackCommand::PushScreen(screen) => { 117 | info!("Screens::handle_command: PushScreen"); 118 | self.screens.push(ScreenWithPopups::new(screen)); 119 | } 120 | StackCommand::Pop => { 121 | info!("Screens::handle_command: Pop"); 122 | let popups = &mut self.screen_mut().popups; 123 | if !popups.is_empty() { 124 | popups.pop().expect(ERR_MSG_STACK_EMPTY); 125 | } else if self.screens.len() > 1 { 126 | self.screens.pop().expect(ERR_MSG_STACK_EMPTY); 127 | } else { 128 | std::process::exit(0); 129 | } 130 | } 131 | StackCommand::PushPopup(screen) => { 132 | info!("Screens::handle_command: PushPopup"); 133 | self.screen_mut().popups.push(screen); 134 | } 135 | } 136 | Ok(()) 137 | } 138 | 139 | /// Returns a mutable reference to the top screen. 140 | fn screen_mut(&mut self) -> &mut ScreenWithPopups { 141 | self.screens.last_mut().expect(ERR_MSG_STACK_EMPTY) 142 | } 143 | 144 | /// Returns a reference to the top screen. 145 | fn screen(&self) -> &ScreenWithPopups { 146 | self.screens.last().expect(ERR_MSG_STACK_EMPTY) 147 | } 148 | 149 | fn draw_popup_bg(&self) { 150 | let aspect_ratio = utils::aspect_ratio(); 151 | let r = Rect::new(-aspect_ratio, -1.0, aspect_ratio * 2.0, 2.0); 152 | mq::shapes::draw_rectangle(r.x, r.y, r.w, r.h, COLOR_POPUP_BG); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/screen/confirm.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::mpsc::{Receiver, Sender}, 3 | time::Duration, 4 | }; 5 | 6 | use mq::math::Vec2; 7 | use ui::{self, Gui, Widget}; 8 | 9 | use crate::{ 10 | assets, 11 | screen::{Screen, StackCommand}, 12 | utils, ZResult, 13 | }; 14 | 15 | #[derive(Clone, Debug, PartialEq)] 16 | pub enum Message { 17 | Yes, 18 | No, 19 | } 20 | 21 | /// A helper function for a receiving side. 22 | pub fn try_receive_yes(opt_rx: &Option>) -> bool { 23 | utils::try_receive(opt_rx) == Some(Message::Yes) 24 | } 25 | 26 | #[derive(Debug)] 27 | pub struct Confirm { 28 | gui: Gui, 29 | sender: Sender, 30 | } 31 | 32 | impl Confirm { 33 | pub fn from_lines(lines: &[impl AsRef], sender: Sender) -> ZResult { 34 | let font = assets::get().font; 35 | let h = utils::line_heights().big; 36 | let mut layout = ui::VLayout::new(); 37 | for line in lines { 38 | let text = ui::Drawable::text(line.as_ref(), font); 39 | let label = Box::new(ui::Label::new(text, h)?); 40 | layout.add(label); 41 | } 42 | Self::from_widget(Box::new(layout), sender) 43 | } 44 | 45 | pub fn from_line(line: &str, sender: Sender) -> ZResult { 46 | Self::from_lines(&[line], sender) 47 | } 48 | 49 | pub fn from_widget(widget: Box, sender: Sender) -> ZResult { 50 | let font = assets::get().font; 51 | let mut gui = ui::Gui::new(); 52 | let h = utils::line_heights().big; 53 | let mut layout = Box::new(ui::VLayout::new()); 54 | let spacer = || Box::new(ui::Spacer::new_vertical(h * 0.5)); 55 | let button = |line, message| -> ZResult<_> { 56 | let text = ui::Drawable::text(line, font); 57 | let b = ui::Button::new(text, h, gui.sender(), message)?.stretchable(true); 58 | Ok(b) 59 | }; 60 | let button_width = widget.rect().w / 3.0; 61 | let mut yes = button("yes", Message::Yes)?; 62 | yes.stretch(button_width); 63 | let mut no = button("no", Message::No)?; 64 | no.stretch(button_width); 65 | let spacer_width = widget.rect().w - yes.rect().w - no.rect().w; 66 | let mut line_layout = ui::HLayout::new(); 67 | line_layout.add(Box::new(yes)); 68 | line_layout.add(Box::new(ui::Spacer::new_horizontal(spacer_width))); 69 | line_layout.add(Box::new(no)); 70 | layout.add(widget); 71 | layout.add(spacer()); 72 | layout.add(Box::new(line_layout)); 73 | let layout = utils::add_offsets_and_bg_big(layout)?; 74 | let anchor = ui::Anchor(ui::HAnchor::Middle, ui::VAnchor::Middle); 75 | gui.add(&ui::pack(layout), anchor); 76 | Ok(Self { gui, sender }) 77 | } 78 | } 79 | 80 | // TODO: handle Enter/ESC keys 81 | impl Screen for Confirm { 82 | fn update(&mut self, _dtime: Duration) -> ZResult { 83 | Ok(StackCommand::None) 84 | } 85 | 86 | fn draw(&self) -> ZResult { 87 | self.gui.draw(); 88 | Ok(()) 89 | } 90 | 91 | fn click(&mut self, pos: Vec2) -> ZResult { 92 | let message = self.gui.click(pos); 93 | match message { 94 | Some(message) => { 95 | self.sender 96 | .send(message) 97 | .expect("Can't report back the result"); 98 | Ok(StackCommand::Pop) 99 | } 100 | None => Ok(StackCommand::None), 101 | } 102 | } 103 | 104 | fn resize(&mut self, aspect_ratio: f32) { 105 | self.gui.resize_if_needed(aspect_ratio); 106 | } 107 | 108 | fn move_mouse(&mut self, pos: Vec2) -> ZResult { 109 | self.gui.move_mouse(pos); 110 | Ok(()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/screen/general_info.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use mq::math::Vec2; 4 | use ui::{self, Gui, Widget}; 5 | 6 | use crate::{ 7 | assets, 8 | screen::{Screen, StackCommand}, 9 | utils, ZResult, 10 | }; 11 | 12 | #[derive(Clone, Debug)] 13 | enum Message { 14 | Back, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub struct GeneralInfo { 19 | gui: Gui, 20 | } 21 | 22 | impl GeneralInfo { 23 | pub fn new(title: &str, lines: &[String]) -> ZResult { 24 | let font = assets::get().font; 25 | let mut gui = ui::Gui::new(); 26 | let h = utils::line_heights().normal; 27 | let mut layout = Box::new(ui::VLayout::new().stretchable(true)); 28 | let text_ = |s: &str| ui::Drawable::text(s, font); 29 | let label_ = |text: &str| -> ZResult<_> { Ok(ui::Label::new(text_(text), h)?) }; 30 | let label = |text: &str| -> ZResult<_> { Ok(Box::new(label_(text)?)) }; 31 | let label_s = |text: &str| -> ZResult<_> { Ok(Box::new(label_(text)?.stretchable(true))) }; 32 | let spacer = || Box::new(ui::Spacer::new_vertical(h * 0.5)); 33 | layout.add(label_s(&format!("~~~ {} ~~~", title))?); 34 | layout.add(spacer()); 35 | for line in lines { 36 | layout.add(label(line)?); 37 | } 38 | layout.add(spacer()); 39 | { 40 | let mut button = 41 | ui::Button::new(text_("back"), h, gui.sender(), Message::Back)?.stretchable(true); 42 | button.stretch(layout.rect().w / 3.0); 43 | button.set_stretchable(false); 44 | layout.add(Box::new(button)); 45 | } 46 | layout.stretch_to_self(); 47 | let layout = utils::add_offsets_and_bg_big(layout)?; 48 | let anchor = ui::Anchor(ui::HAnchor::Middle, ui::VAnchor::Middle); 49 | gui.add(&ui::pack(layout), anchor); 50 | Ok(Self { gui }) 51 | } 52 | } 53 | 54 | impl Screen for GeneralInfo { 55 | fn update(&mut self, _dtime: Duration) -> ZResult { 56 | Ok(StackCommand::None) 57 | } 58 | 59 | fn draw(&self) -> ZResult { 60 | self.gui.draw(); 61 | Ok(()) 62 | } 63 | 64 | fn click(&mut self, pos: Vec2) -> ZResult { 65 | let message = self.gui.click(pos); 66 | match message { 67 | Some(Message::Back) => Ok(StackCommand::Pop), 68 | None => Ok(StackCommand::None), 69 | } 70 | } 71 | 72 | fn resize(&mut self, aspect_ratio: f32) { 73 | self.gui.resize_if_needed(aspect_ratio); 74 | } 75 | 76 | fn move_mouse(&mut self, pos: Vec2) -> ZResult { 77 | self.gui.move_mouse(pos); 78 | Ok(()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/screen/main_menu.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::mpsc::{channel, Receiver}, 3 | time::Duration, 4 | }; 5 | 6 | use log::trace; 7 | use mq::math::Vec2; 8 | use ui::{self, Widget}; 9 | 10 | use crate::{ 11 | assets, 12 | core::battle::{scenario, state}, 13 | screen::{self, Screen, StackCommand}, 14 | utils, ZResult, 15 | }; 16 | 17 | #[derive(Copy, Clone, Debug)] 18 | enum Message { 19 | #[cfg_attr(target_arch = "wasm32", allow(unused))] // can't quit WASM so it's not used there 20 | Exit, 21 | 22 | StartInstant, 23 | 24 | StartCampaign, 25 | } 26 | 27 | fn make_gui() -> ZResult> { 28 | let font = assets::get().font; 29 | let mut gui = ui::Gui::new(); 30 | let h = utils::line_heights().large; 31 | let space = || Box::new(ui::Spacer::new_vertical(h / 8.0)); 32 | let button = &mut |text, message| -> ZResult<_> { 33 | let text = ui::Drawable::text(text, font); 34 | let b = ui::Button::new(text, h, gui.sender(), message)?.stretchable(true); 35 | Ok(Box::new(b)) 36 | }; 37 | let mut layout = Box::new(ui::VLayout::new().stretchable(true)); 38 | layout.add(button("demo battle", Message::StartInstant)?); 39 | layout.add(space()); 40 | layout.add(button("campaign", Message::StartCampaign)?); 41 | #[cfg(not(target_arch = "wasm32"))] // can't quit WASM 42 | { 43 | layout.add(space()); 44 | layout.add(button("exit", Message::Exit)?); 45 | } 46 | layout.stretch_to_self(); 47 | let layout = utils::add_offsets_and_bg_big(layout)?; 48 | let anchor = ui::Anchor(ui::HAnchor::Middle, ui::VAnchor::Middle); 49 | gui.add(&ui::pack(layout), anchor); 50 | Ok(gui) 51 | } 52 | 53 | #[derive(Debug)] 54 | pub struct MainMenu { 55 | gui: ui::Gui, 56 | receiver_battle_result: Option>>, 57 | } 58 | 59 | // TODO: add the game's version to one of the corners 60 | impl MainMenu { 61 | pub fn new() -> ZResult { 62 | let gui = make_gui()?; 63 | Ok(Self { 64 | gui, 65 | receiver_battle_result: None, 66 | }) 67 | } 68 | } 69 | 70 | impl Screen for MainMenu { 71 | fn update(&mut self, _: Duration) -> ZResult { 72 | Ok(StackCommand::None) 73 | } 74 | 75 | fn draw(&self) -> ZResult { 76 | self.gui.draw(); 77 | Ok(()) 78 | } 79 | 80 | fn click(&mut self, pos: Vec2) -> ZResult { 81 | let message = self.gui.click(pos); 82 | trace!("MainMenu: click: pos={:?}, message={:?}", pos, message); 83 | match message { 84 | Some(Message::StartInstant) => { 85 | let prototypes = assets::get().prototypes.clone(); 86 | let scenario = assets::get().demo_scenario.clone(); 87 | let (sender, receiver) = channel(); 88 | self.receiver_battle_result = Some(receiver); 89 | let battle_type = scenario::BattleType::Skirmish; 90 | let screen = screen::Battle::new(scenario, battle_type, prototypes, sender)?; 91 | Ok(StackCommand::PushScreen(Box::new(screen))) 92 | } 93 | Some(Message::StartCampaign) => { 94 | let screen = screen::Campaign::new()?; 95 | Ok(StackCommand::PushScreen(Box::new(screen))) 96 | } 97 | Some(Message::Exit) => Ok(StackCommand::Pop), 98 | None => Ok(StackCommand::None), 99 | } 100 | } 101 | 102 | fn resize(&mut self, aspect_ratio: f32) { 103 | self.gui.resize_if_needed(aspect_ratio); 104 | } 105 | 106 | fn move_mouse(&mut self, pos: Vec2) -> ZResult { 107 | self.gui.move_mouse(pos); 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::mpsc::Receiver, time::Duration}; 2 | 3 | use mq::{ 4 | camera::{set_camera, Camera2D}, 5 | math::{Rect, Vec2}, 6 | }; 7 | 8 | use crate::ZResult; 9 | 10 | pub fn time_s(s: f32) -> Duration { 11 | let ms = s * 1000.0; 12 | Duration::from_millis(ms as u64) 13 | } 14 | 15 | pub struct LineHeights { 16 | pub small: f32, 17 | pub normal: f32, 18 | pub big: f32, 19 | pub large: f32, 20 | } 21 | 22 | pub fn line_heights() -> LineHeights { 23 | LineHeights { 24 | small: 1.0 / 20.0, 25 | normal: 1.0 / 12.0, 26 | big: 1.0 / 9.0, 27 | large: 1.0 / 6.0, 28 | } 29 | } 30 | 31 | pub const OFFSET_SMALL: f32 = 0.02; 32 | pub const OFFSET_BIG: f32 = 0.04; 33 | 34 | pub fn add_bg(w: Box) -> ZResult { 35 | let bg = ui::ColoredRect::new(ui::SPRITE_COLOR_BG, w.rect()).stretchable(true); 36 | let mut layers = ui::LayersLayout::new(); 37 | layers.add(Box::new(bg)); 38 | layers.add(w); 39 | Ok(layers) 40 | } 41 | 42 | pub fn add_offsets(w: Box, offset: f32) -> Box { 43 | let spacer = || { 44 | ui::Spacer::new(Rect { 45 | w: offset, 46 | h: offset, 47 | ..Default::default() 48 | }) 49 | }; 50 | let mut layout_h = ui::HLayout::new().stretchable(true); 51 | layout_h.add(Box::new(spacer())); 52 | layout_h.add(w); 53 | layout_h.add(Box::new(spacer())); 54 | let mut layout_v = ui::VLayout::new().stretchable(true); 55 | layout_v.add(Box::new(spacer())); 56 | layout_v.add(Box::new(layout_h)); 57 | layout_v.add(Box::new(spacer())); 58 | Box::new(layout_v) 59 | } 60 | 61 | pub fn add_offsets_and_bg(w: Box, offset: f32) -> ZResult { 62 | add_bg(add_offsets(w, offset)) 63 | } 64 | 65 | pub fn add_offsets_and_bg_big(w: Box) -> ZResult { 66 | add_offsets_and_bg(w, OFFSET_BIG) 67 | } 68 | 69 | pub fn remove_widget(gui: &mut ui::Gui, widget: &mut Option) -> ZResult { 70 | if let Some(w) = widget.take() { 71 | gui.remove(&w); 72 | } 73 | Ok(()) 74 | } 75 | 76 | pub fn aspect_ratio() -> f32 { 77 | mq::window::screen_width() / mq::window::screen_height() 78 | } 79 | 80 | pub fn make_and_set_camera(aspect_ratio: f32) -> Camera2D { 81 | let camera = Camera2D::from_display_rect(Rect { 82 | x: -aspect_ratio, 83 | y: -1.0, 84 | w: aspect_ratio * 2.0, 85 | h: 2.0, 86 | }); 87 | set_camera(&camera); 88 | camera 89 | } 90 | 91 | pub fn get_world_mouse_pos(camera: &Camera2D) -> Vec2 { 92 | camera.screen_to_world(mq::input::mouse_position().into()) 93 | } 94 | 95 | pub fn try_receive(opt_rx: &Option>) -> Option { 96 | opt_rx.as_ref().and_then(|rx| rx.try_recv().ok()) 97 | } 98 | -------------------------------------------------------------------------------- /utils/assets_export.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Convert one `.svg` file to many `png`s. 4 | 5 | EXPORT_IDS="assets_src/export_ids" 6 | INPUT_FILE="assets_src/atlas.svg" 7 | OUT_DIR="assets/img" 8 | 9 | mkdir -p $OUT_DIR 10 | 11 | cat $EXPORT_IDS | tr -d '\r' | while read -r id 12 | do 13 | echo Exporting "$id" 14 | resvg --zoom=12 --export-id="$id" $INPUT_FILE "$OUT_DIR/$id.png" 15 | done 16 | -------------------------------------------------------------------------------- /utils/wasm/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | cargo build --target wasm32-unknown-unknown --release 6 | 7 | rm -rf static 8 | mkdir static 9 | cp -r assets static/assets 10 | cp target/wasm32-unknown-unknown/release/zemeroth.wasm static/ 11 | cp utils/wasm/index.html static/ 12 | ls -lh static 13 | -------------------------------------------------------------------------------- /utils/wasm/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zemeroth 6 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /zcomponents/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zcomponents" 3 | version = "0.2.0" 4 | authors = ["Andrey Lesnikov "] 5 | edition = "2018" 6 | license = "MIT/Apache-2.0" 7 | description = "ZComponents is a stupid component storage" 8 | repository = "https://github.com/ozkriff/zemeroth" 9 | homepage = "https://github.com/ozkriff/zemeroth/tree/master/zcomponents" 10 | documentation = "https://docs.rs/zcomponents/" 11 | readme = "README.md" 12 | keywords = ["gamedev"] 13 | -------------------------------------------------------------------------------- /zcomponents/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT/X Consortium License 2 | 3 | @ 2017-2021 Andrey Lesnikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 18 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /zcomponents/README.md: -------------------------------------------------------------------------------- 1 | # `ZComponents` - a stupid component storage 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/zcomponents.svg)](https://crates.io/crates/zcomponents) 4 | [![Docs.rs](https://docs.rs/zcomponents/badge.svg)](https://docs.rs/zcomponents) 5 | 6 | I find "serious" ECS to be an overkill for turn-based game logic, 7 | so I've created this simple library that does only one thing: 8 | stores your components. 9 | 10 | ## Basic Example 11 | 12 | ```rust 13 | use zcomponents::zcomponents_storage; 14 | 15 | #[derive(PartialEq, Eq, Clone, Copy, Debug, Hash, Default)] 16 | pub struct Id(i32); 17 | 18 | #[derive(Clone, Debug)] 19 | pub struct SomeComponent(pub i32); 20 | 21 | #[derive(Clone, Debug)] 22 | pub struct SomeFlag; 23 | 24 | zcomponents_storage!(Storage: { 25 | component: SomeComponent, 26 | flag: SomeFlag, 27 | }); 28 | 29 | let mut storage = Storage::new(); 30 | 31 | let id0 = storage.alloc_id(); 32 | storage.component.insert(id0, SomeComponent(0)); 33 | 34 | let id1 = storage.alloc_id(); 35 | storage.component.insert(id1, SomeComponent(1)); 36 | storage.flag.insert(id1, SomeFlag); 37 | 38 | storage.component.get_mut(id0).0 += 1; 39 | 40 | if let Some(component) = storage.component.get_opt_mut(id1) { 41 | component.0 += 1; 42 | } 43 | 44 | storage.flag.remove(id1); 45 | 46 | storage.remove(id0); 47 | ``` 48 | 49 | See a more advanced example [in crate's documentation][advanced_example]. 50 | 51 | [advanced_example]: https://docs.rs/zcomponents/0/zcomponents/#example 52 | 53 | ## Implementation 54 | 55 | It's implemented as a simple macro and a bunch of naive `HashMap`s 56 | so don't expect any outstanding performance. 57 | -------------------------------------------------------------------------------- /zcomponents/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # ZComponents - a stupid component storage 2 | //! 3 | //! I find "serious" ECS to be an overkill for turn-based game logic, 4 | //! so I've created this simple library that does only one thing: 5 | //! stores your components. 6 | //! 7 | //! ## Example: 8 | //! 9 | //! ```rust 10 | //! use zcomponents::zcomponents_storage; 11 | //! 12 | //! #[derive(PartialEq, Eq, Clone, Copy, Debug, Hash, Default)] 13 | //! pub struct Id(i32); 14 | //! 15 | //! #[derive(Clone, Debug)] 16 | //! pub struct A { 17 | //! value: i32, 18 | //! } 19 | //! 20 | //! #[derive(Clone, Debug)] 21 | //! pub struct B { 22 | //! value: i32, 23 | //! } 24 | //! 25 | //! #[derive(Clone, Debug)] 26 | //! pub struct C; 27 | //! 28 | //! zcomponents_storage!(Storage: { 29 | //! a: A, 30 | //! b: B, 31 | //! c: C, 32 | //! }); 33 | //! 34 | //! // Create a new storage instance. 35 | //! let mut storage = Storage::new(); 36 | //! 37 | //! // It doesn't store anything yet. 38 | //! assert_eq!(storage.ids().count(), 0); 39 | //! 40 | //! // Allocate a new id. 41 | //! let id0 = storage.alloc_id(); 42 | //! assert_eq!(storage.ids().count(), 1); 43 | //! 44 | //! // This Entity doesn't have any components assigned. 45 | //! assert!(!storage.is_exist(id0)); 46 | //! 47 | //! storage.a.insert(id0, A { value: 0 }); 48 | //! 49 | //! // Now it has a component. 50 | //! assert!(storage.is_exist(id0)); 51 | //! 52 | //! // Allocate another id. 53 | //! let id1 = storage.alloc_id(); 54 | //! assert_eq!(storage.ids().count(), 2); 55 | //! 56 | //! storage.a.insert(id1, A { value: 1 }); 57 | //! storage.b.insert(id1, B { value: 1 }); 58 | //! 59 | //! // Iterate over everything. 60 | //! for id in storage.ids_collected() { 61 | //! // We are not sure that this entity has the component, 62 | //! // so we must use `get_opt`/`get_opt_mut` methods. 63 | //! if let Some(a) = storage.a.get_opt_mut(id) { 64 | //! a.value += 1; 65 | //! } 66 | //! if let Some(b) = storage.b.get_opt_mut(id) { 67 | //! b.value += 1; 68 | //! storage.c.insert(id, C); 69 | //! } 70 | //! } 71 | //! 72 | //! // Iterate over `a` components. 73 | //! for id in storage.a.ids_collected() { 74 | //! // Since we are sure that component exists, 75 | //! // we can just use `get`/`get_mut` version: 76 | //! storage.a.get_mut(id).value += 1; 77 | //! } 78 | //! 79 | //! // Remove the component 80 | //! storage.a.remove(id0); 81 | //! 82 | //! // Remove the whole entity 83 | //! storage.remove(id0); 84 | //! 85 | //! assert!(!storage.is_exist(id0)); 86 | //! ``` 87 | 88 | use std::{ 89 | collections::{hash_map, HashMap}, 90 | default::Default, 91 | fmt::Debug, 92 | hash::Hash, 93 | }; 94 | 95 | #[derive(Debug, Clone)] 96 | pub struct ComponentContainer { 97 | data: HashMap, 98 | } 99 | 100 | impl Default for ComponentContainer { 101 | fn default() -> Self { 102 | Self::new() 103 | } 104 | } 105 | 106 | impl ComponentContainer { 107 | pub fn new() -> Self { 108 | let data = HashMap::new(); 109 | Self { data } 110 | } 111 | 112 | pub fn get_opt(&self, id: Id) -> Option<&V> { 113 | self.data.get(&id) 114 | } 115 | 116 | /// Note: panics if there's no such entity. 117 | pub fn get(&self, id: Id) -> &V { 118 | self.get_opt(id) 119 | .unwrap_or_else(|| panic!("Can't find {:?} id", id)) 120 | } 121 | 122 | pub fn get_opt_mut(&mut self, id: Id) -> Option<&mut V> { 123 | self.data.get_mut(&id) 124 | } 125 | 126 | /// Note: panics if there's no such entity. 127 | pub fn get_mut(&mut self, id: Id) -> &mut V { 128 | self.get_opt_mut(id) 129 | .unwrap_or_else(|| panic!("Can't find {:?} id", id)) 130 | } 131 | 132 | /// Store a given data value under a given entity id of a stupid component 133 | /// if no value is already stored under that entity's id. 134 | pub fn insert(&mut self, id: Id, data: V) { 135 | assert!(self.get_opt(id).is_none()); 136 | self.data.insert(id, data); 137 | } 138 | 139 | /// Note: panics if there's no such entity. 140 | pub fn remove(&mut self, id: Id) { 141 | assert!(self.get_opt(id).is_some()); 142 | self.data.remove(&id); 143 | } 144 | 145 | pub fn ids(&self) -> IdIter<'_, Id, V> { 146 | IdIter::new(&self.data) 147 | } 148 | 149 | /// Note: Allocates Vec in heap. 150 | pub fn ids_collected(&self) -> Vec { 151 | self.ids().collect() 152 | } 153 | } 154 | 155 | #[derive(Clone, Debug)] 156 | pub struct IdIter<'a, Id, V> { 157 | iter: hash_map::Iter<'a, Id, V>, 158 | } 159 | 160 | impl<'a, Id: Eq + Hash + Clone + 'a, V: 'a> IdIter<'a, Id, V> { 161 | pub fn new(map: &'a HashMap) -> Self { 162 | Self { iter: map.iter() } 163 | } 164 | } 165 | 166 | impl<'a, Id: Copy + 'a, V> Iterator for IdIter<'a, Id, V> { 167 | type Item = Id; 168 | 169 | fn next(&mut self) -> Option { 170 | if let Some((&id, _)) = self.iter.next() { 171 | Some(id) 172 | } else { 173 | None 174 | } 175 | } 176 | } 177 | 178 | #[macro_export] 179 | macro_rules! zcomponents_storage { 180 | ($struct_name:ident<$id_type:ty>: { $($component:ident: $t:ty,)* } ) => { 181 | use std::collections::HashMap; 182 | 183 | #[derive(Clone, Debug)] 184 | pub struct $struct_name { 185 | $( 186 | pub $component: $crate::ComponentContainer<$id_type, $t>, 187 | )* 188 | next_obj_id: $id_type, 189 | ids: HashMap<$id_type, ()>, 190 | } 191 | 192 | #[allow(dead_code)] 193 | impl $struct_name { 194 | pub fn new() -> Self { 195 | Self { 196 | $( 197 | $component: $crate::ComponentContainer::new(), 198 | )* 199 | next_obj_id: Default::default(), 200 | ids: HashMap::new(), 201 | } 202 | } 203 | 204 | pub fn alloc_id(&mut self) -> $id_type { 205 | let id = self.next_obj_id; 206 | self.next_obj_id.0 += 1; 207 | self.ids.insert(id, ()); 208 | id 209 | } 210 | 211 | pub fn ids(&self) -> $crate::IdIter<$id_type, ()> { 212 | $crate::IdIter::new(&self.ids) 213 | } 214 | 215 | pub fn ids_collected(&self) -> Vec<$id_type> { 216 | self.ids().collect() 217 | } 218 | 219 | pub fn is_exist(&self, id: $id_type) -> bool { 220 | $( 221 | if self.$component.get_opt(id).is_some() { 222 | return true; 223 | } 224 | )* 225 | false 226 | } 227 | 228 | pub fn remove(&mut self, id: $id_type) { 229 | $( 230 | if self.$component.get_opt(id).is_some() { 231 | self.$component.remove(id); 232 | } 233 | )* 234 | } 235 | 236 | pub fn debug_string(&self, id: $id_type) -> String { 237 | let mut s = String::new(); 238 | $( 239 | if let Some(component) = self.$component.get_opt(id) { 240 | s.push_str(&format!("{:?} ", component)); 241 | } 242 | )* 243 | s 244 | } 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /zemeroth.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /zgui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zgui" 3 | version = "0.1.0" 4 | authors = ["Andrey Lesnikov "] 5 | edition = "2018" 6 | license = "MIT/Apache-2.0" 7 | description = "Tiny and opionated GUI library" 8 | keywords = ["gamedev", "gui"] 9 | 10 | [dependencies] 11 | log = "0.4" 12 | mq = { package = "macroquad", version = "0.3" } 13 | -------------------------------------------------------------------------------- /zgui/README.md: -------------------------------------------------------------------------------- 1 | # `zgui` 2 | 3 | Tiny and opinionated UI library. 4 | 5 | Made for Zemeroth game. 6 | Historically was part of [Häte](https://docs.rs/hate) crate. 7 | 8 | Limitations: 9 | 10 | - Only provides simple labels, buttons and layouts 11 | - Handles only basic click event 12 | - No custom styles, only the basic one 13 | 14 | ## Examples 15 | 16 | From simple to complicated: 17 | 18 | - [text_button.rs](./examples/text_button.rs) 19 | - [layers_layout.rs](examples/layers_layout.rs) 20 | - [nested.rs](./examples/nested.rs) 21 | - [remove.rs](./examples/remove.rs) 22 | - [pixel_coordinates.rs](./examples/pixel_coordinates.rs) 23 | - [absolute_coordinates.rs](./examples/absolute_coordinates.rs) 24 | -------------------------------------------------------------------------------- /zgui/assets/Karla-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozkriff/zemeroth/fae7d89abe9702b25729453395e46fce105debbd/zgui/assets/Karla-Regular.ttf -------------------------------------------------------------------------------- /zgui/assets/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozkriff/zemeroth/fae7d89abe9702b25729453395e46fce105debbd/zgui/assets/fire.png -------------------------------------------------------------------------------- /zgui/examples/absolute_coordinates.rs: -------------------------------------------------------------------------------- 1 | use mq::color::{RED, WHITE}; 2 | use zgui as ui; 3 | 4 | mod common; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum Message { 8 | Command, 9 | } 10 | 11 | fn make_gui(font: mq::text::Font) -> ui::Result> { 12 | let mut gui = ui::Gui::new(); 13 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 14 | let text = ui::Drawable::text("Button", font); 15 | let button = ui::Button::new(text, 0.2, gui.sender(), Message::Command)?; 16 | gui.add(&ui::pack(button), anchor); 17 | Ok(gui) 18 | } 19 | 20 | fn draw_scene() { 21 | let x = 0.0; 22 | let y = 0.0; 23 | let r = 0.4; 24 | let color = RED; 25 | mq::shapes::draw_circle(x, y, r, color); 26 | } 27 | 28 | #[mq::main("ZGui: Absolute Coordinates Demo")] 29 | #[macroquad(crate_rename = "mq")] 30 | async fn main() { 31 | let assets = common::Assets::load().await.expect("Can't load assets"); 32 | let mut gui = make_gui(assets.font).expect("Can't create the gui"); 33 | loop { 34 | // Update the camera and the GUI. 35 | let aspect_ratio = common::aspect_ratio(); 36 | let camera = common::make_and_set_camera(aspect_ratio); 37 | gui.resize_if_needed(aspect_ratio); 38 | // Handle cursor updates. 39 | let pos = common::get_world_mouse_pos(&camera); 40 | gui.move_mouse(pos); 41 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 42 | let message = gui.click(pos); 43 | println!("{:?}", message); 44 | } 45 | // Draw the GUI. 46 | mq::window::clear_background(WHITE); 47 | draw_scene(); 48 | gui.draw(); 49 | mq::window::next_frame().await; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /zgui/examples/common/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use mq::{ 4 | camera::{set_camera, Camera2D}, 5 | math::{Rect, Vec2}, 6 | text::{load_ttf_font, Font}, 7 | texture::{self, Texture2D}, 8 | }; 9 | 10 | #[derive(Debug)] 11 | pub enum Err { 12 | File(mq::file::FileError), 13 | Font(mq::text::FontError), 14 | } 15 | 16 | impl From for Err { 17 | fn from(err: mq::file::FileError) -> Self { 18 | Err::File(err) 19 | } 20 | } 21 | 22 | impl From for Err { 23 | fn from(err: mq::text::FontError) -> Self { 24 | Err::Font(err) 25 | } 26 | } 27 | 28 | pub fn aspect_ratio() -> f32 { 29 | mq::window::screen_width() / mq::window::screen_height() 30 | } 31 | 32 | pub fn make_and_set_camera(aspect_ratio: f32) -> Camera2D { 33 | let display_rect = Rect { 34 | x: -aspect_ratio, 35 | y: -1.0, 36 | w: aspect_ratio * 2.0, 37 | h: 2.0, 38 | }; 39 | let camera = Camera2D::from_display_rect(display_rect); 40 | set_camera(&camera); 41 | camera 42 | } 43 | 44 | pub fn get_world_mouse_pos(camera: &Camera2D) -> Vec2 { 45 | camera.screen_to_world(mq::input::mouse_position().into()) 46 | } 47 | 48 | pub struct Assets { 49 | pub font: Font, 50 | pub texture: Texture2D, 51 | } 52 | 53 | impl Assets { 54 | pub async fn load() -> Result { 55 | let font = load_ttf_font("zgui/assets/Karla-Regular.ttf").await?; 56 | let texture = texture::load_texture("zgui/assets/fire.png").await?; 57 | Ok(Self { font, texture }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /zgui/examples/layers_layout.rs: -------------------------------------------------------------------------------- 1 | use mq::color::WHITE; 2 | use zgui as ui; 3 | 4 | mod common; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum Message { 8 | Command, 9 | } 10 | 11 | fn make_gui(assets: common::Assets) -> ui::Result> { 12 | let mut gui = ui::Gui::new(); 13 | let text = ui::Drawable::text(" text", assets.font); 14 | let texture = ui::Drawable::Texture(assets.texture); 15 | let button = ui::Button::new(texture, 0.2, gui.sender(), Message::Command)?; 16 | let label = ui::Label::new(text, 0.1)?; 17 | let mut layout = ui::LayersLayout::new(); 18 | layout.add(Box::new(button)); 19 | layout.add(Box::new(label)); 20 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 21 | gui.add(&ui::pack(layout), anchor); 22 | Ok(gui) 23 | } 24 | 25 | #[mq::main("ZGui: Layers Layout Demo")] 26 | #[macroquad(crate_rename = "mq")] 27 | async fn main() { 28 | let assets = common::Assets::load().await.expect("Can't load assets"); 29 | let mut gui = make_gui(assets).expect("Can't create the gui"); 30 | loop { 31 | // Update the camera and the GUI. 32 | let aspect_ratio = common::aspect_ratio(); 33 | let camera = common::make_and_set_camera(aspect_ratio); 34 | gui.resize_if_needed(aspect_ratio); 35 | // Handle cursor updates. 36 | let pos = common::get_world_mouse_pos(&camera); 37 | gui.move_mouse(pos); 38 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 39 | let message = gui.click(pos); 40 | println!("{:?}", message); 41 | } 42 | // Draw the GUI. 43 | mq::window::clear_background(WHITE); 44 | gui.draw(); 45 | mq::window::next_frame().await; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /zgui/examples/nested.rs: -------------------------------------------------------------------------------- 1 | use mq::color::WHITE; 2 | use zgui as ui; 3 | 4 | mod common; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum Message { 8 | A, 9 | B, 10 | C, 11 | Image, 12 | X, 13 | Y, 14 | Z, 15 | } 16 | 17 | fn make_gui(assets: common::Assets) -> ui::Result> { 18 | let text = |s| ui::Drawable::text(s, assets.font); 19 | let texture = || ui::Drawable::Texture(assets.texture); 20 | let mut gui = ui::Gui::new(); 21 | { 22 | let button = ui::Button::new(texture(), 0.1, gui.sender(), Message::Image)?; 23 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Top); 24 | gui.add(&ui::pack(button), anchor); 25 | } 26 | { 27 | let label = ui::Label::new_with_bg(text("label"), 0.1)?; 28 | let anchor = ui::Anchor(ui::HAnchor::Left, ui::VAnchor::Bottom); 29 | gui.add(&ui::pack(label), anchor); 30 | } 31 | let v_layout_1 = { 32 | let button_a = ui::Button::new(text("A"), 0.1, gui.sender(), Message::A)?; 33 | let button_b = ui::Button::new(text("B"), 0.1, gui.sender(), Message::B)?; 34 | let button_c = ui::Button::new(text("C"), 0.1, gui.sender(), Message::C)?; 35 | let mut layout = ui::VLayout::new(); 36 | layout.add(Box::new(button_a)); 37 | layout.add(Box::new(button_b)); 38 | layout.add(Box::new(button_c)); 39 | layout 40 | }; 41 | let v_layout_2 = { 42 | let button_i = ui::Button::new(texture(), 0.1, gui.sender(), Message::Image)?; 43 | let button_x = ui::Button::new(text("X"), 0.1, gui.sender(), Message::X)?; 44 | let button_y = ui::Button::new(text("Y"), 0.1, gui.sender(), Message::Y)?; 45 | let button_z = ui::Button::new(text("Z"), 0.1, gui.sender(), Message::Z)?; 46 | let mut layout = ui::VLayout::new(); 47 | layout.add(Box::new(button_i)); 48 | layout.add(Box::new(button_x)); 49 | layout.add(Box::new(button_y)); 50 | layout.add(Box::new(button_z)); 51 | layout 52 | }; 53 | { 54 | let button_a = ui::Button::new(text("A"), 0.1, gui.sender(), Message::A)?; 55 | let button_b = ui::Button::new(text("B"), 0.1, gui.sender(), Message::B)?; 56 | let button_i = ui::Button::new(texture(), 0.2, gui.sender(), Message::Image)?; 57 | let mut layout = ui::HLayout::new(); 58 | layout.add(Box::new(button_a)); 59 | layout.add(Box::new(button_i)); 60 | layout.add(Box::new(v_layout_1)); 61 | layout.add(Box::new(v_layout_2)); 62 | layout.add(Box::new(button_b)); 63 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 64 | gui.add(&ui::pack(layout), anchor); 65 | } 66 | Ok(gui) 67 | } 68 | 69 | #[mq::main("ZGui: Nested Layouts Demo")] 70 | #[macroquad(crate_rename = "mq")] 71 | async fn main() { 72 | let assets = common::Assets::load().await.expect("Can't load assets"); 73 | let mut gui = make_gui(assets).expect("Can't create the gui"); 74 | loop { 75 | // Update the camera and the GUI. 76 | let aspect_ratio = common::aspect_ratio(); 77 | let camera = common::make_and_set_camera(aspect_ratio); 78 | gui.resize_if_needed(aspect_ratio); 79 | // Handle cursor updates. 80 | let pos = common::get_world_mouse_pos(&camera); 81 | gui.move_mouse(pos); 82 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 83 | let message = gui.click(pos); 84 | println!("{:?}", message); 85 | } 86 | // Draw the GUI. 87 | mq::window::clear_background(WHITE); 88 | gui.draw(); 89 | mq::window::next_frame().await; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /zgui/examples/pixel_coordinates.rs: -------------------------------------------------------------------------------- 1 | use mq::{ 2 | camera::{set_camera, Camera2D}, 3 | color::{RED, WHITE}, 4 | math::Rect, 5 | }; 6 | use zgui as ui; 7 | 8 | mod common; 9 | 10 | #[derive(Clone, Copy, Debug)] 11 | enum Message { 12 | Command, 13 | } 14 | 15 | pub fn make_and_set_camera(_aspect_ratio: f32) -> Camera2D { 16 | let display_rect = Rect { 17 | x: 0.0, 18 | y: 0.0, 19 | w: mq::window::screen_width(), 20 | h: mq::window::screen_height(), 21 | }; 22 | let camera = Camera2D::from_display_rect(display_rect); 23 | set_camera(&camera); 24 | camera 25 | } 26 | 27 | fn make_gui(font: mq::text::Font) -> ui::Result> { 28 | let mut gui = ui::Gui::new(); 29 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 30 | let text = ui::Drawable::text("Button", font); 31 | let button = ui::Button::new(text, 0.2, gui.sender(), Message::Command)?; 32 | gui.add(&ui::pack(button), anchor); 33 | Ok(gui) 34 | } 35 | 36 | fn draw_scene() { 37 | let x = 150.0; 38 | let y = 150.0; 39 | let r = 100.0; 40 | let color = RED; 41 | mq::shapes::draw_circle(x, y, r, color); 42 | } 43 | 44 | #[mq::main("ZGui: Pixel Coordinates Demo")] 45 | #[macroquad(crate_rename = "mq")] 46 | async fn main() { 47 | let assets = common::Assets::load().await.expect("Can't load assets"); 48 | let mut gui = make_gui(assets.font).expect("Can't create the gui"); 49 | loop { 50 | // Update the camera and the GUI. 51 | let aspect_ratio = common::aspect_ratio(); 52 | let camera = make_and_set_camera(aspect_ratio); 53 | gui.resize_if_needed(aspect_ratio); 54 | // Handle cursor updates. 55 | let pos = common::get_world_mouse_pos(&camera); 56 | gui.move_mouse(pos); 57 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 58 | let message = gui.click(pos); 59 | println!("{:?}", message); 60 | } 61 | // Draw the GUI. 62 | mq::window::clear_background(WHITE); 63 | draw_scene(); 64 | gui.draw(); 65 | mq::window::next_frame().await; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /zgui/examples/remove.rs: -------------------------------------------------------------------------------- 1 | use mq::color::WHITE; 2 | use zgui as ui; 3 | 4 | mod common; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum Message { 8 | AddOrRemove, 9 | } 10 | 11 | fn make_gui(font: mq::text::Font) -> ui::Result> { 12 | let mut gui = ui::Gui::new(); 13 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 14 | let text = ui::Drawable::text("Button", font); 15 | let button = ui::Button::new(text, 0.2, gui.sender(), Message::AddOrRemove)?; 16 | gui.add(&ui::pack(button), anchor); 17 | Ok(gui) 18 | } 19 | 20 | fn make_label(assets: &common::Assets) -> ui::Result { 21 | let texture = ui::Drawable::Texture(assets.texture); 22 | let label = ui::Label::new(texture, 0.3)?; 23 | Ok(ui::pack(label)) 24 | } 25 | 26 | struct State { 27 | assets: common::Assets, 28 | gui: ui::Gui, 29 | label: Option, 30 | } 31 | 32 | impl State { 33 | fn new(assets: common::Assets) -> ui::Result { 34 | let gui = make_gui(assets.font)?; 35 | let label = None; 36 | Ok(Self { assets, gui, label }) 37 | } 38 | 39 | fn remove_label(&mut self) { 40 | println!("Removing..."); 41 | if let Some(ref label) = self.label.take() { 42 | self.gui.remove(label); 43 | } 44 | println!("Removed."); 45 | } 46 | 47 | fn add_label(&mut self) { 48 | println!("Adding..."); 49 | let label = make_label(&self.assets).expect("Can't make a label"); 50 | let anchor = ui::Anchor(ui::HAnchor::Left, ui::VAnchor::Top); 51 | self.gui.add(&label, anchor); 52 | self.label = Some(label); 53 | println!("Added."); 54 | } 55 | 56 | fn handle_message(&mut self, message: Option) { 57 | if let Some(Message::AddOrRemove) = message { 58 | if self.label.is_some() { 59 | self.remove_label(); 60 | } else { 61 | self.add_label(); 62 | } 63 | } 64 | } 65 | } 66 | 67 | #[mq::main("ZGui: Remove Widget Demo")] 68 | #[macroquad(crate_rename = "mq")] 69 | async fn main() { 70 | let assets = common::Assets::load().await.expect("Can't load assets"); 71 | let mut state = State::new(assets).expect("Can't create the game state"); 72 | loop { 73 | // Update the camera and the GUI. 74 | let aspect_ratio = common::aspect_ratio(); 75 | let camera = common::make_and_set_camera(aspect_ratio); 76 | state.gui.resize_if_needed(aspect_ratio); 77 | // Handle cursor updates. 78 | let pos = common::get_world_mouse_pos(&camera); 79 | state.gui.move_mouse(pos); 80 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 81 | let message = state.gui.click(pos); 82 | println!("{:?}", message); 83 | state.handle_message(message); 84 | } 85 | // Draw the GUI. 86 | mq::window::clear_background(WHITE); 87 | state.gui.draw(); 88 | mq::window::next_frame().await; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /zgui/examples/text_button.rs: -------------------------------------------------------------------------------- 1 | use mq::color::WHITE; 2 | use zgui as ui; 3 | 4 | mod common; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum Message { 8 | Command, 9 | } 10 | 11 | fn make_gui(font: mq::text::Font) -> ui::Result> { 12 | let mut gui = ui::Gui::new(); 13 | let anchor = ui::Anchor(ui::HAnchor::Right, ui::VAnchor::Bottom); 14 | let text = ui::Drawable::text("Button", font); 15 | let button = ui::Button::new(text, 0.2, gui.sender(), Message::Command)?; 16 | gui.add(&ui::pack(button), anchor); 17 | Ok(gui) 18 | } 19 | 20 | #[mq::main("ZGui: Text Button Demo")] 21 | #[macroquad(crate_rename = "mq")] 22 | async fn main() { 23 | let assets = common::Assets::load().await.expect("Can't load assets"); 24 | let mut gui = make_gui(assets.font).expect("Can't create the gui"); 25 | loop { 26 | // Update the camera and the GUI. 27 | let aspect_ratio = common::aspect_ratio(); 28 | let camera = common::make_and_set_camera(aspect_ratio); 29 | gui.resize_if_needed(aspect_ratio); 30 | // Handle cursor updates. 31 | let pos = common::get_world_mouse_pos(&camera); 32 | gui.move_mouse(pos); 33 | if mq::input::is_mouse_button_pressed(mq::input::MouseButton::Left) { 34 | let message = gui.click(pos); 35 | println!("{:?}", message); 36 | } 37 | // Draw the GUI. 38 | mq::window::clear_background(WHITE); 39 | gui.draw(); 40 | mq::window::next_frame().await; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /zscene/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zscene" 3 | version = "0.1.0" 4 | authors = ["Andrey Lesnikov "] 5 | edition = "2018" 6 | license = "MIT/Apache-2.0" 7 | description = "Scene and Actions for gwg" 8 | keywords = ["gamedev", "2D"] 9 | 10 | [dependencies] 11 | mq = { package = "macroquad", version = "0.3" } 12 | -------------------------------------------------------------------------------- /zscene/README.md: -------------------------------------------------------------------------------- 1 | # `zscene` 2 | 3 | `zscene` is a simple scene and declarative animation manager. 4 | 5 | Made for Zemeroth game. 6 | Historically was part of [Häte](https://docs.rs/hate) crate. 7 | 8 | This crate provides: 9 | 10 | - `Sprite`s that can be shared 11 | - `Scene` and `Action`s to manipulate it 12 | - Basic layers 13 | 14 | ## Examples 15 | 16 | The following code sample shows how to create sprite, 17 | add it to the scene and move it: 18 | 19 | ```rust 20 | let mut sprite = Sprite::from_path(context, "/fire.png", 0.5)?; 21 | sprite.set_pos(Vec2::new(0.0, -1.0)); 22 | let delta = Vector2::new(0.0, 1.5); 23 | let time = Duration::from_millis(2_000); 24 | let action = action::Sequence::new(vec![ 25 | action::Show::new(&self.layers.fg, &sprite).boxed(), // show the sprite 26 | action::MoveBy::new(&sprite, delta, time).boxed(), // move it 27 | ]); 28 | self.scene.add_action(action.boxed()); 29 | ``` 30 | 31 | See [examples/action.rs](./examples/action.rs) for a complete example: 32 | 33 | ```shell 34 | cargo run -p zscene --example action 35 | ``` 36 | -------------------------------------------------------------------------------- /zscene/assets/Karla-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozkriff/zemeroth/fae7d89abe9702b25729453395e46fce105debbd/zscene/assets/Karla-Regular.ttf -------------------------------------------------------------------------------- /zscene/assets/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozkriff/zemeroth/fae7d89abe9702b25729453395e46fce105debbd/zscene/assets/fire.png -------------------------------------------------------------------------------- /zscene/examples/action.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use mq::{ 4 | camera::{set_camera, Camera2D}, 5 | color::{Color, BLACK}, 6 | math::{Rect, Vec2}, 7 | text, 8 | texture::{self, Texture2D}, 9 | time, window, 10 | }; 11 | use zscene::{self, action, Action, Boxed, Layer, Scene, Sprite}; 12 | 13 | #[derive(Debug)] 14 | pub enum Err { 15 | File(mq::file::FileError), 16 | Font(mq::text::FontError), 17 | } 18 | 19 | impl From for Err { 20 | fn from(err: mq::file::FileError) -> Self { 21 | Err::File(err) 22 | } 23 | } 24 | 25 | impl From for Err { 26 | fn from(err: mq::text::FontError) -> Self { 27 | Err::Font(err) 28 | } 29 | } 30 | 31 | #[derive(Debug, Clone, Default)] 32 | pub struct Layers { 33 | pub bg: Layer, 34 | pub fg: Layer, 35 | } 36 | 37 | impl Layers { 38 | fn sorted(self) -> Vec { 39 | vec![self.bg, self.fg] 40 | } 41 | } 42 | 43 | struct Assets { 44 | font: text::Font, 45 | texture: Texture2D, 46 | } 47 | 48 | impl Assets { 49 | async fn load() -> Result { 50 | let font = text::load_ttf_font("zscene/assets/Karla-Regular.ttf").await?; 51 | let texture = texture::load_texture("zscene/assets/fire.png").await?; 52 | Ok(Self { font, texture }) 53 | } 54 | } 55 | 56 | struct State { 57 | assets: Assets, 58 | scene: Scene, 59 | layers: Layers, 60 | } 61 | 62 | impl State { 63 | fn new(assets: Assets) -> Self { 64 | let layers = Layers::default(); 65 | let scene = Scene::new(layers.clone().sorted()); 66 | update_aspect_ratio(); 67 | Self { 68 | assets, 69 | scene, 70 | layers, 71 | } 72 | } 73 | 74 | fn action_demo_move(&self) -> Box { 75 | let mut sprite = Sprite::from_texture(self.assets.texture, 0.5); 76 | sprite.set_pos(Vec2::new(0.0, -1.0)); 77 | let delta = Vec2::new(0.0, 1.5); 78 | let move_duration = Duration::from_millis(2_000); 79 | let action = action::Sequence::new(vec![ 80 | action::Show::new(&self.layers.fg, &sprite).boxed(), 81 | action::MoveBy::new(&sprite, delta, move_duration).boxed(), 82 | ]); 83 | action.boxed() 84 | } 85 | 86 | fn action_demo_show_hide(&self) -> Box { 87 | let mut sprite = { 88 | let mut sprite = Sprite::from_text(("some text", self.assets.font), 0.1); 89 | sprite.set_pos(Vec2::new(0.0, 0.0)); 90 | sprite.set_scale(2.0); // just testing set_size method 91 | let scale = sprite.scale(); 92 | assert!((scale - 2.0).abs() < 0.001); 93 | sprite 94 | }; 95 | let visible = Color::new(0.0, 1.0, 0.0, 1.0); 96 | let invisible = Color::new(0.0, 1.0, 0.0, 0.0); 97 | sprite.set_color(invisible); 98 | let t = Duration::from_millis(1_000); 99 | let action = action::Sequence::new(vec![ 100 | action::Show::new(&self.layers.bg, &sprite).boxed(), 101 | action::ChangeColorTo::new(&sprite, visible, t).boxed(), 102 | action::Sleep::new(t).boxed(), 103 | action::ChangeColorTo::new(&sprite, invisible, t).boxed(), 104 | action::Hide::new(&self.layers.bg, &sprite).boxed(), 105 | ]); 106 | action.boxed() 107 | } 108 | } 109 | 110 | fn update_aspect_ratio() { 111 | let aspect_ratio = window::screen_width() / window::screen_height(); 112 | let coordinates = Rect::new(-aspect_ratio, -1.0, aspect_ratio * 2.0, 2.0); 113 | set_camera(&Camera2D::from_display_rect(coordinates)); 114 | } 115 | 116 | #[mq::main("ZScene: Actions Demo")] 117 | #[macroquad(crate_rename = "mq")] 118 | async fn main() { 119 | let assets = Assets::load().await.expect("Can't load assets"); 120 | let mut state = State::new(assets); 121 | { 122 | // Run two demo demo actions in parallel. 123 | state.scene.add_action(state.action_demo_move()); 124 | state.scene.add_action(state.action_demo_show_hide()); 125 | } 126 | loop { 127 | window::clear_background(BLACK); 128 | update_aspect_ratio(); 129 | let dtime = time::get_frame_time(); 130 | state.scene.tick(Duration::from_secs_f32(dtime)); 131 | state.scene.draw(); 132 | window::next_frame().await; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /zscene/src/action.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, time::Duration}; 2 | 3 | pub use crate::action::{ 4 | change_color_to::ChangeColorTo, custom::Custom, empty::Empty, fork::Fork, hide::Hide, 5 | move_by::MoveBy, sequence::Sequence, set_color::SetColor, set_facing::SetFacing, 6 | set_frame::SetFrame, show::Show, sleep::Sleep, 7 | }; 8 | 9 | mod change_color_to; 10 | mod custom; 11 | mod empty; 12 | mod fork; 13 | mod hide; 14 | mod move_by; 15 | mod sequence; 16 | mod set_color; 17 | mod set_facing; 18 | mod set_frame; 19 | mod show; 20 | mod sleep; 21 | 22 | pub trait Action: Debug { 23 | fn begin(&mut self) {} 24 | fn update(&mut self, _dtime: Duration) {} 25 | fn end(&mut self) {} 26 | 27 | /// Note that it return only the main actions' duration and ignores all forks. 28 | /// Also see [Scene::any_unfinished_actions] if you need to check for alive forks. 29 | fn duration(&self) -> Duration { 30 | Duration::new(0, 0) 31 | } 32 | 33 | fn try_fork(&mut self) -> Option> { 34 | None 35 | } 36 | 37 | fn is_finished(&self) -> bool { 38 | true 39 | } 40 | } 41 | 42 | /// Just a helper trait to replace 43 | /// `Box::new(action::Empty::new())` 44 | /// with 45 | /// `action::Empty::new().boxed()`. 46 | pub trait Boxed { 47 | type Out; 48 | 49 | fn boxed(self) -> Self::Out; 50 | } 51 | 52 | impl Boxed for T { 53 | type Out = Box; 54 | 55 | fn boxed(self) -> Self::Out { 56 | Box::new(self) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /zscene/src/action/change_color_to.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use mq::color::Color; 4 | 5 | use crate::{Action, Sprite}; 6 | 7 | #[derive(Debug)] 8 | pub struct ChangeColorTo { 9 | sprite: Sprite, 10 | from: Color, 11 | to: Color, 12 | duration: Duration, 13 | progress: Duration, 14 | } 15 | 16 | impl ChangeColorTo { 17 | pub fn new(sprite: &Sprite, to: Color, duration: Duration) -> Self { 18 | Self { 19 | sprite: sprite.clone(), 20 | from: sprite.color(), 21 | to, 22 | duration, 23 | progress: Duration::new(0, 0), 24 | } 25 | } 26 | } 27 | 28 | impl Action for ChangeColorTo { 29 | fn begin(&mut self) { 30 | self.from = self.sprite.color(); 31 | } 32 | 33 | fn update(&mut self, mut dtime: Duration) { 34 | if dtime + self.progress > self.duration { 35 | dtime = self.duration - self.progress; 36 | } 37 | let progress_f = self.progress.as_secs_f32(); 38 | let duration_f = self.duration.as_secs_f32(); 39 | let k = progress_f / duration_f; 40 | self.sprite.set_color(interpolate(self.from, self.to, k)); 41 | self.progress += dtime; 42 | } 43 | 44 | fn end(&mut self) { 45 | self.sprite.set_color(self.to); 46 | } 47 | 48 | fn duration(&self) -> Duration { 49 | self.duration 50 | } 51 | 52 | fn is_finished(&self) -> bool { 53 | self.progress >= self.duration 54 | } 55 | } 56 | 57 | fn interpolate(from: Color, to: Color, k: f32) -> Color { 58 | let calc = |a, b| a + (b - a) * k; 59 | Color { 60 | r: calc(from.r, to.r), 61 | g: calc(from.g, to.g), 62 | b: calc(from.b, to.b), 63 | a: calc(from.a, to.a), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /zscene/src/action/custom.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::Action; 4 | 5 | pub struct Custom { 6 | f: Box, 7 | } 8 | 9 | impl fmt::Debug for Custom { 10 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 11 | f.debug_struct("Custom").field("f", &"").finish() 12 | } 13 | } 14 | 15 | impl Custom { 16 | pub fn new(f: Box) -> Self { 17 | Self { f } 18 | } 19 | } 20 | 21 | impl Action for Custom { 22 | fn begin(&mut self) { 23 | (self.f)(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /zscene/src/action/empty.rs: -------------------------------------------------------------------------------- 1 | use crate::Action; 2 | 3 | #[derive(Debug, Default)] 4 | pub struct Empty; 5 | 6 | impl Empty { 7 | pub fn new() -> Self { 8 | Empty 9 | } 10 | } 11 | 12 | impl Action for Empty {} 13 | -------------------------------------------------------------------------------- /zscene/src/action/fork.rs: -------------------------------------------------------------------------------- 1 | use crate::Action; 2 | 3 | #[derive(Debug)] 4 | pub struct Fork { 5 | action: Option>, 6 | } 7 | 8 | impl Fork { 9 | pub fn new(action: Box) -> Self { 10 | Self { 11 | action: Some(action), 12 | } 13 | } 14 | } 15 | 16 | impl Action for Fork { 17 | fn end(&mut self) { 18 | assert!(self.action.is_none()); 19 | } 20 | 21 | fn try_fork(&mut self) -> Option> { 22 | self.action.take() 23 | } 24 | 25 | fn is_finished(&self) -> bool { 26 | self.action.is_none() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /zscene/src/action/hide.rs: -------------------------------------------------------------------------------- 1 | use crate::{Action, Layer, Sprite}; 2 | 3 | #[derive(Debug)] 4 | pub struct Hide { 5 | layer: Layer, 6 | sprite: Sprite, 7 | } 8 | 9 | impl Hide { 10 | pub fn new(layer: &Layer, sprite: &Sprite) -> Self { 11 | Self { 12 | layer: layer.clone(), 13 | sprite: sprite.clone(), 14 | } 15 | } 16 | } 17 | 18 | impl Action for Hide { 19 | fn begin(&mut self) { 20 | assert!(self.layer.has_sprite(&self.sprite)); // TODO: add unit test for this 21 | self.layer.remove(&self.sprite); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /zscene/src/action/move_by.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use mq::math::Vec2; 4 | 5 | use crate::{Action, Sprite}; 6 | 7 | #[derive(Debug)] 8 | pub struct MoveBy { 9 | sprite: Sprite, 10 | duration: Duration, 11 | delta: Vec2, 12 | progress: Duration, 13 | } 14 | 15 | impl MoveBy { 16 | pub fn new(sprite: &Sprite, delta: Vec2, duration: Duration) -> Self { 17 | Self { 18 | sprite: sprite.clone(), 19 | delta, 20 | duration, 21 | progress: Duration::new(0, 0), 22 | } 23 | } 24 | } 25 | 26 | impl Action for MoveBy { 27 | fn update(&mut self, mut dtime: Duration) { 28 | let old_pos = self.sprite.pos(); 29 | if dtime + self.progress > self.duration { 30 | dtime = self.duration - self.progress; 31 | } 32 | let dtime_f = dtime.as_secs_f32(); 33 | let duration_f = self.duration.as_secs_f32(); 34 | let new_pos = old_pos + self.delta * (dtime_f / duration_f); 35 | self.sprite.set_pos(new_pos); 36 | self.progress += dtime; 37 | } 38 | 39 | fn duration(&self) -> Duration { 40 | self.duration 41 | } 42 | 43 | fn is_finished(&self) -> bool { 44 | self.progress >= self.duration 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /zscene/src/action/sequence.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, time::Duration}; 2 | 3 | use crate::Action; 4 | 5 | #[derive(Debug)] 6 | pub struct Sequence { 7 | actions: VecDeque>, 8 | duration: Duration, 9 | } 10 | 11 | impl Sequence { 12 | pub fn new(actions: Vec>) -> Self { 13 | let mut total_time = Duration::new(0, 0); 14 | for action in &actions { 15 | total_time += action.duration(); 16 | } 17 | Self { 18 | actions: actions.into(), 19 | duration: total_time, 20 | } 21 | } 22 | 23 | /// Current action 24 | fn action(&mut self) -> &mut dyn Action { 25 | self.actions.front_mut().unwrap().as_mut() 26 | } 27 | 28 | fn end_current_action_and_start_next(&mut self) { 29 | assert!(!self.actions.is_empty()); 30 | assert!(self.action().is_finished()); 31 | self.action().end(); 32 | self.actions.pop_front().unwrap(); 33 | if !self.actions.is_empty() { 34 | self.action().begin(); 35 | } 36 | } 37 | } 38 | 39 | impl Action for Sequence { 40 | fn begin(&mut self) { 41 | if !self.actions.is_empty() { 42 | self.action().begin(); 43 | } 44 | } 45 | 46 | fn update(&mut self, dtime: Duration) { 47 | if self.actions.is_empty() { 48 | return; 49 | } 50 | self.action().update(dtime); 51 | // Skipping instant actions 52 | while !self.actions.is_empty() && self.action().is_finished() { 53 | self.end_current_action_and_start_next(); 54 | } 55 | } 56 | 57 | fn end(&mut self) { 58 | assert!(self.actions.is_empty()); 59 | } 60 | 61 | fn duration(&self) -> Duration { 62 | self.duration 63 | } 64 | 65 | fn try_fork(&mut self) -> Option> { 66 | if self.actions.is_empty() { 67 | return None; 68 | } 69 | let forked_action = self.action().try_fork(); 70 | if forked_action.is_some() && self.action().is_finished() { 71 | self.end_current_action_and_start_next(); 72 | } 73 | forked_action 74 | } 75 | 76 | fn is_finished(&self) -> bool { 77 | self.actions.is_empty() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /zscene/src/action/set_color.rs: -------------------------------------------------------------------------------- 1 | use mq::color::Color; 2 | 3 | use crate::{Action, Sprite}; 4 | 5 | #[derive(Debug)] 6 | pub struct SetColor { 7 | sprite: Sprite, 8 | to: Color, 9 | } 10 | 11 | impl SetColor { 12 | pub fn new(sprite: &Sprite, to: Color) -> Self { 13 | Self { 14 | sprite: sprite.clone(), 15 | to, 16 | } 17 | } 18 | } 19 | 20 | impl Action for SetColor { 21 | fn begin(&mut self) { 22 | self.sprite.set_color(self.to); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /zscene/src/action/set_facing.rs: -------------------------------------------------------------------------------- 1 | use crate::{Action, Facing, Sprite}; 2 | 3 | #[derive(Debug)] 4 | pub struct SetFacing { 5 | sprite: Sprite, 6 | facing: Facing, 7 | } 8 | 9 | impl SetFacing { 10 | pub fn new(sprite: &Sprite, facing: Facing) -> Self { 11 | let sprite = sprite.clone(); 12 | Self { sprite, facing } 13 | } 14 | } 15 | 16 | impl Action for SetFacing { 17 | fn begin(&mut self) { 18 | self.sprite.set_facing(self.facing); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /zscene/src/action/set_frame.rs: -------------------------------------------------------------------------------- 1 | use crate::{Action, Sprite}; 2 | 3 | #[derive(Debug)] 4 | pub struct SetFrame { 5 | sprite: Sprite, 6 | frame_name: String, 7 | } 8 | 9 | impl SetFrame { 10 | pub fn new(sprite: &Sprite, frame_name: impl Into) -> Self { 11 | let frame_name = frame_name.into(); 12 | assert!(sprite.has_frame(&frame_name)); 13 | let sprite = sprite.clone(); 14 | Self { sprite, frame_name } 15 | } 16 | } 17 | 18 | impl Action for SetFrame { 19 | fn begin(&mut self) { 20 | self.sprite.set_frame(&self.frame_name); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /zscene/src/action/show.rs: -------------------------------------------------------------------------------- 1 | use crate::{Action, Layer, Sprite}; 2 | 3 | #[derive(Debug)] 4 | pub struct Show { 5 | layer: Layer, 6 | sprite: Sprite, 7 | } 8 | 9 | impl Show { 10 | pub fn new(layer: &Layer, sprite: &Sprite) -> Self { 11 | Self { 12 | layer: layer.clone(), 13 | sprite: sprite.clone(), 14 | } 15 | } 16 | } 17 | 18 | impl Action for Show { 19 | fn begin(&mut self) { 20 | assert!(!self.layer.has_sprite(&self.sprite)); // TODO: add unit test for this 21 | self.layer.add(&self.sprite); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /zscene/src/action/sleep.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crate::Action; 4 | 5 | #[derive(Debug)] 6 | pub struct Sleep { 7 | duration: Duration, 8 | time: Duration, 9 | } 10 | 11 | impl Sleep { 12 | pub fn new(duration: Duration) -> Self { 13 | Self { 14 | duration, 15 | time: Duration::new(0, 0), 16 | } 17 | } 18 | } 19 | 20 | impl Action for Sleep { 21 | fn update(&mut self, dtime: Duration) { 22 | self.time += dtime; 23 | } 24 | 25 | fn duration(&self) -> Duration { 26 | self.duration 27 | } 28 | 29 | fn is_finished(&self) -> bool { 30 | self.duration < self.time 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /zscene/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, fmt, rc::Rc, time::Duration}; 2 | 3 | pub use crate::{ 4 | action::{Action, Boxed}, 5 | sprite::{Facing, Sprite}, 6 | }; 7 | 8 | pub mod action; 9 | 10 | mod sprite; 11 | 12 | pub type Result = std::result::Result; 13 | 14 | #[derive(Debug)] 15 | pub enum Error { 16 | NoDimensions, 17 | } 18 | 19 | impl fmt::Display for Error { 20 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 21 | match self { 22 | Error::NoDimensions => write!(f, "The drawable has no dimensions"), 23 | } 24 | } 25 | } 26 | 27 | impl std::error::Error for Error { 28 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 29 | match self { 30 | Error::NoDimensions => None, 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug)] 36 | struct SpriteWithZ { 37 | sprite: Sprite, 38 | z: f32, 39 | } 40 | 41 | #[derive(Debug)] 42 | struct LayerData { 43 | sprites: Vec, 44 | } 45 | 46 | #[derive(Debug, Clone)] 47 | pub struct Layer { 48 | data: Rc>, 49 | } 50 | 51 | impl Layer { 52 | pub fn new() -> Self { 53 | let data = LayerData { 54 | sprites: Vec::new(), 55 | }; 56 | Self { 57 | data: Rc::new(RefCell::new(data)), 58 | } 59 | } 60 | 61 | pub fn add(&mut self, sprite: &Sprite) { 62 | let sprite = SpriteWithZ { 63 | sprite: sprite.clone(), 64 | z: 0.0, 65 | }; 66 | self.data.borrow_mut().sprites.push(sprite); 67 | self.sort(); 68 | } 69 | 70 | pub fn set_z(&mut self, sprite: &Sprite, z: f32) { 71 | { 72 | let sprites = &mut self.data.borrow_mut().sprites; 73 | let sprite = sprites 74 | .iter_mut() 75 | .find(|other| other.sprite.is_same(sprite)) 76 | .expect("can't find the sprite"); 77 | sprite.z = z; 78 | } 79 | self.sort(); 80 | } 81 | 82 | fn sort(&mut self) { 83 | let sprites = &mut self.data.borrow_mut().sprites; 84 | sprites.sort_by(|a, b| a.z.partial_cmp(&b.z).expect("can't find the sprite")); 85 | } 86 | 87 | pub fn remove(&mut self, sprite: &Sprite) { 88 | let mut data = self.data.borrow_mut(); 89 | data.sprites.retain(|other| !sprite.is_same(&other.sprite)) 90 | } 91 | 92 | pub fn has_sprite(&self, sprite: &Sprite) -> bool { 93 | let sprites = &self.data.borrow_mut().sprites; 94 | sprites.iter().any(|other| other.sprite.is_same(sprite)) 95 | } 96 | } 97 | 98 | impl Default for Layer { 99 | fn default() -> Self { 100 | Self::new() 101 | } 102 | } 103 | 104 | #[derive(Debug)] 105 | pub struct Scene { 106 | layers: Vec, 107 | interpreter: ActionInterpreter, 108 | } 109 | 110 | impl Scene { 111 | pub fn new(layers: Vec) -> Self { 112 | Self { 113 | layers, 114 | interpreter: ActionInterpreter::new(), 115 | } 116 | } 117 | 118 | pub fn draw(&self) { 119 | for layer in &self.layers { 120 | for z_sprite in &layer.data.borrow().sprites { 121 | z_sprite.sprite.draw(); 122 | } 123 | } 124 | } 125 | 126 | pub fn add_action(&mut self, action: Box) { 127 | self.interpreter.add(action); 128 | } 129 | 130 | pub fn tick(&mut self, dtime: Duration) { 131 | self.interpreter.tick(dtime); 132 | } 133 | 134 | pub fn any_unfinished_actions(&self) -> bool { 135 | !self.interpreter.actions.is_empty() 136 | } 137 | } 138 | 139 | #[derive(Debug)] 140 | struct ActionInterpreter { 141 | actions: Vec>, 142 | } 143 | 144 | impl ActionInterpreter { 145 | pub fn new() -> Self { 146 | Self { 147 | actions: Vec::new(), 148 | } 149 | } 150 | 151 | pub fn add(&mut self, mut action: Box) { 152 | action.begin(); 153 | self.actions.push(action); 154 | } 155 | 156 | pub fn tick(&mut self, dtime: Duration) { 157 | let mut forked_actions = Vec::new(); 158 | for action in &mut self.actions { 159 | action.update(dtime); 160 | while let Some(forked_action) = action.try_fork() { 161 | forked_actions.push(forked_action); 162 | } 163 | if action.is_finished() { 164 | action.end(); 165 | } 166 | } 167 | for action in forked_actions { 168 | self.add(action); 169 | } 170 | self.actions.retain(|action| !action.is_finished()); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /zscene/src/sprite.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, collections::HashMap, rc::Rc}; 2 | 3 | use mq::{ 4 | color::Color, 5 | math::{Rect, Vec2}, 6 | text::{self, Font}, 7 | texture::{self, DrawTextureParams, Texture2D}, 8 | }; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq)] 11 | pub enum Facing { 12 | Left, 13 | Right, 14 | } 15 | 16 | #[derive(Clone, Debug)] 17 | enum Drawable { 18 | Texture(Texture2D), 19 | Text { 20 | label: String, 21 | font: Font, 22 | font_size: u16, 23 | }, 24 | } 25 | 26 | impl Drawable { 27 | fn dimensions(&self) -> Rect { 28 | match *self { 29 | Drawable::Texture(texture) => Rect::new(0.0, 0.0, texture.width(), texture.height()), 30 | Drawable::Text { 31 | ref label, 32 | font, 33 | font_size, 34 | } => { 35 | let dimensions = text::measure_text(label, Some(font), font_size, 1.0); 36 | // TODO: A hack to have a fixed height for text. 37 | // TODO: Keep this in sync with the same hack in zscene until fixed. 38 | let w = dimensions.width; 39 | let h = font_size as f32 * 1.4; 40 | Rect::new(-w / 1.0, (h / 1.0) * 0.5, w / 1.0, h / 1.0) 41 | } 42 | } 43 | } 44 | } 45 | 46 | #[derive(Debug)] 47 | struct SpriteData { 48 | drawable: Option, 49 | drawables: HashMap>, 50 | current_frame_name: String, 51 | dimensions: Rect, 52 | basic_scale: f32, 53 | pos: Vec2, 54 | scale: Vec2, 55 | color: Color, 56 | offset: Vec2, 57 | facing: Facing, 58 | } 59 | 60 | #[derive(Debug, Clone)] 61 | pub struct Sprite { 62 | data: Rc>, 63 | } 64 | 65 | impl Sprite { 66 | pub fn deep_clone(&self) -> Self { 67 | let data = self.data.borrow(); 68 | let cloned_data = SpriteData { 69 | drawable: data.drawable.clone(), 70 | drawables: data.drawables.clone(), 71 | current_frame_name: data.current_frame_name.clone(), 72 | dimensions: data.dimensions, 73 | basic_scale: data.basic_scale, 74 | pos: data.pos, 75 | scale: data.scale, 76 | color: data.color, 77 | offset: data.offset, 78 | facing: data.facing, 79 | }; 80 | Sprite { 81 | data: Rc::new(RefCell::new(cloned_data)), 82 | } 83 | } 84 | 85 | fn from_drawable(drawable: Drawable, height: f32) -> Self { 86 | let dimensions = drawable.dimensions(); 87 | let scale = height / dimensions.h; 88 | let mut drawables = HashMap::new(); 89 | drawables.insert("".into(), None); 90 | let data = SpriteData { 91 | drawable: Some(drawable), 92 | drawables, 93 | current_frame_name: "".into(), 94 | dimensions, 95 | basic_scale: scale, 96 | scale: Vec2::new(scale, scale), 97 | offset: Vec2::new(0.0, 0.0), 98 | color: Color::new(1.0, 1.0, 1.0, 1.0), 99 | pos: Vec2::new(0.0, 0.0), 100 | facing: Facing::Right, 101 | }; 102 | let data = Rc::new(RefCell::new(data)); 103 | Self { data } 104 | } 105 | 106 | pub fn from_texture(texture: Texture2D, height: f32) -> Self { 107 | Self::from_drawable(Drawable::Texture(texture), height) 108 | } 109 | 110 | pub fn from_text((label, font): (&str, Font), height: f32) -> Self { 111 | // TODO: it'd be cool to move this to the drawing method (same as in zgui) 112 | let (font_size, _, _) = mq::text::camera_font_scale(height); 113 | Self::from_drawable( 114 | Drawable::Text { 115 | label: label.to_string(), 116 | font, 117 | font_size, 118 | }, 119 | height, 120 | ) 121 | } 122 | 123 | fn add_frame(&mut self, frame_name: String, drawable: Drawable) { 124 | let mut data = self.data.borrow_mut(); 125 | data.drawables.insert(frame_name, Some(drawable)); 126 | } 127 | 128 | pub fn from_textures(frames: &HashMap, height: f32) -> Self { 129 | let tex = *frames.get("").expect("missing default path"); 130 | let mut this = Self::from_texture(tex, height); 131 | for (frame_name, &tex) in frames.iter() { 132 | this.add_frame(frame_name.clone(), Drawable::Texture(tex)); 133 | } 134 | this 135 | } 136 | 137 | pub fn has_frame(&self, frame_name: &str) -> bool { 138 | let data = self.data.borrow(); 139 | data.drawables.contains_key(frame_name) 140 | } 141 | 142 | // TODO: Add a usage example 143 | pub fn set_frame(&mut self, frame_name: &str) { 144 | assert!(self.has_frame(frame_name)); 145 | let mut data = self.data.borrow_mut(); 146 | let previous_frame_name = data.current_frame_name.clone(); 147 | let previous_drawable = data.drawable.take().expect("no active drawable"); 148 | let previous_slot = data 149 | .drawables 150 | .get_mut(&previous_frame_name) 151 | .expect("bad frame name"); 152 | *previous_slot = Some(previous_drawable); 153 | data.drawable = data 154 | .drawables 155 | .get_mut(frame_name) 156 | .expect("bad frame name") 157 | .take(); 158 | assert!(data.drawable.is_some()); 159 | data.current_frame_name = frame_name.into(); 160 | } 161 | 162 | pub fn set_facing(&mut self, facing: Facing) { 163 | if facing == self.data.borrow().facing { 164 | return; 165 | } 166 | let offset; 167 | { 168 | let mut data = self.data.borrow_mut(); 169 | data.facing = facing; 170 | data.scale.x *= -1.0; 171 | let mut dimensions = data.dimensions; 172 | dimensions.scale(data.scale.x, data.scale.y); 173 | let off_x = -data.offset.x / dimensions.w; 174 | let off_y = -data.offset.y / dimensions.h; 175 | offset = Vec2::new(-off_x, off_y); 176 | } 177 | self.set_offset(offset); 178 | } 179 | 180 | pub fn set_centered(&mut self, is_centered: bool) { 181 | let offset = if is_centered { 182 | Vec2::new(0.5, 0.5) 183 | } else { 184 | Vec2::new(0.0, 0.0) 185 | }; 186 | self.set_offset(offset); 187 | } 188 | 189 | /// [0.0 .. 1.0] 190 | pub fn set_offset(&mut self, offset: Vec2) { 191 | let mut data = self.data.borrow_mut(); 192 | let old_offset = data.offset; 193 | let off_x = -data.dimensions.w * data.scale.x * offset.x; 194 | let off_y = -data.dimensions.h * data.scale.y * offset.y; 195 | data.offset = Vec2::new(off_x, off_y); 196 | data.pos = data.pos + data.offset - old_offset; 197 | } 198 | 199 | pub fn draw(&self) { 200 | let data = self.data.borrow(); 201 | let drawable = data.drawable.as_ref().expect("no active drawable"); 202 | match drawable { 203 | Drawable::Texture(texture) => { 204 | texture::draw_texture_ex( 205 | *texture, 206 | data.pos.x, 207 | data.pos.y, 208 | data.color, 209 | DrawTextureParams { 210 | dest_size: Some(data.scale * Vec2::new(texture.width(), texture.height())), 211 | ..Default::default() 212 | }, 213 | ); 214 | } 215 | Drawable::Text { 216 | label, 217 | font, 218 | font_size, 219 | } => { 220 | text::draw_text_ex( 221 | label, 222 | data.pos.x, 223 | data.pos.y + (data.dimensions.y + data.dimensions.h) * data.scale.y * 0.5, 224 | text::TextParams { 225 | font_size: *font_size, 226 | font: *font, 227 | font_scale: data.scale.x, 228 | color: data.color, 229 | ..Default::default() 230 | }, 231 | ); 232 | } 233 | } 234 | } 235 | 236 | pub fn pos(&self) -> Vec2 { 237 | let data = self.data.borrow(); 238 | data.pos - data.offset 239 | } 240 | 241 | pub fn rect(&self) -> Rect { 242 | // TODO: `self.dimensions` + `graphics::transform_rect(param)` ? 243 | let pos = self.pos(); 244 | let data = self.data.borrow(); 245 | let r = data.dimensions; 246 | // TODO: angle? 247 | Rect { 248 | x: pos.x, 249 | y: pos.y, 250 | w: r.w * data.scale.x, 251 | h: r.h * data.scale.y, 252 | } 253 | } 254 | 255 | pub fn color(&self) -> Color { 256 | self.data.borrow().color 257 | } 258 | 259 | pub fn scale(&self) -> f32 { 260 | let data = self.data.borrow(); 261 | data.scale.x / data.basic_scale 262 | } 263 | 264 | pub fn set_pos(&mut self, pos: Vec2) { 265 | let mut data = self.data.borrow_mut(); 266 | data.pos = pos + data.offset; 267 | } 268 | 269 | pub fn set_color(&mut self, color: Color) { 270 | self.data.borrow_mut().color = color; 271 | } 272 | 273 | pub fn set_scale(&mut self, scale: f32) { 274 | let mut data = self.data.borrow_mut(); 275 | let s = data.basic_scale * scale; 276 | data.scale = Vec2::new(s, s); 277 | } 278 | 279 | // TODO: unittest this? 280 | pub fn is_same(&self, other: &Self) -> bool { 281 | Rc::ptr_eq(&self.data, &other.data) 282 | } 283 | } 284 | --------------------------------------------------------------------------------