├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── guides ├── Installation.md ├── common_caveats.md ├── ecs_design.md ├── tutorial │ ├── backend_basics.md │ ├── initial_setup.md │ └── web_frontend_liveview.md └── upgrade_guide.md ├── lib ├── ecsx.ex ├── ecsx │ ├── base.ex │ ├── client_events.ex │ ├── component.ex │ ├── exceptions.ex │ ├── manager.ex │ ├── persistence.ex │ ├── persistence │ │ ├── behaviour.ex │ │ ├── file_adapter.ex │ │ └── server.ex │ ├── system.ex │ └── tag.ex └── mix │ └── tasks │ ├── ecsx.gen.component.ex │ ├── ecsx.gen.system.ex │ ├── ecsx.gen.tag.ex │ ├── ecsx.setup.ex │ └── ecsx │ └── helpers.ex ├── mix.exs ├── mix.lock ├── priv └── templates │ ├── component.ex │ ├── manager.ex │ ├── system.ex │ └── tag.ex └── test ├── ecsx ├── base_test.exs ├── client_events_test.exs ├── component_test.exs ├── ecsx_test.exs ├── manager_test.exs ├── persistence_test.exs └── system_test.exs ├── mix └── tasks │ ├── ecsx.gen.component_test.exs │ ├── ecsx.gen.system_test.exs │ ├── ecsx.gen.tag_test.exs │ └── ecsx.setup_test.exs ├── support ├── integer_component.ex ├── mix_helper.exs ├── mock_persistence_adapter.ex ├── mocks.ex └── string_component.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | name: Build and test 16 | runs-on: ubuntu-20.04 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | elixir-version: '1.14.1' # Define the elixir version [required] 24 | otp-version: '25.1.2' # Define the OTP version [required] 25 | - name: Restore dependencies cache 26 | uses: actions/cache@v3 27 | with: 28 | path: deps 29 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 30 | restore-keys: ${{ runner.os }}-mix- 31 | - name: Install dependencies 32 | run: mix deps.get 33 | - name: Run tests 34 | run: mix test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ecsx-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | .DS_Store 29 | 30 | .elixir_ls 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.5.2 (2025-01-25) 4 | 5 | * Fixed an issue where Components with `index: true` were not fully removed with `Component.remove/1` 6 | 7 | ## v0.5.1 (2024-01-24) 8 | 9 | * Fixed an issue where some Component or System naming patterns could prevent generators from working properly 10 | * Replace deprecated function which was causing compiler warnings (h/t @hl) 11 | 12 | ## v0.5 (2023-09-23) 13 | 14 | * Non-unique Component types are no longer allowed (see the [upgrade guide](upgrade_guide.html)) 15 | * Component modules now accept option `:index` to index components for better `search/2` performance 16 | * `mix ecsx.gen.component` now accepts option `--index` to automatically set `index: true` 17 | * Component callback `get_one/2` has been renamed `get/2` 18 | * Systems' `run/0` no longer requires `:ok` return value 19 | * Manager `setup/0` and `startup/0` no longer require `:ok` return value 20 | 21 | ## v0.4 (2023-06-02) 22 | 23 | * Adding ECSx.ClientEvents to your supervision tree is no longer required 24 | * Adding the manager to your supervision tree is no longer required 25 | * Running a generator before ecsx.setup will now raise an error 26 | * Added telemetry events 27 | * Added component persistence, by default saving a binary file to disk 28 | * Persistence file is loaded on app startup 29 | * The interval between saves can be set via application config 30 | * Tick rate is now set in application config 31 | * Manager module (and optional custom path) are now defined in application config 32 | * Added functions `tick_rate/0`, `manager/0`, `persist_interval/0`, and `manager_path/0` to the `ECSx` module for reading the configured values at runtime 33 | * Added callback `add/3` for components and tags, which accepts `persist: true` option, marking the component/tag for persistence across app reboots 34 | * `get_one/1` now raises an error if no results are found 35 | * Added `Component` callback `get_one/2` which accepts a default value to return if no results are found 36 | * `add/{2,3}` now raises if `unique: true` and the component already exists 37 | * Added `Component` callback `update/2` for updating an existing component's value, while maintaining the previously set `:persist` option 38 | * Manager `setup` macro is now an optional callback `setup/0` which only runs once, at the server's first startup 39 | * Added a new Manager callback `startup/0` which runs every time the server starts 40 | * Added `Component` callbacks `between/2`, `at_least/1`, and `at_most/1` (only available for integer and float component types) 41 | 42 | ## v0.3.1 (2023-01-12) 43 | 44 | * Added ECSx.ClientEvents: ephemeral components created by client processes to communicate user input/interaction with the ECSx backend 45 | * ECSx.QueryError renamed to ECSx.MultipleResultsError 46 | 47 | ## v0.3.0 (2023-01-03) 48 | 49 | * Components are now stored as key-value pairs 50 | * Component values now require a type declaration which is checked on insertions 51 | * Simplified API for working with components 52 | * Aspects have been renamed to Component Types 53 | * Added Tags: boolean component types which don't store any value 54 | * Component `table_type` now toggled via `:unique` flag 55 | 56 | ## v0.2.0 (2022-08-26) 57 | 58 | * New Query API for fetching Components 59 | * Improved generators to better handle code injection 60 | * Generators now raise helpful error messages when missing arguments 61 | 62 | ## v0.1.1 (2022-07-21) 63 | 64 | * Setup task `mix ecsx.setup` no longer generates sample modules 65 | * Added option `mix ecsx.setup --no-folders` to prevent generating folders during setup 66 | * Added guides and other documentation 67 | 68 | ## v0.1.0 (2022-07-15) 69 | 70 | Initial release 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ECSx 2 | 3 | [![Hex Version](https://img.shields.io/hexpm/v/ecsx.svg)](https://hex.pm/packages/ecsx) 4 | [![License](https://img.shields.io/hexpm/l/ecsx.svg)](https://github.com/ecsx-framework/ECSx/blob/master/LICENSE) 5 | [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/ecsx) 6 | 7 | ECSx is an Entity-Component-System (ECS) framework for Elixir. ECS is an architecture for building real-time games and simulations, wherein data about Entities is stored in small fragments called Components, which are then read and updated by Systems. 8 | 9 | ## Setup 10 | 11 | - Add `:ecsx` to the list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:ecsx, "~> 0.5"} 17 | ] 18 | end 19 | ``` 20 | 21 | - Run `mix deps.get` 22 | - Run `mix ecsx.setup` 23 | 24 | ## Upgrading 25 | 26 | While ECSx is pre-v1.0, minor version updates will contain breaking changes. If you are upgrading an application from ECSx 0.4.x or earlier, please refer to our [upgrade guide](https://hexdocs.pm/ecsx/upgrade_guide.html). 27 | 28 | ## Tutorial Project 29 | 30 | [Building a ship combat engine with ECSx in a Phoenix app](https://hexdocs.pm/ecsx/initial_setup.html) 31 | Note: This tutorial project is a work-in-progress 32 | 33 | ## Usage 34 | 35 | ### Entities and Components 36 | 37 | Everything in your application is an Entity, but in ECS you won't work with these Entities directly - instead you will work with the individual attributes that an Entity might have. These attributes are given to an Entity by creating a Component, which holds, at minimum, the Entity's unique ID, but also can store a value. For example: 38 | 39 | - You're running a 2-dimensional simulation of cars on a highway 40 | - Each car gets its own `entity_id` e.g. `123` 41 | - If the car with ID `123` is blue, we give it a `Color` Component with value `"blue"` 42 | - If the same car is moving west at 60mph, we might model this with a `Direction` Component with value `"west"` and a `Speed` Component with value `60` 43 | - The car would also have Components such as `XCoordinate` and `YCoordinate` to locate it on the map 44 | 45 | ### Systems 46 | 47 | Once your Entities are modeled using Components, you'll create Systems to operate on them. For example: 48 | 49 | - Entities with `Speed` Components should have their locations regularly updated according to the speed and direction 50 | - We can create a `Move` System which reads the `Speed` and `Direction` Components, calculates how far the car has moved since the last server tick, and updates the Entity's `XCoordinate` and/or `YCoordinate` Component accordingly. 51 | - The System will run every tick, only considering Entities which have a `Speed` Component 52 | 53 | ### Generators 54 | 55 | ECSx comes with generators to quickly create new Components or Systems: 56 | 57 | - `mix ecsx.gen.component` 58 | - `mix ecsx.gen.system` 59 | 60 | ### Manager 61 | 62 | Every ECSx application requires a Manager module, where valid Component types and Systems are declared, as well as the setup to spawn world objects before any players join. This module is created for you during `mix ecsx.setup` and will be automatically updated by the other generators. 63 | 64 | It is especially important to consider the order of your Systems list. The manager will run each System one at a time, in order. 65 | 66 | ### Persistence 67 | 68 | Components are not persisted by default. To persist an entity, use the `persist: true` option when adding the component. The default adapter for persistence is the [ECSx.Persistence.FileAdapter](lib/ecsx/persistence/file_adapter.ex), which stores the components in a binary file. The adapter can be changed using the `persistence_adapter` application variable: 69 | 70 | ```elixir 71 | config :ecsx, 72 | ... 73 | persistence_adapter: ... 74 | ``` 75 | 76 | Currently available Persistence Adapters (see links for installation/configuration instructions): 77 | 78 | - [ECSx.Persistence.Ecto](https://github.com/ecsx-framework/ecsx_persistence_ecto) 79 | 80 | ## License 81 | 82 | Copyright (C) 2022 Andrew P Berrien 83 | 84 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. 85 | 86 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) for more details. 87 | -------------------------------------------------------------------------------- /guides/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | To create an ECSx application, there are a few simple steps: 4 | 5 | * install Elixir + erlang/OTP 6 | * install Phoenix (optional) 7 | * create an Elixir/Phoenix project 8 | * fetch ECSx as a dependency for your project 9 | * run the ECSx setup 10 | 11 | ## Elixir and erlang/OTP 12 | 13 | If you don't yet have Elixir and erlang/OTP installed on your machine, follow the instructions on the official [Installation Page](https://elixir-lang.org/install.html). 14 | 15 | ## Phoenix 16 | 17 | If you plan on hosting your application online, you'll probably want to use Phoenix. You can skip this step if you only want to run the app locally. Otherwise, follow the instructions for Phoenix [installation](https://hexdocs.pm/phoenix/installation.html) (the tutorial project will assume you are using Phoenix). 18 | 19 | ## Create project 20 | 21 | If you are using Phoenix, you'll create your new application with the command 22 | 23 | ```console 24 | $ mix phx.new my_app 25 | ``` 26 | 27 | Or for a regular Elixir application (with supervision tree): 28 | 29 | ```console 30 | $ mix new my_app --sup 31 | ``` 32 | 33 | ## Install ECSx 34 | 35 | To use the ECSx framework in your application, it should be added to the list of dependencies in `my_app/mix.exs`: 36 | 37 | ``` 38 | defp deps do 39 | [ 40 | {:ecsx, "~> 0.5"} 41 | ] 42 | end 43 | ``` 44 | 45 | Then (from the root directory of your application) run: 46 | 47 | ```console 48 | $ mix deps.get 49 | ``` 50 | 51 | ## Setup ECSx 52 | 53 | With ECSx installed, you can run the setup generator: 54 | 55 | ```console 56 | $ mix ecsx.setup 57 | ``` 58 | 59 | which will create the Manager, and two folders to get your project started. You should now have everything you need to start building! -------------------------------------------------------------------------------- /guides/common_caveats.md: -------------------------------------------------------------------------------- 1 | # Common Caveats 2 | 3 | The ECSx API has been carefully designed to avoid common Elixir pitfalls and encourage efficient architectural patterns in your application. However, there are still some opportunities for bad patterns to sneak in and destabilize performance. This guide will list known problems and how to avoid them. 4 | 5 | ## Database Queries 6 | 7 | It is expected that most applications will use a database at some point or another. However, when dealing with `ECSx.System`s, which run many times per second, most databases are too slow to keep up as the load grows. Each server tick has a deadline to finish its work, and if it falls behind, there will be lag, unstable performance, and eventually game crashes. 8 | 9 | > #### Therefore: {: .error} 10 | > 11 | > You should never query the database from within a system 12 | 13 | Instead: use `ECSx.Manager.setup/1` to read the necessary data from the database at startup, and create components with it. Components are stored in memory, allowing the quick reads and writes which are required for system logic. 14 | 15 | Example: 16 | 17 | ```elixir 18 | defmodule MyApp.Manager do 19 | use ECSx.Manager 20 | 21 | alias MyApp.Components.Height 22 | alias MyApp.Components.XPosition 23 | alias MyApp.Components.YPosition 24 | alias MyApp.Trees 25 | alias MyApp.Trees.Tree 26 | 27 | setup do 28 | for %Tree{id: id, x: x, y: y, height: height} <- Trees.my_db_query() do 29 | XPosition.add(id, x) 30 | YPosition.add(id, y) 31 | Height.add(id, height) 32 | end 33 | end 34 | ... 35 | end 36 | ``` 37 | 38 | Then when our systems need to work with the height or position of trees, we use the `ECSx.Component` API instead of querying the database again. 39 | 40 | ## External Requests 41 | 42 | Database queries are just one example of a slow call which can hold up the game systems. Any other request to a service outside your application will likely be too slow to be used in system logic. 43 | 44 | Instead: 45 | 46 | * If you only need to make the request once upon initialization, use `ECSx.Manager.setup/1` as shown above 47 | * If the request is made once, but triggered by input to some client process, that process should make the request (or spawn a `Task` for it) 48 | * If the request should happen regularly, create a new [`GenServer`](https://hexdocs.pm/elixir/GenServer.html#module-receiving-regular-messages) (don't forget to add it to your app's supervision tree) 49 | * If you have more than one external request happening regularly, this is a good use case for the [`Oban`](https://hexdocs.pm/oban/Oban.html) library 50 | * Remember that client processes (including GenServers, LiveViews, and Oban) have read-only access 51 | to components, and must use `ECSx.ClientEvents` for writes 52 | 53 | ## search/1 Without Index 54 | 55 | `ECSx.Component.search/1` will scan all Components of the given type to find matches. This can be 56 | OK if the quantity of Components of that type is small, or if it is just a one-time search. But 57 | if you are searching every tick within a System, through a large list of Components, this can become 58 | a performance concern. The solution is to set the `index: true` option, which will drastically 59 | improve search performance. 60 | -------------------------------------------------------------------------------- /guides/ecs_design.md: -------------------------------------------------------------------------------- 1 | # ECS Design 2 | 3 | ## Entities and Components 4 | 5 | Everything in your application is an Entity, but in ECS you won't work with these 6 | Entities directly - instead you will work with the individual attributes that an Entity 7 | might have. These attributes are given to an Entity by creating a Component, which holds, 8 | at minimum, the Entity's unique ID, but also can store a value. For example: 9 | 10 | * You're running a 2-dimensional simulation of cars on a highway 11 | * Each car gets its own `entity_id` e.g. `123` 12 | * If the car with ID `123` is blue, we give it a `Color` Component with value `"blue"` 13 | * If the same car is moving west at 60mph, we might model this with a `Direction` Component with value `"west"` and a `Speed` Component with value `60` 14 | * The car would also have Components such as `XCoordinate` and `YCoordinate` to locate it 15 | on the map 16 | 17 | ## Systems 18 | 19 | Once your Entities are modeled using Components, you'll create Systems to operate on them. 20 | For example: 21 | 22 | * Entities with `Speed` Components should have their locations regularly updated according to the speed and direction 23 | * We can create a `Move` System which reads the `Speed` and `Direction` Components, calculates how far the car has moved since the last server tick, and updates the Entity's `XCoordinate` and/or `YCoordinate` Component accordingly. 24 | * The System will run every tick, only considering Entities which have a `Speed` Component 25 | 26 | ## one-to-many associations 27 | 28 | At some point, you might find yourself thinking of adding multiple Components of the same type to a 29 | single Entity. We'll call this a "one-to-many" association - as in, one Entity, many Components. 30 | For example: 31 | 32 | Let's say you have a fantasy game where the hero wields a weapon in one hand, and a shield in the 33 | other hand. At first, you create a Component for each 34 | 35 | ```elixir 36 | Weapon.add(hero_entity, "Longsword") 37 | Shield.add(hero_entity, "Buckler") 38 | ``` 39 | 40 | and move on to other features. However, later on you decide to implement a dual-wielding mechanic 41 | where the hero can wield two swords at a time. You briefly consider creating a new Component type 42 | called `OffhandWeapon` to go alongside the primary `Weapon`, but then remember that eventually the 43 | hero will encounter monsters with more than two arms! Also there is a feature request for weapons 44 | to be magically enchanted, and eventually you'd also like equipment to lose durability over time and 45 | require repairs at the blacksmith. So, creating a new Component type for each weapon slot is only 46 | a temporary workaround which is not a very robust solution. 47 | 48 | The ideal solution here is to think about the weapons not as Components, but as separate Entities. 49 | When the hero equips a sword, create a new entity reference for that sword, and reference back 50 | to the hero entity with one of the sword's Components. 51 | 52 | ```elixir 53 | sword_entity = Ecto.UUID.generate() 54 | Description.add(sword_entity, "Longsword") 55 | EquippedBy.add(sword_entity, hero_entity) 56 | ``` 57 | 58 | Now if the hero gets a second sword, we can repeat the process: 59 | 60 | ```elixir 61 | another_sword_entity = Ecto.UUID.generate() 62 | Description.add(another_sword_entity, "Shortsword") 63 | EquippedBy.add(another_sword_entity, hero_entity) 64 | ``` 65 | 66 | Fetching a list of weapons equipped by the hero can then be done with `EquippedBy.search(hero_entity)` 67 | 68 | To implement weapon durability: 69 | 70 | ```elixir 71 | Durability.add(sword_entity, 150) 72 | Durability.add(another_sword_entity, 75) 73 | ``` 74 | 75 | Implementing magic enchantments presents the same situation: we could add a Component to the sword, 76 | but this only works for one simple enchantment per weapon. In order to allow multiple enchantments 77 | per weapon, with arbitrary enchantment complexity, we should think about each enchantment as an 78 | Entity. 79 | 80 | ```elixir 81 | enchantment_entity = Ecto.UUID.generate() 82 | Description.add(enchantment_entity, "Firaga") 83 | EnchantTarget.add(enchantment_entity, sword_entity) 84 | ``` 85 | 86 | -------------------------------------------------------------------------------- /guides/tutorial/backend_basics.md: -------------------------------------------------------------------------------- 1 | # Backend Basics 2 | 3 | ## Defining Component Types 4 | 5 | First let's consider the basic properties of a ship: 6 | 7 | * Hull Points: How much damage can it take before it is destroyed 8 | * Armor Rating: How much is each incoming attack reduced by the ship's defenses 9 | * Attack Damage: How much damage does its weapon deal to enemies 10 | * Attack Range: How close must enemies get before the weapon can attack 11 | * Attack Speed: How much time must you wait in-between attacks 12 | * X Position: The horizontal position of the ship 13 | * Y Position: The vertical position of the ship 14 | * X Velocity: The speed at which the ship is moving, horizontally 15 | * Y Velocity: The speed at which the ship is moving, vertically 16 | 17 | We'll start by creating `integer` component types for each one of these, except AttackSpeed, which will use `float`: 18 | 19 | $ mix ecsx.gen.component HullPoints integer 20 | $ mix ecsx.gen.component ArmorRating integer 21 | $ mix ecsx.gen.component AttackDamage integer 22 | $ mix ecsx.gen.component AttackRange integer 23 | $ mix ecsx.gen.component XPosition integer 24 | $ mix ecsx.gen.component YPosition integer 25 | $ mix ecsx.gen.component XVelocity integer 26 | $ mix ecsx.gen.component YVelocity integer 27 | $ mix ecsx.gen.component AttackSpeed float 28 | 29 | For now, this is all we need to do. The ECSx generator has automatically set you up with modules for each component type, complete with a simple interface for handling the components. We'll see this in action soon. 30 | 31 | ## Our First System 32 | 33 | Having set up the component types which will model our game data, let's think about the Systems which will organize game logic. What makes our game work? 34 | 35 | * Ships change position based on velocity 36 | * Ships target other ships for attack when they are within range 37 | * Ships with valid targets should attack the target, reducing its hull points 38 | * Ships with zero or less hull points are destroyed 39 | * Players change the velocity of their ship using an input device 40 | * Players can see a display of the area around their ship 41 | 42 | Let's start with changing position based on velocity. We'll call it `Driver`: 43 | 44 | $ mix ecsx.gen.system Driver 45 | 46 | Head over to the generated file `lib/ship/systems/driver.ex` and we'll add some code: 47 | 48 | ```elixir 49 | defmodule Ship.Systems.Driver do 50 | ... 51 | @behaviour ECSx.System 52 | 53 | alias Ship.Components.XPosition 54 | alias Ship.Components.YPosition 55 | alias Ship.Components.XVelocity 56 | alias Ship.Components.YVelocity 57 | 58 | @impl ECSx.System 59 | def run do 60 | for {entity, x_velocity} <- XVelocity.get_all() do 61 | x_position = XPosition.get(entity) 62 | new_x_position = x_position + x_velocity 63 | XPosition.update(entity, new_x_position) 64 | end 65 | 66 | # Once the x-values are updated, do the same for the y-values 67 | for {entity, y_velocity} <- YVelocity.get_all() do 68 | y_position = YPosition.get(entity) 69 | new_y_position = y_position + y_velocity 70 | YPosition.update(entity, new_y_position) 71 | end 72 | end 73 | end 74 | ``` 75 | 76 | Now whenever a ship gains velocity, this system will update the position accordingly over time. Keep in mind that the velocity is relative to the server's tick rate, which by default is 20. This means the unit of measurement is "game units per 1/20th of a second". 77 | 78 | For example, if you want the speed to move from XPosition 0 to XPosition 100 in one second, you divide the distance 100 by the tick rate 20, to see that an XVelocity of 5 is appropriate. The tick rate can be changed in `config/config.ex` and fetched at runtime by calling `ECSx.tick_rate/0`. 79 | 80 | ## Targeting & Attacking 81 | 82 | Next let's move on to a more complicated part of the game - attacking. We'll start by considering the conditions which must be met in order to attack a given target: 83 | 84 | * Target must be a ship 85 | * Target must be within your ship's attack range 86 | * You must not have attacked too recently (based on attack speed) 87 | 88 | For each of these conditions, we want to use the presence or absence of a component as the signal to a system that action is to be taken. For example, in the Driver system, these were the Velocity components - for each Velocity component, we made a Position update. 89 | 90 | First, for determining whether a given entity is a ship, we will simply use the existing HullPoints component, because only ships will have HullPoints. 91 | 92 | Second, for confirming the attack range, we'll make a new component type SeekingTarget which will signal to a Targeting system that a ship's proximity to other ships must be continuously calculated until a valid target is found. Then another new component type AttackTarget will replace SeekingTarget, signaling to the Targeting system that we no longer need to check for new targets. Instead, an Attacking system will detect the AttackTarget and handle the final step of the attacking process. 93 | 94 | The final attack requirement is that after a successful attack, the ship's weapon must wait for a cooldown period, based on the attack speed. To model this cooldown period, we will create an AttackCooldown component type, which will store the time at which the cooldown expires. 95 | 96 | With this plan in place, let's go ahead and create the component types, starting with SeekingTarget. Since the presence of this component alone fulfills its purpose, without the need to store additional data, this is the appropriate use-case for a `Tag`: 97 | 98 | $ mix ecsx.gen.tag SeekingTarget 99 | 100 | Once a target is found, the `AttackTarget` component will be needed, and this time a `Tag` will not be enough, because we need to store the ID of the target. Likewise with `AttackCooldown`, which must store the timestamp of the cooldown's expiration. 101 | 102 | $ mix ecsx.gen.component AttackTarget binary 103 | $ mix ecsx.gen.component AttackCooldown datetime 104 | 105 | > Note: In our case, we're using binary IDs to represent Entities, and Elixir `DateTime` structs for cooldown expirations. If you're planning on using different types, such as integer IDs for entities, or storing timestamps as integers, simply adjust the parameters accordingly. 106 | 107 | Before we set up the systems, let's make a helper module for storing any shared mathematical logic. In particular, we'll need a function for calculating the distance between two entities. This will come in handy for several systems in the future. 108 | 109 | ```elixir 110 | defmodule Ship.SystemUtils do 111 | @moduledoc """ 112 | Useful math functions used by multiple systems. 113 | """ 114 | 115 | alias Ship.Components.XPosition 116 | alias Ship.Components.YPosition 117 | 118 | def distance_between(entity_1, entity_2) do 119 | x_1 = XPosition.get(entity_1) 120 | x_2 = XPosition.get(entity_2) 121 | y_1 = YPosition.get(entity_1) 122 | y_2 = YPosition.get(entity_2) 123 | 124 | x = abs(x_1 - x_2) 125 | y = abs(y_1 - y_2) 126 | 127 | :math.sqrt(x ** 2 + y ** 2) 128 | end 129 | end 130 | ``` 131 | 132 | Now we're onto the Targeting system, which operates only on entities with the SeekingTarget component, checking the distance to all other ships, and comparing them to the entity's attack range. When an enemy ship is found to be within range, we can remove SeekingTarget and replace it with an AttackTarget: 133 | 134 | $ mix ecsx.gen.system Targeting 135 | 136 | ```elixir 137 | defmodule Ship.Systems.Targeting do 138 | ... 139 | @behaviour ECSx.System 140 | 141 | alias Ship.Components.AttackRange 142 | alias Ship.Components.AttackTarget 143 | alias Ship.Components.HullPoints 144 | alias Ship.Components.SeekingTarget 145 | alias Ship.SystemUtils 146 | 147 | @impl ECSx.System 148 | def run do 149 | entities = SeekingTarget.get_all() 150 | 151 | Enum.each(entities, &attempt_target/1) 152 | end 153 | 154 | defp attempt_target(self) do 155 | case look_for_target(self) do 156 | nil -> :noop 157 | {target, _hp} -> add_target(self, target) 158 | end 159 | end 160 | 161 | defp look_for_target(self) do 162 | # For now, we're assuming anything which has HullPoints can be attacked 163 | HullPoints.get_all() 164 | # ... except your own ship! 165 | |> Enum.reject(fn {possible_target, _hp} -> possible_target == self end) 166 | |> Enum.find(fn {possible_target, _hp} -> 167 | distance_between = SystemUtils.distance_between(possible_target, self) 168 | range = AttackRange.get(self) 169 | 170 | distance_between < range 171 | end) 172 | end 173 | 174 | defp add_target(self, target) do 175 | SeekingTarget.remove(self) 176 | AttackTarget.add(self, target) 177 | end 178 | end 179 | ``` 180 | 181 | The Attacking system will also check distance, but only to the target ship, in case it has moved out-of-range. If not, we just need to check on the cooldown, and do the attack. 182 | 183 | $ mix ecsx.gen.system Attacking 184 | 185 | ```elixir 186 | defmodule Ship.Systems.Attacking do 187 | ... 188 | @behaviour ECSx.System 189 | 190 | alias Ship.Components.ArmorRating 191 | alias Ship.Components.AttackCooldown 192 | alias Ship.Components.AttackDamage 193 | alias Ship.Components.AttackRange 194 | alias Ship.Components.AttackSpeed 195 | alias Ship.Components.AttackTarget 196 | alias Ship.Components.HullPoints 197 | alias Ship.Components.SeekingTarget 198 | alias Ship.SystemUtils 199 | 200 | @impl ECSx.System 201 | def run do 202 | attack_targets = AttackTarget.get_all() 203 | 204 | Enum.each(attack_targets, &attack_if_ready/1) 205 | end 206 | 207 | defp attack_if_ready({self, target}) do 208 | cond do 209 | SystemUtils.distance_between(self, target) > AttackRange.get(self) -> 210 | # If the target ever leaves our attack range, we want to remove the AttackTarget 211 | # and begin searching for a new one. 212 | AttackTarget.remove(self) 213 | SeekingTarget.add(self) 214 | 215 | AttackCooldown.exists?(self) -> 216 | # We're still within range, but waiting on the cooldown 217 | :noop 218 | 219 | :otherwise -> 220 | deal_damage(self, target) 221 | add_cooldown(self) 222 | end 223 | end 224 | 225 | defp deal_damage(self, target) do 226 | attack_damage = AttackDamage.get(self) 227 | # Assuming one armor rating always equals one damage 228 | reduction_from_armor = ArmorRating.get(target) 229 | final_damage_amount = attack_damage - reduction_from_armor 230 | 231 | target_current_hp = HullPoints.get(target) 232 | target_new_hp = target_current_hp - final_damage_amount 233 | 234 | HullPoints.update(target, target_new_hp) 235 | end 236 | 237 | defp add_cooldown(self) do 238 | now = DateTime.utc_now() 239 | ms_between_attacks = calculate_cooldown_time(self) 240 | cooldown_until = DateTime.add(now, ms_between_attacks, :millisecond) 241 | 242 | AttackCooldown.add(self, cooldown_until) 243 | end 244 | 245 | # We're going to model AttackSpeed with a float representing attacks per second. 246 | # The goal here is to convert that into milliseconds per attack. 247 | defp calculate_cooldown_time(self) do 248 | attacks_per_second = AttackSpeed.get(self) 249 | seconds_per_attack = 1 / attacks_per_second 250 | 251 | ceil(seconds_per_attack * 1000) 252 | end 253 | end 254 | ``` 255 | 256 | Phew, that was a lot! But we're still using the same basic concepts: `get_all/0` to fetch the list of all relevant entities, then `get/1` and `exists?/1` to check specific attributes of the entities, `add/2` for creating new components, and `update/2` for overwriting existing ones. We're also starting to see the use of `remove/1` for excluding an entity from game logic which is no longer necessary. 257 | 258 | ## Cooldowns 259 | 260 | Our attacking system will add a cooldown with an expiration timestamp, but the next step is to ensure the cooldown component is removed from the entity once the time is reached, so it can attack again. For that, we'll create a `CooldownExpiration` system: 261 | 262 | $ mix ecsx.gen.system CooldownExpiration 263 | 264 | ```elixir 265 | defmodule Ship.Systems.CooldownExpiration do 266 | ... 267 | @behaviour ECSx.System 268 | 269 | alias Ship.Components.AttackCooldown 270 | 271 | @impl ECSx.System 272 | def run do 273 | now = DateTime.utc_now() 274 | cooldowns = AttackCooldown.get_all() 275 | 276 | Enum.each(cooldowns, &remove_when_expired(&1, now)) 277 | end 278 | 279 | defp remove_when_expired({entity, timestamp}, now) do 280 | case DateTime.compare(now, timestamp) do 281 | :lt -> :noop 282 | _ -> AttackCooldown.remove(entity) 283 | end 284 | end 285 | end 286 | ``` 287 | 288 | This system will check the cooldowns on each game tick, removing them as soon as the expiration time is reached. 289 | 290 | ## Death & Destruction 291 | 292 | Next let's handle what happens when a ship has its HP reduced to zero or less: 293 | 294 | $ mix ecsx.gen.component DestroyedAt datetime 295 | 296 | $ mix ecsx.gen.system Destruction 297 | 298 | ```elixir 299 | defmodule Ship.Systems.Destruction do 300 | ... 301 | @behaviour ECSx.System 302 | 303 | alias Ship.Components.ArmorRating 304 | alias Ship.Components.AttackCooldown 305 | alias Ship.Components.AttackDamage 306 | alias Ship.Components.AttackRange 307 | alias Ship.Components.AttackSpeed 308 | alias Ship.Components.AttackTarget 309 | alias Ship.Components.DestroyedAt 310 | alias Ship.Components.HullPoints 311 | alias Ship.Components.SeekingTarget 312 | alias Ship.Components.XPosition 313 | alias Ship.Components.XVelocity 314 | alias Ship.Components.YPosition 315 | alias Ship.Components.YVelocity 316 | 317 | @impl ECSx.System 318 | def run do 319 | ships = HullPoints.get_all() 320 | 321 | Enum.each(ships, fn {entity, hp} -> 322 | if hp <= 0, do: destroy(entity) 323 | end) 324 | end 325 | 326 | defp destroy(ship) do 327 | ArmorRating.remove(ship) 328 | AttackCooldown.remove(ship) 329 | AttackDamage.remove(ship) 330 | AttackRange.remove(ship) 331 | AttackSpeed.remove(ship) 332 | AttackTarget.remove(ship) 333 | HullPoints.remove(ship) 334 | SeekingTarget.remove(ship) 335 | XPosition.remove(ship) 336 | XVelocity.remove(ship) 337 | YPosition.remove(ship) 338 | YVelocity.remove(ship) 339 | 340 | # when a ship is destroyed, other ships should stop targeting it 341 | untarget(ship) 342 | 343 | DestroyedAt.add(ship, DateTime.utc_now()) 344 | end 345 | 346 | defp untarget(target) do 347 | for ship <- AttackTarget.search(target) do 348 | AttackTarget.remove(ship) 349 | SeekingTarget.add(ship) 350 | end 351 | end 352 | end 353 | ``` 354 | 355 | In this example we remove all the components the entity might have, then add a new DestroyedAt component with the current timestamp. If we wanted some components to persist - such as the position and/or velocity, so the wreckage could still be visible on the player displays - we could keep them around and possibly have another system clean them up later on. Likewise if there were other components to add, such as a `RespawnTimer` or `FinalScore`, we could add them here as well. 356 | 357 | ## Initializing Component Data 358 | 359 | By now you might be wondering "How did those components get created in the first place?" We have code for adding `AttackCooldown` and `DestroyedAt`, when needed, but the basic components for the ships still need to be added before the game can even start. For that, we'll check out `lib/ship/manager.ex`: 360 | 361 | ```elixir 362 | defmodule Ship.Manager do 363 | ... 364 | use ECSx.Manager 365 | 366 | def setup do 367 | ... 368 | end 369 | 370 | def startup do 371 | ... 372 | end 373 | 374 | def components do 375 | ... 376 | end 377 | 378 | def systems do 379 | ... 380 | end 381 | end 382 | ``` 383 | 384 | This module holds three critical pieces of data - component setup, a list of every valid component type, and a list of each game system in the order they are to be run. Let's create some ship components inside the `startup` block: 385 | 386 | ```elixir 387 | def startup do 388 | for _ships <- 1..40 do 389 | # First generate a unique ID to represent the new entity 390 | entity = Ecto.UUID.generate() 391 | 392 | # Then use that ID to create the components which make up a ship 393 | Ship.Components.ArmorRating.add(entity, 0) 394 | Ship.Components.AttackDamage.add(entity, 5) 395 | Ship.Components.AttackRange.add(entity, 10) 396 | Ship.Components.AttackSpeed.add(entity, 1.05) 397 | Ship.Components.HullPoints.add(entity, 50) 398 | Ship.Components.SeekingTarget.add(entity) 399 | Ship.Components.XPosition.add(entity, Enum.random(1..100)) 400 | Ship.Components.YPosition.add(entity, Enum.random(1..100)) 401 | Ship.Components.XVelocity.add(entity, 0) 402 | Ship.Components.YVelocity.add(entity, 0) 403 | end 404 | end 405 | ``` 406 | 407 | Now whenever the server starts, there will be forty ships set up and ready to go. 408 | -------------------------------------------------------------------------------- /guides/tutorial/initial_setup.md: -------------------------------------------------------------------------------- 1 | # Initial Setup 2 | 3 | To demonstrate ECSx in a real-time application, we're going to make a game where each player will control a ship, which can sail around the map, and will attack enemies if they come too close. 4 | 5 | > Note: This guide will get you up-and-running with a working game, but it is intentionally generic. Feel free to experiment with altering details from this implementation to customize your own game. 6 | 7 | * First, ensure you have installed [Elixir](https://elixir-lang.org/install.html) and [Phoenix](https://hexdocs.pm/phoenix/installation.html) 1.7+. 8 | * Create the application by running `mix phx.new ship` 9 | * Run `mix ecto.create` to initialize the database 10 | * Add `{:ecsx, "~> 0.5"}` to your `mix.exs` deps 11 | * Run `mix deps.get` 12 | * Run `mix ecsx.setup` 13 | -------------------------------------------------------------------------------- /guides/tutorial/web_frontend_liveview.md: -------------------------------------------------------------------------------- 1 | # Web Frontend with LiveView 2 | 3 | Since we're using Phoenix, we can take advantage of the many features it brings for building a web interface. 4 | 5 | ## Player Auth 6 | 7 | When it comes to player auth, there are two sides to the coin: authentication (AuthN) and authorization (AuthZ). The former refers to verifying the identity of a player (and will be our primary focus, for now), while the latter refers to checking whether a user has permission to take a restricted action. 8 | 9 | Phoenix comes with an [AuthN generator](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Auth.html) built-in, which should be more than enough for our needs: 10 | 11 | $ mix phx.gen.auth Players Player players --binary-id 12 | $ mix deps.get 13 | $ mix ecto.migrate 14 | 15 | This will expect players to register an email and password, which will be used to log in. A unique ID will also be created for each player upon registration, allowing us to begin thinking of players as entities. However, we can't just take the player input and start creating components with it - only systems can create components. Instead, we'll use a special component type provided for this purpose: `ECSx.ClientEvents`. 16 | 17 | ## Client Input via LiveView 18 | 19 | First consider the goals for our frontend: 20 | 21 | * Authenticate the player and hold player ID 22 | * Spawn the player's ship upon connection (writes components) 23 | * Hold the coordinates for the player's ship 24 | * Hold the coordinates for enemy ships 25 | * Validate user input to move the ship (writes components) 26 | 27 | When we need to write components, `ECSx.ClientEvents` will be our line of communication from the frontend to the backend. 28 | 29 | Let's create `/lib/ship_web/live/game_live.ex` and put it to use: 30 | 31 | ```elixir 32 | defmodule ShipWeb.GameLive do 33 | use ShipWeb, :live_view 34 | 35 | alias Ship.Components.HullPoints 36 | alias Ship.Components.XPosition 37 | alias Ship.Components.YPosition 38 | 39 | def mount(_params, %{"player_token" => token} = _session, socket) do 40 | # This context function was generated by phx.gen.auth 41 | player = Ship.Players.get_player_by_session_token(token) 42 | 43 | socket = 44 | socket 45 | |> assign(player_entity: player.id) 46 | # Keeping a set of currently held keys will allow us to prevent duplicate keydown events 47 | |> assign(keys: MapSet.new()) 48 | # We don't know where the ship will spawn, yet 49 | |> assign(x_coord: nil, y_coord: nil, current_hp: nil) 50 | 51 | # We don't want these calls to be made on both the initial static page render and again after 52 | # the LiveView is connected, so we wrap them in `connected?/1` to prevent duplication 53 | if connected?(socket) do 54 | ECSx.ClientEvents.add(player.id, :spawn_ship) 55 | :timer.send_interval(50, :load_player_info) 56 | end 57 | 58 | {:ok, socket} 59 | end 60 | 61 | def handle_info(:load_player_info, socket) do 62 | # This will run every 50ms to keep the client assigns updated 63 | x = XPosition.get(socket.assigns.player_entity) 64 | y = YPosition.get(socket.assigns.player_entity) 65 | hp = HullPoints.get(socket.assigns.player_entity) 66 | 67 | {:noreply, assign(socket, x_coord: x, y_coord: y, current_hp: hp)} 68 | end 69 | 70 | def handle_event("keydown", %{"key" => key}, socket) do 71 | if MapSet.member?(socket.assigns.keys, key) do 72 | # Already holding this key - do nothing 73 | {:noreply, socket} 74 | else 75 | # We only want to add a client event if the key is defined by the `keydown/1` helper below 76 | maybe_add_client_event(socket.assigns.player_entity, key, &keydown/1) 77 | {:noreply, assign(socket, keys: MapSet.put(socket.assigns.keys, key))} 78 | end 79 | end 80 | 81 | def handle_event("keyup", %{"key" => key}, socket) do 82 | # We don't have to worry about duplicate keyup events 83 | # But once again, we will only add client events for keys that actually do something 84 | maybe_add_client_event(socket.assigns.player_entity, key, &keyup/1) 85 | {:noreply, assign(socket, keys: MapSet.delete(socket.assigns.keys, key))} 86 | end 87 | 88 | defp maybe_add_client_event(player_entity, key, fun) do 89 | case fun.(key) do 90 | :noop -> :ok 91 | event -> ECSx.ClientEvents.add(player_entity, event) 92 | end 93 | end 94 | 95 | defp keydown(key) when key in ~w(w W ArrowUp), do: {:move, :north} 96 | defp keydown(key) when key in ~w(a A ArrowLeft), do: {:move, :west} 97 | defp keydown(key) when key in ~w(s S ArrowDown), do: {:move, :south} 98 | defp keydown(key) when key in ~w(d D ArrowRight), do: {:move, :east} 99 | defp keydown(_key), do: :noop 100 | 101 | defp keyup(key) when key in ~w(w W ArrowUp), do: {:stop_move, :north} 102 | defp keyup(key) when key in ~w(a A ArrowLeft), do: {:stop_move, :west} 103 | defp keyup(key) when key in ~w(s S ArrowDown), do: {:stop_move, :south} 104 | defp keyup(key) when key in ~w(d D ArrowRight), do: {:stop_move, :east} 105 | defp keyup(_key), do: :noop 106 | 107 | def render(assigns) do 108 | ~H""" 109 |
110 |

Player ID: <%= @player_entity %>

111 |

Player Coords: <%= inspect({@x_coord, @y_coord}) %>

112 |

Hull Points: <%= @current_hp %>

113 |
114 | """ 115 | end 116 | end 117 | ``` 118 | 119 | ## Handling Client Events 120 | 121 | Finally, spin up a new system for handling the events: 122 | 123 | $ mix ecsx.gen.system ClientEventHandler 124 | 125 | ```elixir 126 | defmodule Ship.Systems.ClientEventHandler do 127 | ... 128 | @behaviour ECSx.System 129 | 130 | alias Ship.Components.ArmorRating 131 | alias Ship.Components.AttackDamage 132 | alias Ship.Components.AttackRange 133 | alias Ship.Components.AttackSpeed 134 | alias Ship.Components.HullPoints 135 | alias Ship.Components.SeekingTarget 136 | alias Ship.Components.XPosition 137 | alias Ship.Components.XVelocity 138 | alias Ship.Components.YPosition 139 | alias Ship.Components.YVelocity 140 | 141 | @impl ECSx.System 142 | def run do 143 | client_events = ECSx.ClientEvents.get_and_clear() 144 | 145 | Enum.each(client_events, &process_one/1) 146 | end 147 | 148 | defp process_one({player, :spawn_ship}) do 149 | # We'll give player ships better stats than the enemy ships 150 | # (otherwise the game would be very short!) 151 | ArmorRating.add(player, 2) 152 | AttackDamage.add(player, 6) 153 | AttackRange.add(player, 15) 154 | AttackSpeed.add(player, 1.2) 155 | HullPoints.add(player, 75) 156 | SeekingTarget.add(player) 157 | XPosition.add(player, Enum.random(1..100)) 158 | YPosition.add(player, Enum.random(1..100)) 159 | XVelocity.add(player, 0) 160 | YVelocity.add(player, 0) 161 | end 162 | 163 | # Note Y movement will use screen position (increasing Y goes south) 164 | defp process_one({player, {:move, :north}}), do: YVelocity.update(player, -1) 165 | defp process_one({player, {:move, :south}}), do: YVelocity.update(player, 1) 166 | defp process_one({player, {:move, :east}}), do: XVelocity.update(player, 1) 167 | defp process_one({player, {:move, :west}}), do: XVelocity.update(player, -1) 168 | 169 | defp process_one({player, {:stop_move, :north}}), do: YVelocity.update(player, 0) 170 | defp process_one({player, {:stop_move, :south}}), do: YVelocity.update(player, 0) 171 | defp process_one({player, {:stop_move, :east}}), do: XVelocity.update(player, 0) 172 | defp process_one({player, {:stop_move, :west}}), do: XVelocity.update(player, 0) 173 | end 174 | ``` 175 | 176 | Notice how the LiveView client can write to `ECSx.ClientEvents`, while the system handles and also clears the events. This ensures that we don't process the same event twice, nor will any events get "lost" and not processed. 177 | 178 | ## Creating a Phoenix Route 179 | 180 | Head into `router.ex` and look for the new scope which uses `:require_authenticated_player`. We're going to add a new route for our game interface: 181 | 182 | ```elixir 183 | scope "/", ShipWeb do 184 | pipe_through [:browser, :require_authenticated_player] 185 | 186 | live_session :require_authenticated_player, 187 | on_mount: [{ShipWeb.PlayerAuth, :ensure_authenticated}] do 188 | live "/game", GameLive 189 | ... 190 | end 191 | end 192 | ``` 193 | 194 | Now we can run 195 | 196 | $ iex -S mix phx.server 197 | 198 | and go to `localhost:4000/game` to test the input. Once you are logged in, wait for the player coords to display (this will be the indicator that your ship has spawned), and try moving around with `WASD` or arrow keys! 199 | 200 | ## Loading Screen 201 | 202 | You might notice that while the ship is spawning, the Player Coords and Hull Points don't display properly - this isn't a major issue now, but once our coordinates are being used by a more sophisticated display, this will not be acceptable. What we need is a loading screen to show the user until the necessary data is properly loaded. 203 | 204 | First, let's create a new `ECSx.Tag` to mark when a player's ship has finished spawning: 205 | 206 | $ mix ecsx.gen.tag PlayerSpawned 207 | 208 | Then we'll add this tag at the end of the `:spawn_ship` client event 209 | 210 | ```elixir 211 | defmodule Ship.Systems.ClientEventHandler do 212 | ... 213 | alias Ship.Components.PlayerSpawned 214 | ... 215 | defp process_one({player, :spawn_ship}) do 216 | ... 217 | PlayerSpawned.add(player) 218 | end 219 | ... 220 | end 221 | ``` 222 | 223 | Now we'll update our LiveView to use a new `@loading` assign which is initially set to `true`, then set to `false` after the ship is spawned and the data is loaded for the first time. 224 | 225 | Replace both the current `mount` and `handle_info` functions with the below functions, and replace the existing `render` function 226 | with the new `render` function below 227 | 228 | ```elixir 229 | defmodule ShipWeb.GameLive do 230 | ... 231 | alias Ship.Components.PlayerSpawned 232 | ... 233 | def mount(_params, %{"player_token" => token} = _session, socket) do 234 | player = Ship.Players.get_player_by_session_token(token) 235 | 236 | socket = 237 | socket 238 | |> assign(player_entity: player.id) 239 | |> assign(keys: MapSet.new()) 240 | # This gets its own helper in case we need to return to this state again later 241 | |> assign_loading_state() 242 | 243 | if connected?(socket) do 244 | ECSx.ClientEvents.add(player.id, :spawn_ship) 245 | # The first load will now have additional responsibilities 246 | send(self(), :first_load) 247 | end 248 | 249 | 250 | {:ok, socket} 251 | end 252 | 253 | defp assign_loading_state(socket) do 254 | assign(socket, 255 | x_coord: nil, 256 | y_coord: nil, 257 | current_hp: nil, 258 | # This new assign will control whether the loading screen is shown 259 | loading: true 260 | ) 261 | end 262 | 263 | def handle_info(:first_load, socket) do 264 | # Don't start fetching components until after spawn is complete! 265 | :ok = wait_for_spawn(socket.assigns.player_entity) 266 | 267 | socket = 268 | socket 269 | |> assign_player_ship() 270 | |> assign(loading: false) 271 | 272 | # We want to keep up-to-date on this info 273 | :timer.send_interval(50, :refresh) 274 | 275 | {:noreply, socket} 276 | end 277 | 278 | def handle_info(:refresh, socket) do 279 | {:noreply, assign_player_ship(socket)} 280 | end 281 | 282 | defp wait_for_spawn(player_entity) do 283 | if PlayerSpawned.exists?(player_entity) do 284 | :ok 285 | else 286 | Process.sleep(10) 287 | wait_for_spawn(player_entity) 288 | end 289 | end 290 | 291 | # Our previous :load_player_info handler becomes a shared helper for the new handlers 292 | defp assign_player_ship(socket) do 293 | x = XPosition.get(socket.assigns.player_entity) 294 | y = YPosition.get(socket.assigns.player_entity) 295 | hp = HullPoints.get(socket.assigns.player_entity) 296 | 297 | assign(socket, x_coord: x, y_coord: y, current_hp: hp) 298 | end 299 | 300 | def handle_event("keydown", %{"key" => key}, socket) do 301 | ... 302 | 303 | def render(assigns) do 304 | ~H""" 305 |
306 | <%= if @loading do %> 307 |

Loading...

308 | <% else %> 309 |

Player ID: <%= @player_entity %>

310 |

Player Coords: <%= inspect({@x_coord, @y_coord}) %>

311 |

Hull Points: <%= @current_hp %>

312 | <% end %> 313 |
314 | """ 315 | end 316 | end 317 | ``` 318 | 319 | ## Player GUI using SVG 320 | 321 | One of the simplest ways to build a display for web is with SVG. Each entity can be represented by a single SVG element, which only requires its coordinates. Then a `viewBox` can zoom the player's display in to show just the local area around their ship. 322 | 323 | ```elixir 324 | defmodule ShipWeb.GameLive do 325 | ... 326 | def render(assigns) do 327 | ~H""" 328 |
329 | 333 | 334 | 335 | <%= if @loading do %> 336 | 337 | Loading... 338 | 339 | <% else %> 340 | 347 | <%= for {_entity, x, y, image_file} <- @other_ships do %> 348 | 355 | <% end %> 356 | 357 | Hull Points: <%= @current_hp %> 358 | 359 | <% end %> 360 | 361 |
362 | """ 363 | end 364 | end 365 | ``` 366 | 367 | We've added a lot here, so let's go line-by-line: 368 | 369 | We're filling the screen with an [`svg viewBox`](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox), which takes four arguments. The first two - x and y offsets - tell the viewBox what area of the map to focus on, while the latter two - screen width and height - tell it how much to zoom in. To get the offsets, we'll need to calculate the coordinate pair which should be at the very top-left of the player's view. This will need to be updated every time the player moves. The screen width and height (measured by game coordinates) will be assigned on mount and won't change. The `preserveAspectRatio` has two parts: `xMinYMin` means we define the offset by the top-left coordinate, and `slice` means we're only expecting to show a "slice" of the map in our viewBox, not the whole thing. 370 | 371 | Our first element will be a simple `rect` (rectangle) with a light-blue fill to cover the entire world map. This will be the "background" - representing the ocean. The game world will consistently be 100x100, so we can assign a `game_world_size` of 100 for this purpose. 372 | 373 | The loading screen will still use the same viewBox and background, but with only one other element - `text` to display a loading message in the center of the screen. One curiosity regarding the font size: we use `1px`, but you'll see later that the text is actually quite large. This is because the viewBox considers each game "tile" to be 1 pixel, and automatically scales these pixels up to a larger size based on the screen width and height compared to the size of the browser window. So when we say `font: 1px` it means the text will be as tall as one game tile. 374 | 375 | Once the game is finished loading, we'll display three things: the player's ship, other ships, and the player's current HP. 376 | 377 | For the player's ship, we'll make an `image` element, using the existing x and y coordinates, defining the size as one game tile, and pointing to the player's ship image file. 378 | 379 | For other ships, we'll need a new assign to hold that data - ID and coordinates, at minimum. Then each one will get an `image` just like the player's ship. 380 | 381 | Lastly, we'll put an HP display near the top-left corner. 382 | 383 | Lets first create an `ImageFile` component: 384 | 385 | $ mix ecsx.gen.component ImageFile binary 386 | 387 | Next step is to update our LiveView with these new assigns: 388 | 389 | ```elixir 390 | defmodule ShipWeb.GameLive do 391 | ... 392 | alias Ship.Components.ImageFile 393 | ... 394 | def mount(_params, %{"player_token" => token} = _session, socket) do 395 | player = Ship.Players.get_player_by_session_token(token) 396 | 397 | socket = 398 | socket 399 | |> assign(player_entity: player.id) 400 | |> assign(keys: MapSet.new()) 401 | # These will configure the scale of our display compared to the game world 402 | |> assign(game_world_size: 100, screen_height: 30, screen_width: 50) 403 | |> assign_loading_state() 404 | 405 | if connected?(socket) do 406 | ECSx.ClientEvents.add(player.id, :spawn_ship) 407 | send(self(), :first_load) 408 | end 409 | 410 | {:ok, socket} 411 | end 412 | 413 | defp assign_loading_state(socket) do 414 | assign(socket, 415 | x_coord: nil, 416 | y_coord: nil, 417 | current_hp: nil, 418 | player_ship_image_file: nil, 419 | other_ships: [], 420 | x_offset: 0, 421 | y_offset: 0, 422 | loading: true 423 | ) 424 | end 425 | 426 | def handle_info(:first_load, socket) do 427 | :ok = wait_for_spawn(socket.assigns.player_entity) 428 | 429 | socket = 430 | socket 431 | |> assign_player_ship() 432 | |> assign_other_ships() 433 | |> assign_offsets() 434 | |> assign(loading: false) 435 | 436 | :timer.send_interval(50, :refresh) 437 | 438 | {:noreply, socket} 439 | end 440 | 441 | def handle_info(:refresh, socket) do 442 | socket = 443 | socket 444 | |> assign_player_ship() 445 | |> assign_other_ships() 446 | |> assign_offsets() 447 | 448 | {:noreply, socket} 449 | end 450 | 451 | defp wait_for_spawn(player_entity) do 452 | if PlayerSpawned.exists?(player_entity) do 453 | :ok 454 | else 455 | Process.sleep(10) 456 | wait_for_spawn(player_entity) 457 | end 458 | end 459 | 460 | defp assign_player_ship(socket) do 461 | x = XPosition.get(socket.assigns.player_entity) 462 | y = YPosition.get(socket.assigns.player_entity) 463 | hp = HullPoints.get(socket.assigns.player_entity) 464 | image = ImageFile.get(socket.assigns.player_entity) 465 | 466 | assign(socket, x_coord: x, y_coord: y, current_hp: hp, player_ship_image_file: image) 467 | end 468 | 469 | defp assign_other_ships(socket) do 470 | other_ships = 471 | Enum.reject(all_ships(), fn {entity, _, _, _} -> entity == socket.assigns.player_entity end) 472 | 473 | assign(socket, other_ships: other_ships) 474 | end 475 | 476 | defp all_ships do 477 | for {ship, _hp} <- HullPoints.get_all() do 478 | x = XPosition.get(ship) 479 | y = YPosition.get(ship) 480 | image = ImageFile.get(ship) 481 | {ship, x, y, image} 482 | end 483 | end 484 | 485 | defp assign_offsets(socket) do 486 | # Note: the socket must already have updated player coordinates before assigning offsets! 487 | %{screen_width: screen_width, screen_height: screen_height} = socket.assigns 488 | %{x_coord: x, y_coord: y, game_world_size: game_world_size} = socket.assigns 489 | 490 | x_offset = calculate_offset(x, screen_width, game_world_size) 491 | y_offset = calculate_offset(y, screen_height, game_world_size) 492 | 493 | assign(socket, x_offset: x_offset, y_offset: y_offset) 494 | end 495 | 496 | defp calculate_offset(coord, screen_size, game_world_size) do 497 | case coord - div(screen_size, 2) do 498 | offset when offset < 0 -> 0 499 | offset when offset > game_world_size - screen_size -> game_world_size - screen_size 500 | offset -> offset 501 | end 502 | end 503 | 504 | def handle_event("keydown", %{"key" => key}, socket) do 505 | ... 506 | end 507 | ``` 508 | 509 | Next let's create the `ImageFile` components when a ship is spawned: 510 | 511 | ```elixir 512 | defmodule Ship.Manager do 513 | ... 514 | def startup do 515 | for _ships <- 1..40 do 516 | ... 517 | Ship.Components.ImageFile.add(entity, "npc_ship.svg") 518 | end 519 | end 520 | ... 521 | end 522 | ``` 523 | 524 | ```elixir 525 | defmodule Ship.Systems.ClientEventHandler do 526 | ... 527 | alias Ship.Components.ImageFile 528 | ... 529 | defp process_one({player, :spawn_ship}) do 530 | ... 531 | ImageFile.add(player, "player_ship.svg") 532 | PlayerSpawned.add(player) 533 | end 534 | ... 535 | end 536 | ``` 537 | 538 | Lastly, we'll need the [player_ship.svg](https://raw.githubusercontent.com/ecsx-framework/ship/master/priv/static/images/player_ship.svg) and [npc_ship.svg](https://raw.githubusercontent.com/ecsx-framework/ship/master/priv/static/images/npc_ship.svg) files. Right-click on the links and save them to `priv/static/images/`, where they will be found by our `~p"/images/..."` calls in the LiveView template. 539 | 540 | Now running 541 | 542 | $ iex -S mix phx.server 543 | 544 | and heading to `localhost:4000/game` should provide a usable game interface to move your ship around, ideally keeping it out of attack range of enemy ships, while remaining close enough for your own ship to attack (remember that we gave the player ship a longer attack range than the enemy ships). 545 | 546 | ## Projectile Animations 547 | 548 | Currently the most challenging part of the game is knowing when your ship is attacking, and when it is being attacked. Let's implement a new feature to make attacks visible to the player(s). There are several ways to go about this; we're going to take an approach that showcases ECS design: 549 | 550 | * Instead of an attack immediately dealing damage, it will spawn a cannonball entity 551 | * The cannonball entity will have position and velocity components, like ships do 552 | * It will also have new components such as `ProjectileTarget` and `ProjectileDamage` 553 | * A `Projectile` system will guide it to its target, then destroy the cannonball and deal damage 554 | * In our LiveView, we'll create a new assign to hold the locations of projectiles 555 | * The new assign will be used to create SVG elements 556 | * To help fetch locations for projectiles only, we'll add an `IsProjectile` tag 557 | 558 | Start by running the generator commands for our new components, systems, and tag: 559 | 560 | $ mix ecsx.gen.component ProjectileTarget binary 561 | $ mix ecsx.gen.component ProjectileDamage integer 562 | $ mix ecsx.gen.system Projectile 563 | $ mix ecsx.gen.tag IsProjectile 564 | 565 | Then we need to update the `Attacking` system to spawn projectiles instead of immediately dealing damage. We'll replace the existing `deal_damage/2` with a `spawn_projectile/2`: 566 | 567 | ```elixir 568 | defmodule Ship.Systems.Attacking do 569 | ... 570 | @behaviour ECSx.System 571 | 572 | alias Ship.Components.AttackCooldown 573 | alias Ship.Components.AttackDamage 574 | alias Ship.Components.AttackRange 575 | alias Ship.Components.AttackSpeed 576 | alias Ship.Components.AttackTarget 577 | alias Ship.Components.ImageFile 578 | alias Ship.Components.IsProjectile 579 | alias Ship.Components.ProjectileDamage 580 | alias Ship.Components.ProjectileTarget 581 | alias Ship.Components.SeekingTarget 582 | alias Ship.Components.XPosition 583 | alias Ship.Components.XVelocity 584 | alias Ship.Components.YPosition 585 | alias Ship.Components.YVelocity 586 | alias Ship.SystemUtils 587 | ... 588 | defp attack_if_ready({self, target}) do 589 | cond do 590 | ... 591 | :otherwise -> 592 | spawn_projectile(self, target) 593 | add_cooldown(self) 594 | end 595 | end 596 | 597 | defp spawn_projectile(self, target) do 598 | attack_damage = AttackDamage.get(self) 599 | x = XPosition.get(self) 600 | y = YPosition.get(self) 601 | # Armor reduction should wait until impact to be calculated 602 | cannonball_entity = Ecto.UUID.generate() 603 | 604 | IsProjectile.add(cannonball_entity) 605 | XPosition.add(cannonball_entity, x) 606 | YPosition.add(cannonball_entity, y) 607 | XVelocity.add(cannonball_entity, 0) 608 | YVelocity.add(cannonball_entity, 0) 609 | ImageFile.add(cannonball_entity, "cannonball.svg") 610 | ProjectileTarget.add(cannonball_entity, target) 611 | ProjectileDamage.add(cannonball_entity, attack_damage) 612 | end 613 | ... 614 | end 615 | ``` 616 | 617 | Notice we start the velocity at zero, because the movement will be entirely handled by the `Projectile` system: 618 | 619 | ```elixir 620 | defmodule Ship.Systems.Projectile do 621 | ... 622 | @behaviour ECSx.System 623 | 624 | alias Ship.Components.ArmorRating 625 | alias Ship.Components.HullPoints 626 | alias Ship.Components.ImageFile 627 | alias Ship.Components.IsProjectile 628 | alias Ship.Components.ProjectileDamage 629 | alias Ship.Components.ProjectileTarget 630 | alias Ship.Components.XPosition 631 | alias Ship.Components.XVelocity 632 | alias Ship.Components.YPosition 633 | alias Ship.Components.YVelocity 634 | 635 | @cannonball_speed 3 636 | 637 | @impl ECSx.System 638 | def run do 639 | projectiles = IsProjectile.get_all() 640 | 641 | Enum.each(projectiles, fn projectile -> 642 | case ProjectileTarget.get(projectile, nil) do 643 | nil -> 644 | # The target has already been destroyed 645 | destroy_projectile(projectile) 646 | 647 | target -> 648 | continue_seeking_target(projectile, target) 649 | end 650 | end) 651 | end 652 | 653 | defp continue_seeking_target(projectile, target) do 654 | {dx, dy, distance} = get_distance_to_target(projectile, target) 655 | 656 | case distance do 657 | 0 -> 658 | collision(projectile, target) 659 | 660 | distance when distance / @cannonball_speed <= 1 -> 661 | move_directly_to_target(projectile, {dx, dy}) 662 | 663 | distance -> 664 | adjust_velocity_towards_target(projectile, {distance, dx, dy}) 665 | end 666 | end 667 | 668 | defp get_distance_to_target(projectile, target) do 669 | target_x = XPosition.get(target) 670 | target_y = YPosition.get(target) 671 | target_dx = XVelocity.get(target) 672 | target_dy = YVelocity.get(target) 673 | target_next_x = target_x + target_dx 674 | target_next_y = target_y + target_dy 675 | 676 | x = XPosition.get(projectile) 677 | y = YPosition.get(projectile) 678 | 679 | dx = target_next_x - x 680 | dy = target_next_y - y 681 | 682 | {dx, dy, ceil(:math.sqrt(dx ** 2 + dy ** 2))} 683 | end 684 | 685 | defp collision(projectile, target) do 686 | damage_target(projectile, target) 687 | destroy_projectile(projectile) 688 | end 689 | 690 | defp damage_target(projectile, target) do 691 | damage = ProjectileDamage.get(projectile) 692 | reduction_from_armor = ArmorRating.get(target) 693 | final_damage_amount = damage - reduction_from_armor 694 | 695 | target_current_hp = HullPoints.get(target) 696 | target_new_hp = target_current_hp - final_damage_amount 697 | 698 | HullPoints.update(target, target_new_hp) 699 | end 700 | 701 | defp destroy_projectile(projectile) do 702 | IsProjectile.remove(projectile) 703 | XPosition.remove(projectile) 704 | YPosition.remove(projectile) 705 | XVelocity.remove(projectile) 706 | YVelocity.remove(projectile) 707 | ImageFile.remove(projectile) 708 | ProjectileTarget.remove(projectile) 709 | ProjectileDamage.remove(projectile) 710 | end 711 | 712 | defp move_directly_to_target(projectile, {dx, dy}) do 713 | XVelocity.update(projectile, dx) 714 | YVelocity.update(projectile, dy) 715 | end 716 | 717 | defp adjust_velocity_towards_target(projectile, {distance, dx, dy}) do 718 | # We know what is needed, but we need to slow it down, so its travel 719 | # will take more than one tick. Otherwise the player will not see it! 720 | ticks_away = ceil(distance / @cannonball_speed) 721 | adjusted_dx = div(dx, ticks_away) 722 | adjusted_dy = div(dy, ticks_away) 723 | 724 | XVelocity.update(projectile, adjusted_dx) 725 | YVelocity.update(projectile, adjusted_dy) 726 | end 727 | end 728 | ``` 729 | 730 | Note that we rely on the absence of a `ProjectileTarget` to know that the target is already destroyed. Currently our `Destruction` system does have an `untarget` feature for removing target components upon destruction, but this only applies to `AttackTarget`s. We'll want to expand this feature to also cover `ProjectileTarget`s: 731 | 732 | ```elixir 733 | defmodule Ship.Systems.Destruction do 734 | ... 735 | alias Ship.Components.ProjectileTarget 736 | ... 737 | defp untarget(target) do 738 | for ship <- AttackTarget.search(target) do 739 | AttackTarget.remove(ship) 740 | SeekingTarget.add(ship) 741 | end 742 | 743 | for projectile <- ProjectileTarget.search(target) do 744 | ProjectileTarget.remove(projectile) 745 | end 746 | end 747 | end 748 | ``` 749 | 750 | Our final task is to render these projectiles in the LiveView. Let's start by adding a new assign: 751 | 752 | ```elixir 753 | defmodule Ship.GameLive do 754 | ... 755 | alias Ship.Components.IsProjectile 756 | ... 757 | defp assign_loading_state(socket) do 758 | assign(socket, 759 | ... 760 | projectiles: [] 761 | ) 762 | end 763 | 764 | def handle_info(:first_load, socket) do 765 | ... 766 | socket = 767 | socket 768 | |> assign_player_ship() 769 | |> assign_other_ships() 770 | |> assign_projectiles() 771 | |> assign_offsets() 772 | |> assign(loading: false) 773 | ... 774 | end 775 | 776 | def handle_info(:refresh, socket) do 777 | socket = 778 | socket 779 | |> assign_player_ship() 780 | |> assign_other_ships() 781 | |> assign_projectiles() 782 | |> assign_offsets() 783 | ... 784 | end 785 | ... 786 | defp assign_projectiles(socket) do 787 | projectiles = 788 | for projectile <- IsProjectile.get_all() do 789 | x = XPosition.get(projectile) 790 | y = YPosition.get(projectile) 791 | image = ImageFile.get(projectile) 792 | {projectile, x, y, image} 793 | end 794 | 795 | assign(socket, projectiles: projectiles) 796 | end 797 | ... 798 | end 799 | ``` 800 | 801 | Then we'll update the render to include the projectiles: 802 | 803 | ```elixir 804 | defmodule Ship.GameLive do 805 | def render(assigns) do 806 | ~H""" 807 | ... 808 | <%= for {_entity, x, y, image_file} <- @projectiles do %> 809 | 816 | <% end %> 817 | <%= for {_entity, x, y, image_file} <- @other_ships do %> 818 | ... 819 | """ 820 | end 821 | end 822 | ``` 823 | 824 | Lastly - `cannonball.svg` - we will make this file from scratch! 825 | 826 | $ touch priv/static/images/cannonball.svg 827 | 828 | ```html 829 | 830 | 831 | 832 | ``` 833 | 834 | The `width` and `height` values will be overriden by our LiveView render's `width="1" height="1"`, but they still play an important role - because the circle's parameters will be measured relative to these - so we'll set them to `100` for simplicity. `cx` and `cy` represent the coordinates for the center of the circle, which should be one-half the `width` and `height`. The size of the circle will be set with `r` (radius) and `stroke-width` (the border around the circle) - we can calculate `diameter = 2 * (r + stroke_width) = 60`. This diameter is also relative, so when our `cannonball.svg` is scaled down to `1 x 1`, the visible circle will be `0.6 x 0.6` 835 | 836 | ## Limiting Ship Movement 837 | 838 | You might notice that that once the ship hits the edge of the world map it keeps moving and disappears from sight. This happens because we keep updating the ship's position regardless of it being within the limits of the map. 839 | 840 | To solve this, let's go to the `Driver` system and limit the position range for the player ship: 841 | 842 | ```elixir 843 | defmodule Ship.Systems.Driver do 844 | ... 845 | def run do 846 | for {entity, x_velocity} <- XVelocity.get_all() do 847 | ... 848 | new_x_position = calculate_new_position(x_position, x_velocity) 849 | ... 850 | end 851 | 852 | for {entity, y_velocity} <- YVelocity.get_all() do 853 | ... 854 | new_y_position = calculate_new_position(y_position, y_velocity) 855 | ... 856 | end 857 | ... 858 | end 859 | 860 | # Do not let player ship move past the map limit 861 | defp calculate_new_position(current_position, velocity) do 862 | new_position = current_position + velocity 863 | new_position = Enum.min([new_position, 99]) 864 | 865 | Enum.max([new_position, 0]) 866 | end 867 | end 868 | ``` 869 | 870 | This will limit the position for the ship on both X and Y axis to be between 0 and 99 (the size of the 100x100 world map). -------------------------------------------------------------------------------- /guides/upgrade_guide.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## 0.4 to 0.5 4 | 5 | Non-unique Component types are no longer allowed. Setting the `:unique` option from within a Component 6 | module now has no effect. If you are currently using non-unique Component types in your application, 7 | you must replace them with Entities as described in the [one_to_many guide](ecs_design.html#one-to-many-associations). 8 | 9 | Component read/write changes: 10 | 11 | * `MyComponent.get_one/1` should be renamed to `MyComponent.get/1` 12 | 13 | ## 0.3.x to 0.4 14 | 15 | In `manager.ex`: 16 | 17 | * `setup do` should be changed to `def startup do` 18 | * Remove the `:tick_rate` option if it is set 19 | * If the `:tick_rate` option was not the default of 20, add `config :ecsx, tick_rate: n` to your `config.exs`, where n is your chosen tick rate 20 | 21 | In `application.ex`: 22 | 23 | * If the list of children in `start/2` contains `ECSx.ClientEvents` or `YourApp.Manager`, remove them 24 | 25 | Component read/write changes: 26 | 27 | * Any use of `MyComponent.get_one(entity)` where `nil` was a possibility, should be replaced with `MyComponent.get_one(entity, nil)` 28 | * Any use of `MyComponent.add(entity, value)` to update the value of a unique component, should be replaced with `MyComponent.update(entity, value)`. If the `add/2` call was used in a way where sometimes it would create new components, and other times update those components, you will need to separate the two cases to use `add/2` only for the initial creation, and then `update/2` for all subsequent updates. 29 | -------------------------------------------------------------------------------- /lib/ecsx.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx do 2 | @moduledoc """ 3 | ECSx is an Entity-Component-System (ECS) framework for Elixir. 4 | 5 | In ECS: 6 | 7 | * Every game object is an Entity, represented by a unique ID. 8 | * The data which comprises an Entity is split among many Components. 9 | * Game logic is split into Systems, which update the Components every server tick. 10 | 11 | Under the hood, ECSx uses Erlang Term Storage (ETS) to store active Components in memory. 12 | A single GenServer manages the ETS tables to ensure strict serializability and customize 13 | the run order for Systems. 14 | 15 | ## Configuration 16 | 17 | You may configure various settings for ECSx in the application environment 18 | (usually defined in `config/config.exs`): 19 | 20 | config :ecsx, 21 | manager: MyApp.Manager, 22 | tick_rate: 20, 23 | persist_interval: :timer.seconds(15), 24 | persistence_adapter: ECSx.Persistence.FileAdapter, 25 | persistence_file_location: "components.persistence" 26 | 27 | * `:manager` - This setting defines the module and path for your app's ECSx Manager. 28 | When only a module name is given here, the path will be inferred using the standard 29 | directory conventions (e.g. `MyApp.Manager` becomes `lib/my_app/manager.ex`). 30 | If you are using a different structure for your directories, you can instead use a tuple 31 | including the `:path` option (e.g. `{ManagerModule, path: "lib/path/to/file.ex"}`) 32 | * `:tick_rate` - This controls how many times per second each system will run. Setting a higher 33 | value here can make a smoother experience for users of your app, but will come at the cost 34 | of increased server load. Increasing this value beyond your hardware's capabilities will 35 | result in instability across the entire application, worsening over time until eventually 36 | the application crashes. 37 | * `:persist_interval` - ECSx makes regular backups of all components marked for persistence. 38 | This setting defines the length of time between each backup. 39 | * `:persistence_adapter` - If you have a custom adapter which implements 40 | `ECSx.Persistence.Behaviour`, you can set it here to replace the default `FileAdapter`. 41 | * `:persistence_file_location` - If you are using the default `FileAdapter` for persistence, 42 | this setting allows you to define the path for the backup file. 43 | 44 | """ 45 | use Application 46 | 47 | @doc false 48 | def start(_type, _args) do 49 | children = [ECSx.ClientEvents, ECSx.Persistence.Server] ++ List.wrap(ECSx.manager() || []) 50 | 51 | Supervisor.start_link(children, strategy: :one_for_one, name: ECSx.Supervisor) 52 | end 53 | 54 | @doc """ 55 | Returns the ECSx manager module. 56 | 57 | This is set in your app configuration: 58 | 59 | ```elixir 60 | config :ecsx, manager: MyApp.Manager 61 | ``` 62 | """ 63 | @spec manager() :: module() | nil 64 | def manager do 65 | case Application.get_env(:ecsx, :manager) do 66 | {module, path: _} when is_atom(module) -> module 67 | module_or_nil when is_atom(module_or_nil) -> module_or_nil 68 | end 69 | end 70 | 71 | @doc """ 72 | Returns the path to the ECSx manager file. 73 | 74 | This is inferred by your module name. If you want to rename or move the 75 | manager file so the path and module name are no longer in alignment, use 76 | a custom `:path` opt along with the manager module, wrapped in a tuple. 77 | 78 | ## Examples 79 | 80 | ```elixir 81 | # standard path: lib/my_app/manager.ex 82 | config :ecsx, manager: MyApp.Manager 83 | 84 | # custom path: lib/foo/bar/baz.ex 85 | config :ecsx, manager: {MyApp.Manager, path: "lib/foo/bar/baz.ex"} 86 | ``` 87 | """ 88 | @spec manager_path() :: binary() | nil 89 | def manager_path do 90 | case Application.get_env(:ecsx, :manager) do 91 | {_module, path: path} when is_binary(path) -> 92 | path 93 | 94 | nil -> 95 | nil 96 | 97 | module when is_atom(module) -> 98 | path = 99 | module 100 | |> Module.split() 101 | |> Enum.map_join("/", &Macro.underscore/1) 102 | 103 | "lib/" <> path <> ".ex" 104 | end 105 | end 106 | 107 | @doc """ 108 | Returns the tick rate of the ECSx application. 109 | 110 | This defaults to 20, and can be changed in your app configuration: 111 | 112 | ```elixir 113 | config :ecsx, tick_rate: 15 114 | ``` 115 | """ 116 | @spec tick_rate() :: integer() 117 | def tick_rate do 118 | Application.get_env(:ecsx, :tick_rate, 20) 119 | end 120 | 121 | @doc """ 122 | Returns the frequency of component persistence. 123 | 124 | This defaults to 15 seconds, and can be changed in your app configuration: 125 | 126 | ```elixir 127 | config :ecsx, persist_interval: :timer.minutes(1) 128 | ``` 129 | """ 130 | @spec persist_interval() :: integer() 131 | def persist_interval do 132 | Application.get_env(:ecsx, :persist_interval, :timer.seconds(15)) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/ecsx/base.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Base do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | def add(component_type, id, value, opts) do 7 | persist = Keyword.get(opts, :persist, false) 8 | 9 | case :ets.lookup(component_type, id) do 10 | [] -> 11 | if Keyword.get(opts, :log_edits) do 12 | Logger.debug("#{component_type} add #{inspect(id)}: #{inspect(value)}") 13 | end 14 | 15 | if Keyword.get(opts, :index) do 16 | index_table = Module.concat(component_type, "Index") 17 | :ets.insert(index_table, {value, id, persist}) 18 | end 19 | 20 | :ets.insert(component_type, {id, value, persist}) 21 | :ok 22 | 23 | _ -> 24 | raise ECSx.AlreadyExistsError, 25 | message: "`add` expects component to not exist yet", 26 | entity_id: id 27 | end 28 | end 29 | 30 | # Direct load for persistence 31 | def load(component_type, component) do 32 | :ets.insert(component_type, component) 33 | end 34 | 35 | def update(component_type, id, value, opts) do 36 | if Keyword.get(opts, :log_edits) do 37 | Logger.debug("#{component_type} update #{inspect(id)}: #{inspect(value)}") 38 | end 39 | 40 | case :ets.lookup(component_type, id) do 41 | [{id, old_value, persist}] -> 42 | if Keyword.get(opts, :index) do 43 | index_table = Module.concat(component_type, "Index") 44 | :ets.delete_object(index_table, {old_value, id, persist}) 45 | :ets.insert(index_table, {value, id, persist}) 46 | end 47 | 48 | :ets.insert(component_type, {id, value, persist}) 49 | :ok 50 | 51 | [] -> 52 | raise ECSx.NoResultsError, 53 | message: "`update` expects an existing value", 54 | entity_id: id 55 | end 56 | end 57 | 58 | def get(component_type, entity_id, default) do 59 | case :ets.lookup(component_type, entity_id) do 60 | [] -> 61 | case default do 62 | :raise -> 63 | raise ECSx.NoResultsError, 64 | message: "`get` expects one result, got 0", 65 | entity_id: entity_id 66 | 67 | other -> 68 | other 69 | end 70 | 71 | [component] -> 72 | elem(component, 1) 73 | end 74 | end 75 | 76 | def get_all(component_type) do 77 | component_type 78 | |> :ets.tab2list() 79 | |> Enum.map(&{elem(&1, 0), elem(&1, 1)}) 80 | end 81 | 82 | def get_all(component_type, entity_id) do 83 | component_type 84 | |> :ets.lookup(entity_id) 85 | |> Enum.map(&elem(&1, 1)) 86 | end 87 | 88 | def get_all_persist(component_type) do 89 | component_type 90 | |> :ets.tab2list() 91 | |> Enum.filter(&elem(&1, 2)) 92 | end 93 | 94 | def get_all_keys(component_type) do 95 | component_type 96 | |> :ets.tab2list() 97 | |> Enum.map(&elem(&1, 0)) 98 | end 99 | 100 | def search(component_type, value, opts) do 101 | if Keyword.get(opts, :index) do 102 | component_type 103 | |> Module.concat("Index") 104 | |> :ets.lookup(value) 105 | |> Enum.map(fn {_value, id, _persist} -> id end) 106 | else 107 | component_type 108 | |> :ets.match({:"$1", value, :_}) 109 | |> List.flatten() 110 | end 111 | end 112 | 113 | def between(component_type, min, max) do 114 | :ets.select(component_type, [ 115 | {{:"$1", :"$2", :_}, [{:>=, :"$2", min}, {:"=<", :"$2", max}], [{{:"$1", :"$2"}}]} 116 | ]) 117 | end 118 | 119 | def at_least(component_type, min) do 120 | :ets.select(component_type, [{{:"$1", :"$2", :_}, [{:>=, :"$2", min}], [{{:"$1", :"$2"}}]}]) 121 | end 122 | 123 | def at_most(component_type, max) do 124 | :ets.select(component_type, [{{:"$1", :"$2", :_}, [{:"=<", :"$2", max}], [{{:"$1", :"$2"}}]}]) 125 | end 126 | 127 | def remove(component_type, entity_id, opts) do 128 | if Keyword.get(opts, :log_edits) do 129 | Logger.debug("#{component_type} remove #{inspect(entity_id)}") 130 | end 131 | 132 | if Keyword.get(opts, :index) do 133 | case :ets.lookup(component_type, entity_id) do 134 | [{^entity_id, value, persist}] -> 135 | index_table = Module.concat(component_type, "Index") 136 | :ets.delete(component_type, entity_id) 137 | :ets.delete_object(index_table, {value, entity_id, persist}) 138 | 139 | _ -> 140 | nil 141 | end 142 | else 143 | :ets.delete(component_type, entity_id) 144 | end 145 | 146 | :ok 147 | end 148 | 149 | def exists?(component_type, entity_id) do 150 | :ets.member(component_type, entity_id) 151 | end 152 | 153 | def init(table_name, concurrency, opts) do 154 | :ets.new(table_name, [:named_table, :set, concurrency]) 155 | 156 | if Keyword.get(opts, :index) do 157 | index_table = Module.concat(table_name, "Index") 158 | :ets.new(index_table, [:named_table, :bag]) 159 | end 160 | 161 | :ok 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/ecsx/client_events.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.ClientEvents do 2 | @moduledoc """ 3 | A store to which clients can write, for communication with the ECSx backend. 4 | 5 | Events are created from the client process by calling `add/2`, then retrieved by the handler 6 | system using `get_and_clear/0`. You will be required to create the handler system yourself - 7 | see the [tutorial project](web_frontend_liveview.html#handling-client-events) for a detailed example. 8 | """ 9 | 10 | @doc false 11 | use GenServer 12 | 13 | @type id :: any() 14 | 15 | @doc false 16 | def start_link(_), do: ECSx.Manager.start_link(__MODULE__) 17 | 18 | @doc false 19 | def init(_), do: {:ok, []} 20 | 21 | @doc false 22 | def handle_cast({:add, entity, value}, state) do 23 | {:noreply, [{entity, value} | state]} 24 | end 25 | 26 | @doc false 27 | def handle_call(:get_and_clear, _from, state) do 28 | {reversed, count} = reverse_and_count(state) 29 | 30 | :telemetry.execute([:ecsx, :client_events], %{count: count}) 31 | 32 | {:reply, reversed, []} 33 | end 34 | 35 | defp reverse_and_count(list, done \\ [], count \\ 0) 36 | defp reverse_and_count([], done, count), do: {done, count} 37 | defp reverse_and_count([h | t], done, count), do: reverse_and_count(t, [h | done], count + 1) 38 | 39 | @doc """ 40 | Add a new client event. 41 | 42 | The first argument is the entity which spawned the event. 43 | The second argument can be any representation of the event, usually either an atom or a tuple 44 | containing an atom name along with additional metadata. 45 | 46 | ## Examples 47 | 48 | # Simple event requiring no metadata 49 | ECSx.ClientEvents.add(player_id, :spawn_player) 50 | 51 | # Event with metadata 52 | ECSx.ClientEvents.add(player_id, {:send_message_to, recipient_id, message}) 53 | 54 | """ 55 | @spec add(id(), any()) :: :ok 56 | def add(entity, event), do: GenServer.cast(__MODULE__, {:add, entity, event}) 57 | 58 | @doc """ 59 | Returns the list of events, simultaneously clearing it. 60 | 61 | This function guarantees that each event is returned exactly once. 62 | """ 63 | @spec get_and_clear() :: [{id(), any()}] 64 | def get_and_clear, do: GenServer.call(__MODULE__, :get_and_clear) 65 | end 66 | -------------------------------------------------------------------------------- /lib/ecsx/component.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Component do 2 | @moduledoc """ 3 | A Component labels an entity as having a certain attribute, and holds any data needed to model that attribute. 4 | 5 | For example, if Entities in your application should have a "color" value, you will create a Component type called `Color`. This allows you to add a color component to an Entity with `add/2`, look up the color value for a given Entity with `get_one/1`, get all Entities' color values with `get_all/1`, remove the color value from an Entity altogether with `remove/1`, or test whether an entity has a color with `exists?/1`. 6 | 7 | Under the hood, we use ETS to store the Components in memory for quick retrieval via Entity ID. 8 | 9 | ## Usage 10 | 11 | Each Component type should have its own module, where it can be optionally configured. 12 | 13 | defmodule MyApp.Components.Color do 14 | use ECSx.Component, 15 | value: :binary 16 | end 17 | 18 | ### Options 19 | 20 | * `:value` - The type of value which will be stored in this component type. Valid types are: `:atom, :binary, :datetime, :float, :integer` 21 | * `:index` - When `true`, the `search/1` function will be much more efficient, at the cost of slightly higher write times. Defaults to `false` 22 | * `:log_edits` - When `true`, log messages will be emitted for each component added, updated, or removed. Defaults to `false` 23 | * `:read_concurrency` - When `true`, enables read concurrency for this component table. Only set this if you know what you're doing. Defaults to `false` 24 | 25 | """ 26 | 27 | @type id :: any 28 | @type value :: any 29 | 30 | defmacro __using__(opts) do 31 | quote bind_quoted: [opts: opts] do 32 | @behaviour ECSx.Component 33 | 34 | @table_name __MODULE__ 35 | @concurrency {:read_concurrency, opts[:read_concurrency] || false} 36 | @valid_value_types ~w(atom binary datetime float integer)a 37 | @component_opts [ 38 | log_edits: opts[:log_edits] || false, 39 | index: opts[:index] || false 40 | ] 41 | 42 | # Sets up value type validation 43 | case Keyword.fetch!(opts, :value) do 44 | :integer -> 45 | defguard ecsx_type_guard(value) when is_integer(value) 46 | 47 | :float -> 48 | defguard ecsx_type_guard(value) when is_float(value) 49 | 50 | :binary -> 51 | defguard ecsx_type_guard(value) when is_binary(value) 52 | 53 | :atom -> 54 | defguard ecsx_type_guard(value) when is_atom(value) 55 | 56 | :datetime -> 57 | defguard ecsx_type_guard(value) when is_struct(value, DateTime) 58 | 59 | _ -> 60 | raise( 61 | ArgumentError, 62 | "Invalid value type: Valid types are #{inspect(@valid_value_types)}" 63 | ) 64 | end 65 | 66 | # Eventually remove this 67 | case Keyword.get(opts, :unique) do 68 | true -> 69 | msg = "Component option `:unique` no longer has any effect" 70 | IO.warn(msg, Macro.Env.stacktrace(__ENV__)) 71 | :ok 72 | 73 | false -> 74 | raise(ArgumentError, "Component option `unique: false` is no longer allowed") 75 | 76 | nil -> 77 | :ok 78 | end 79 | 80 | def init, do: ECSx.Base.init(@table_name, @concurrency, @component_opts) 81 | 82 | def load(component), do: ECSx.Base.load(@table_name, component) 83 | 84 | def add(entity_id, value, opts \\ []) when ecsx_type_guard(value), 85 | do: ECSx.Base.add(@table_name, entity_id, value, Keyword.merge(opts, @component_opts)) 86 | 87 | def update(entity_id, value) when ecsx_type_guard(value), 88 | do: ECSx.Base.update(@table_name, entity_id, value, @component_opts) 89 | 90 | def get(key, default \\ :raise), do: ECSx.Base.get(@table_name, key, default) 91 | 92 | def get_all, do: ECSx.Base.get_all(@table_name) 93 | 94 | def get_all_persist, do: ECSx.Base.get_all_persist(@table_name) 95 | 96 | def search(value), do: ECSx.Base.search(@table_name, value, @component_opts) 97 | 98 | def remove(entity_id), do: ECSx.Base.remove(@table_name, entity_id, @component_opts) 99 | 100 | def exists?(entity_id), do: ECSx.Base.exists?(@table_name, entity_id) 101 | 102 | if Keyword.fetch!(opts, :value) in [:integer, :float] do 103 | def between(min, max) when is_number(min) and is_number(max), 104 | do: ECSx.Base.between(@table_name, min, max) 105 | 106 | def at_least(min) when is_number(min), do: ECSx.Base.at_least(@table_name, min) 107 | 108 | def at_most(max) when is_number(max), do: ECSx.Base.at_most(@table_name, max) 109 | end 110 | end 111 | end 112 | 113 | @doc """ 114 | Creates a new component. 115 | 116 | ## Options 117 | 118 | * `:persist` - When `true`, this component will persist across app reboots. Defaults to `false` 119 | 120 | ## Example 121 | 122 | # Add an ArmorRating component to entity `123` with value `10` 123 | # If the app shuts down, this component will be removed 124 | ArmorRating.add(123, 10) 125 | 126 | # This ArmorRating component will be persisted after app shutdown, 127 | # and automatically re-added to entity `123` upon next startup 128 | ArmorRating.add(123, 10, persist: true) 129 | 130 | """ 131 | @callback add(entity :: id, value :: value, opts :: Keyword.t()) :: :ok 132 | 133 | @doc """ 134 | Updates an existing component's value. 135 | 136 | The component's `:persist` option will remain unchanged. (see `add/3`) 137 | 138 | ## Example 139 | 140 | ArmorRating.add(123, 10) 141 | # Increase the ArmorRating value from `10` to `15` 142 | ArmorRating.update(123, 15) 143 | 144 | """ 145 | @callback update(entity :: id, value :: value) :: :ok 146 | 147 | @doc """ 148 | Look up a component and return its value. 149 | 150 | If a `default` value is provided, that value will be returned if no result is found. 151 | 152 | If `default` is not provided, this function will raise an `ECSx.NoResultsError` if no result is found. 153 | 154 | ## Example 155 | 156 | # Get the Velocity for entity `123`, which is known to already exist 157 | Velocity.get(123) 158 | 159 | # Get the Velocity for entity `123` if it exists, otherwise return `nil` 160 | Velocity.get(123, nil) 161 | 162 | """ 163 | @callback get(entity :: id, default :: value) :: value 164 | 165 | @doc """ 166 | Look up all components of this type. 167 | 168 | ## Example 169 | 170 | # Get all velocity components 171 | Velocity.get_all() 172 | 173 | """ 174 | @callback get_all() :: [{id, value}] 175 | 176 | @doc """ 177 | Look up all IDs for entities which have a component of this type with a given value. 178 | 179 | This function is significantly optimized by the `:index` option. For component 180 | types which are regularly searched, it is highly recommended to set this option to `true`. 181 | 182 | ## Example 183 | 184 | # Get all entities with a velocity of `60` 185 | Velocity.search(60) 186 | 187 | """ 188 | @callback search(value :: value) :: [id] 189 | 190 | @doc """ 191 | Look up all components where the value is greater than or equal to `min` and less 192 | than or equal to `max`. 193 | 194 | This function only works for numerical component types (`:value` set to either 195 | `:integer` or `:float`). Other value types will raise `UndefinedFunctionError`. 196 | 197 | ## Example 198 | 199 | # Get all RespawnCount components where 51 <= value <= 100 200 | RespawnCount.between(51, 100) 201 | 202 | """ 203 | @callback between(min :: number, max :: number) :: [{id, number}] 204 | 205 | @doc """ 206 | Look up all components where the value is greater than or equal to `min`. 207 | 208 | This function only works for numerical component types (`:value` set to either 209 | `:integer` or `:float`). Other value types will raise `UndefinedFunctionError`. 210 | 211 | ## Example 212 | 213 | # Get all PlayerExperience components where value >= 2500 214 | PlayerExperience.at_least(2500) 215 | 216 | """ 217 | @callback at_least(min :: number) :: [{id, number}] 218 | 219 | @doc """ 220 | Look up all components where the value is less than or equal to `max`. 221 | 222 | This function only works for numerical component types (`:value` set to either 223 | `:integer` or `:float`). Other value types will raise `UndefinedFunctionError`. 224 | 225 | ## Example 226 | 227 | # Get all PlayerHealth components where value <= 10 228 | PlayerHealth.at_most(10) 229 | 230 | """ 231 | @callback at_most(max :: number) :: [{id, number}] 232 | 233 | @doc """ 234 | Removes this component type from an entity. 235 | """ 236 | @callback remove(entity :: id) :: :ok 237 | 238 | @doc """ 239 | Checks if an entity has a component of this type. 240 | """ 241 | @callback exists?(entity :: id) :: boolean 242 | 243 | @optional_callbacks between: 2, at_least: 1, at_most: 1 244 | end 245 | -------------------------------------------------------------------------------- /lib/ecsx/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.MultipleResultsError do 2 | defexception [:message] 3 | 4 | def exception(opts) do 5 | message = Keyword.fetch!(opts, :message) 6 | entity_id = Keyword.fetch!(opts, :entity_id) 7 | 8 | message = """ 9 | #{message} from entity #{entity_id} 10 | """ 11 | 12 | %__MODULE__{message: message} 13 | end 14 | end 15 | 16 | defmodule ECSx.NoResultsError do 17 | defexception [:message] 18 | 19 | def exception(opts) do 20 | message = Keyword.fetch!(opts, :message) 21 | entity_id = Keyword.fetch!(opts, :entity_id) 22 | 23 | message = """ 24 | #{message} from entity #{entity_id} 25 | """ 26 | 27 | %__MODULE__{message: message} 28 | end 29 | end 30 | 31 | defmodule ECSx.AlreadyExistsError do 32 | defexception [:message] 33 | 34 | def exception(opts) do 35 | message = Keyword.fetch!(opts, :message) 36 | entity_id = Keyword.fetch!(opts, :entity_id) 37 | 38 | message = """ 39 | #{message} from entity #{entity_id} 40 | """ 41 | 42 | %__MODULE__{message: message} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/ecsx/manager.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Manager do 2 | @moduledoc """ 3 | The Manager for your ECSx application. 4 | 5 | In an ECSx application, the Manager is responsible for: 6 | 7 | * starting up ETS tables for each Component Type, where the Components will be stored 8 | * prepopulating the game content into memory 9 | * keeping track of the Systems to run, and their run order 10 | * running the Systems every tick 11 | 12 | ## `components/0` and `systems/0` 13 | 14 | Your Manager module must contain two zero-arity functions called `components` and `systems` 15 | which return a list of all Component Types and Systems in your application. The order of 16 | the Component Types list is irrelevant, but the order of the Systems list is very important, 17 | because the Systems are run consecutively in the given order. 18 | 19 | ## `setup/0` and `startup/0` 20 | 21 | Manager modules may also implement two optional functions for loading all the necessary 22 | component data for your app before any Systems run or users connect. 23 | 24 | The `setup/0` function runs only *once*, when you start your app for the first time, while 25 | the `startup/0` function runs *every* time the app starts, including the first 26 | (after `setup/0` is run). The Manager uses the Persistence layer to determine if this 27 | is a fresh server or a subsequent start. 28 | 29 | These functions will be run during the Manager's initialization. The Component tables 30 | will be created before they are executed. 31 | 32 | ## Example 33 | 34 | ``` 35 | defmodule YourApp.Manager do 36 | use ECSx.Manager 37 | 38 | def setup do 39 | for tree <- YourApp.Map.trees() do 40 | YourApp.Components.XPosition.add(tree.id, tree.x_coord, persist: true) 41 | YourApp.Components.YPosition.add(tree.id, tree.y_coord, persist: true) 42 | YourApp.Components.Type.add(tree.id, "Tree", persist: true) 43 | end 44 | end 45 | 46 | def startup do 47 | for spawn_location <- YourApp.spawn_locations() do 48 | YourApp.Components.SpawnLocation.add(spawn_location.id) 49 | YourApp.Components.Type.add(spawn_location.id, spawn_location.type) 50 | YourApp.Components.XPosition.add(spawn_location.id, spawn_location.x_coord) 51 | YourApp.Components.YPosition.add(spawn_location.id, spawn_location.y_coord) 52 | end 53 | end 54 | end 55 | ``` 56 | """ 57 | 58 | defmacro __using__(_opts) do 59 | quote do 60 | use GenServer 61 | 62 | import ECSx.Manager 63 | 64 | @behaviour ECSx.Manager 65 | 66 | require Logger 67 | 68 | def setup, do: :ok 69 | def startup, do: :ok 70 | defoverridable setup: 0, startup: 0 71 | 72 | def start_link(_), do: ECSx.Manager.start_link(__MODULE__) 73 | 74 | def init(_) do 75 | Enum.each(components(), fn module -> module.init() end) 76 | Logger.info("Component tables initialized") 77 | 78 | {:ok, [], {:continue, :start_systems}} 79 | end 80 | 81 | def handle_continue(:start_systems, state) do 82 | case ECSx.Persistence.retrieve_components() do 83 | :ok -> 84 | Logger.info("Retrieved Components") 85 | startup() 86 | Logger.info("`startup/0` complete") 87 | 88 | {:error, :fresh_server} -> 89 | Logger.info("Fresh server detected") 90 | 91 | setup() 92 | Logger.info("`setup/0` complete") 93 | startup() 94 | Logger.info("`startup/0` complete") 95 | 96 | {:error, reason} -> 97 | Logger.warning("Failed to retrieve components: #{inspect(reason)}") 98 | setup() 99 | Logger.info("`setup/0` complete") 100 | startup() 101 | Logger.info("`startup/0` complete") 102 | end 103 | 104 | tick_interval = div(1000, ECSx.tick_rate()) 105 | :timer.send_interval(tick_interval, :tick) 106 | :timer.send_interval(ECSx.persist_interval(), :persist) 107 | 108 | {:noreply, state} 109 | end 110 | 111 | def handle_info(:tick, state) do 112 | Enum.each(systems(), fn system -> 113 | start_time = System.monotonic_time() 114 | system.run() 115 | duration = System.monotonic_time() - start_time 116 | measurements = %{duration: duration} 117 | metadata = %{system: system} 118 | :telemetry.execute([:ecsx, :system_run], measurements, metadata) 119 | end) 120 | 121 | {:noreply, state} 122 | end 123 | 124 | def handle_info(:persist, state) do 125 | ECSx.Persistence.persist_components() 126 | {:noreply, state} 127 | end 128 | end 129 | end 130 | 131 | @doc """ 132 | Loads component data for first app launch. 133 | 134 | This will run only once, the first time you start your app. It runs after component tables 135 | have been initialized, before any systems have started. 136 | 137 | Except for very rare circumstances, all components added here should have `persist: true` 138 | """ 139 | @callback setup() :: any() 140 | 141 | @doc """ 142 | Loads ephemeral component data each time the app is started. 143 | 144 | This will run on your app's first start (after `setup/0`) and then again during all subsequent 145 | app reboots. It runs after component tables have been initialized, before any systems have started. 146 | 147 | Except for very rare circumstances, components added here should *not* be persisted. 148 | """ 149 | @callback startup() :: any() 150 | 151 | @doc false 152 | def start_link(module) do 153 | GenServer.start_link(module, [], name: module) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/ecsx/persistence.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Persistence do 2 | @moduledoc false 3 | 4 | def persist_components(opts \\ []) do 5 | ECSx.manager().components() 6 | |> Enum.map(fn component_module -> 7 | {component_module, component_module.get_all_persist()} 8 | end) 9 | |> Enum.filter(&(length(elem(&1, 1)) > 0)) 10 | |> Map.new() 11 | |> ECSx.Persistence.Server.persist_components(opts) 12 | end 13 | 14 | def retrieve_components(opts \\ []) do 15 | case persistence_adapter().retrieve_components(opts) do 16 | {:ok, component_map} -> 17 | Enum.each(component_map, fn {component_module, components} -> 18 | Enum.each(components, &component_module.load/1) 19 | end) 20 | 21 | {:error, :fresh_server} -> 22 | {:error, :fresh_server} 23 | 24 | {:error, reason} -> 25 | {:error, reason} 26 | end 27 | end 28 | 29 | def persistence_adapter do 30 | Application.get_env(:ecsx, :persistence_adapter, ECSx.Persistence.FileAdapter) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ecsx/persistence/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Persistence.Behaviour do 2 | @moduledoc """ 3 | By default, ECSx persists component data by writing a binary file to disk, then reading the file 4 | when the server restarts. If you would like to use a different method, you can create a module 5 | which implements this behaviour, and update the ECSx configuration to use your module instead of 6 | the default. 7 | 8 | ## `persist_components/2` and `retrieve_components/1` 9 | 10 | To create your own persistence adapter, you only need to implement two functions: 11 | 12 | * `persist_components/2` - This function takes a map, where keys are component type modules, and 13 | values are lists of persistable components of that type. A keyword list of options is also be 14 | passed as a second argument. The function should store the data, then return `:ok`. 15 | * `retrieve_components/1` - This function takes a list of options, and should return 16 | `{:ok, component_map}` where `component_map` stores lists of component tuples as values, 17 | with the keys being the component type module corresponding to each list. 18 | 19 | ## Configuring ECSx to use a custom persistence adapter 20 | 21 | Once you have created a persistence adapter module, simply update your application config to use it: 22 | 23 | config :ecsx, 24 | ... 25 | persistence_adapter: MyAdapterModule 26 | 27 | """ 28 | 29 | @type components :: %{module() => list(tuple())} 30 | @callback persist_components(components :: components(), opts :: keyword()) :: :ok 31 | @callback retrieve_components(opts :: keyword()) :: 32 | {:ok, components()} | {:error, :fresh_server | any()} 33 | end 34 | -------------------------------------------------------------------------------- /lib/ecsx/persistence/file_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Persistence.FileAdapter do 2 | @moduledoc false 3 | 4 | @behaviour ECSx.Persistence.Behaviour 5 | 6 | @default_file_location "components.persistence" 7 | 8 | @impl ECSx.Persistence.Behaviour 9 | def persist_components(components, _opts \\ []) do 10 | bytes = :erlang.term_to_binary(components) 11 | File.write!(file_location(), bytes) 12 | end 13 | 14 | @impl ECSx.Persistence.Behaviour 15 | def retrieve_components(_opts \\ []) do 16 | file_location = file_location() 17 | 18 | with true <- File.exists?(file_location), 19 | {:ok, binary} <- File.read(file_location), 20 | component_map <- :erlang.binary_to_term(binary) do 21 | {:ok, component_map} 22 | else 23 | false -> {:error, :fresh_server} 24 | {:error, reason} -> {:error, reason} 25 | end 26 | rescue 27 | e -> {:error, e} 28 | end 29 | 30 | defp file_location do 31 | Application.get_env(:ecsx, :persistence_file_location, @default_file_location) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ecsx/persistence/server.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Persistence.Server do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def start_link(_) do 6 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 7 | end 8 | 9 | def persist_components(component_map, opts) do 10 | GenServer.cast(__MODULE__, {:persist, component_map, opts}) 11 | end 12 | 13 | @impl GenServer 14 | def init(:ok) do 15 | {:ok, %{}} 16 | end 17 | 18 | @impl GenServer 19 | def handle_cast({:persist, component_map, opts}, state) do 20 | ECSx.Persistence.persistence_adapter().persist_components(component_map, opts) 21 | {:noreply, state} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ecsx/system.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.System do 2 | @moduledoc """ 3 | A fragment of game logic which reads and updates Components. 4 | 5 | Each System must implement a `run/0` function, which will be called once per game tick. 6 | 7 | defmodule MyApp.FooSystem do 8 | @behaviour ECSx.System 9 | 10 | @impl ECSx.System 11 | def run do 12 | # System logic 13 | :ok 14 | end 15 | end 16 | 17 | """ 18 | 19 | @doc """ 20 | Invoked to run System logic. 21 | 22 | This function will be called every game tick. 23 | 24 | Note: A crash inside this function will restart the entire app! 25 | """ 26 | @callback run() :: any() 27 | end 28 | -------------------------------------------------------------------------------- /lib/ecsx/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Tag do 2 | @moduledoc """ 3 | A component type which does not require a value. This is useful when the mere presence or absence of a component is all the information we need. 4 | 5 | For example, if we want a component type to model a boolean attribute, such as whether or not players may target a particular entity, we'll use a Tag: 6 | 7 | defmodule MyApp.Components.Targetable do 8 | use ECSx.Tag 9 | end 10 | 11 | Then we can check for targetability with `...Targetable.exists?(entity)` or get a list of all targetable entities with `...Targetable.get_all()`. 12 | 13 | ### Options 14 | 15 | * `:read_concurrency` - when `true`, enables read concurrency for this component table. Only set this if you know what you're doing. Defaults to `false` 16 | 17 | """ 18 | 19 | @type id :: any 20 | 21 | defmacro __using__(opts) do 22 | quote bind_quoted: [opts: opts] do 23 | @behaviour ECSx.Tag 24 | 25 | @table_name __MODULE__ 26 | @concurrency {:read_concurrency, opts[:read_concurrency] || false} 27 | @tag_opts [log_edits: opts[:log_edits] || false] 28 | 29 | def init, do: ECSx.Base.init(@table_name, @concurrency, @tag_opts) 30 | 31 | def add(entity_id, opts \\ []), 32 | do: ECSx.Base.add(@table_name, entity_id, nil, Keyword.merge(opts, @tag_opts)) 33 | 34 | def load(component), do: ECSx.Base.load(@table_name, component) 35 | 36 | def get_all, do: ECSx.Base.get_all_keys(@table_name) 37 | 38 | def get_all_persist, do: ECSx.Base.get_all_persist(@table_name) 39 | 40 | def remove(entity_id), do: ECSx.Base.remove(@table_name, entity_id, @tag_opts) 41 | 42 | def exists?(entity_id), do: ECSx.Base.exists?(@table_name, entity_id) 43 | end 44 | end 45 | 46 | @doc """ 47 | Creates a new tag for a given entity. 48 | """ 49 | @callback add(entity :: id) :: :ok 50 | 51 | @doc """ 52 | Gets a list of all entities with this tag. 53 | """ 54 | @callback get_all() :: [id] 55 | 56 | @doc """ 57 | Removes this component from an entity. 58 | """ 59 | @callback remove(entity :: id) :: :ok 60 | 61 | @doc """ 62 | Checks if an entity has this tag. 63 | """ 64 | @callback exists?(entity :: id) :: boolean 65 | end 66 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecsx.gen.component.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecsx.Gen.Component do 2 | @shortdoc "Generates a new ECSx Component type" 3 | 4 | @moduledoc """ 5 | Generates a new Component type for an ECSx application. 6 | 7 | $ mix ecsx.gen.component Height integer 8 | 9 | The first argument is the name of the component, followed by the data type of the value. 10 | 11 | Valid types for the component's value are: 12 | 13 | * atom 14 | * binary 15 | * datetime 16 | * float 17 | * integer 18 | 19 | If you know you want components of this type to be indexed for improved `ECSx.Component.search/1` performance, 20 | you may include the `--index` option: 21 | 22 | $ mix ecsx.gen.component Name binary --index 23 | 24 | """ 25 | 26 | use Mix.Task 27 | 28 | alias Mix.Tasks.ECSx.Helpers 29 | 30 | @valid_value_types ~w(atom binary datetime float integer) 31 | 32 | @doc false 33 | def run([]) do 34 | "Invalid arguments." 35 | |> message_with_help() 36 | |> Mix.raise() 37 | end 38 | 39 | def run([_component_type]) do 40 | "Invalid arguments - must provide value type. If you don't want to store a value, try `mix ecsx.gen.tag`" 41 | |> message_with_help() 42 | |> Mix.raise() 43 | end 44 | 45 | def run([component_type_name, value_type | opts]) do 46 | value_type = validate(value_type) 47 | {opts, _, _} = OptionParser.parse(opts, strict: [index: :boolean]) 48 | Helpers.inject_component_module_into_manager(component_type_name) 49 | create_component_file(component_type_name, value_type, opts) 50 | end 51 | 52 | defp message_with_help(message) do 53 | """ 54 | #{message} 55 | 56 | mix ecsx.gen.component expects a component module name (in PascalCase), followed by a valid value type. 57 | 58 | For example: 59 | 60 | mix ecsx.gen.component MyComponentType binary 61 | 62 | """ 63 | end 64 | 65 | defp validate(type) when type in @valid_value_types, do: String.to_atom(type) 66 | 67 | defp validate(_), 68 | do: Mix.raise("Invalid value type. Possible types are: #{inspect(@valid_value_types)}") 69 | 70 | defp create_component_file(component_type_name, value_type, opts) do 71 | filename = Macro.underscore(component_type_name) 72 | target = "lib/#{Helpers.otp_app()}/components/#{filename}.ex" 73 | source = Application.app_dir(:ecsx, "/priv/templates/component.ex") 74 | 75 | binding = [ 76 | app_name: Helpers.root_module(), 77 | index: Keyword.get(opts, :index, false), 78 | component_type: component_type_name, 79 | value: value_type 80 | ] 81 | 82 | Mix.Generator.create_file(target, EEx.eval_file(source, binding)) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecsx.gen.system.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecsx.Gen.System do 2 | @shortdoc "Generates a new ECSx System" 3 | 4 | @moduledoc """ 5 | Generates a new System for an ECSx application. 6 | 7 | $ mix ecsx.gen.system Foo 8 | 9 | The only argument accepted is a module name for the System. 10 | """ 11 | 12 | use Mix.Task 13 | 14 | alias Mix.Tasks.ECSx.Helpers 15 | 16 | @doc false 17 | def run([]) do 18 | Mix.raise(""" 19 | Missing argument. 20 | 21 | mix ecsx.gen.system expects a system module name (in PascalCase). 22 | 23 | For example: 24 | 25 | mix ecsx.gen.system MySystem 26 | 27 | """) 28 | end 29 | 30 | def run([system_name | _] = _args) do 31 | inject_system_module_into_manager(system_name) 32 | create_system_file(system_name) 33 | end 34 | 35 | defp create_system_file(system_name) do 36 | filename = Macro.underscore(system_name) 37 | target = "lib/#{Helpers.otp_app()}/systems/#{filename}.ex" 38 | source = Application.app_dir(:ecsx, "/priv/templates/system.ex") 39 | binding = [app_name: Helpers.root_module(), system_name: system_name] 40 | 41 | Mix.Generator.create_file(target, EEx.eval_file(source, binding)) 42 | end 43 | 44 | defp inject_system_module_into_manager(system_name) do 45 | manager_path = ECSx.manager_path() 46 | {before_systems, after_systems, list} = parse_manager(manager_path) 47 | 48 | new_list = 49 | system_name 50 | |> add_system_to_list(list) 51 | |> ensure_list_format() 52 | 53 | new_contents = 54 | [before_systems, "def systems do\n ", new_list, "\n end\n", after_systems] 55 | |> IO.iodata_to_binary() 56 | |> Code.format_string!() 57 | 58 | Mix.shell().info([:green, "* injecting ", :reset, manager_path]) 59 | File.write!(manager_path, [new_contents, "\n"]) 60 | end 61 | 62 | defp parse_manager(path) do 63 | file = Helpers.read_manager_file!(path) 64 | [top, rest] = String.split(file, "def systems do", parts: 2) 65 | [list, bottom] = String.split(rest, ~r"\send\n", parts: 2) 66 | 67 | {top, bottom, list} 68 | end 69 | 70 | defp add_system_to_list(system_name, list_as_string) do 71 | {result, _binding} = Code.eval_string(list_as_string) 72 | 73 | system_name 74 | |> full_system_module() 75 | |> then(&[&1 | result]) 76 | |> inspect() 77 | end 78 | 79 | defp full_system_module(system_name) do 80 | Module.concat([Helpers.root_module(), "Systems", system_name]) 81 | end 82 | 83 | # Adds a newline to ensure the list is formatted with one system per line 84 | defp ensure_list_format(list_as_string) do 85 | ["[" | rest] = String.graphemes(list_as_string) 86 | 87 | ["[\n" | rest] 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecsx.gen.tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecsx.Gen.Tag do 2 | @shortdoc "Generates a new ECSx Tag - a Component type which doesn't store any value" 3 | 4 | @moduledoc """ 5 | Generates a new ECSx Tag - a Component type which doesn't store any value. 6 | 7 | $ mix ecsx.gen.tag Attackable 8 | 9 | The single argument is the name of the component. 10 | """ 11 | 12 | use Mix.Task 13 | 14 | alias Mix.Tasks.ECSx.Helpers 15 | 16 | @doc false 17 | def run([]) do 18 | "Invalid arguments." 19 | |> message_with_help() 20 | |> Mix.raise() 21 | end 22 | 23 | def run([tag_name | _]) do 24 | Helpers.inject_component_module_into_manager(tag_name) 25 | create_component_file(tag_name) 26 | end 27 | 28 | defp message_with_help(message) do 29 | """ 30 | #{message} 31 | 32 | mix ecsx.gen.tag expects a tag module name (in PascalCase). 33 | 34 | For example: 35 | 36 | mix ecsx.gen.tag MyTag 37 | 38 | """ 39 | end 40 | 41 | defp create_component_file(tag_name) do 42 | filename = Macro.underscore(tag_name) 43 | target = "lib/#{Helpers.otp_app()}/components/#{filename}.ex" 44 | source = Application.app_dir(:ecsx, "/priv/templates/tag.ex") 45 | binding = [app_name: Helpers.root_module(), tag_name: tag_name] 46 | 47 | Mix.Generator.create_file(target, EEx.eval_file(source, binding)) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecsx.setup.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecsx.Setup do 2 | @shortdoc "Generates manager process for ECSx" 3 | 4 | @moduledoc """ 5 | Generates the Manager process which runs an ECSx application. 6 | 7 | $ mix ecsx.setup 8 | 9 | This setup will generate `manager.ex` and empty folders for components and systems. 10 | 11 | If you don't want to generate the folders, you can provide option `--no-folders` 12 | """ 13 | 14 | use Mix.Task 15 | 16 | import Mix.Generator 17 | 18 | alias Mix.Tasks.ECSx.Helpers 19 | 20 | @components_list "[\n # MyApp.Components.SampleComponent\n ]" 21 | @systems_list "[\n # MyApp.Systems.SampleSystem\n ]" 22 | 23 | @doc false 24 | def run(args) do 25 | {opts, _, _} = OptionParser.parse(args, strict: [folders: :boolean]) 26 | 27 | create_manager() 28 | 29 | inject_config() 30 | 31 | if Keyword.get(opts, :folders, true), 32 | do: create_folders() 33 | 34 | Mix.shell().info("ECSx setup complete!") 35 | end 36 | 37 | defp create_manager do 38 | target = "lib/#{Helpers.otp_app()}/manager.ex" 39 | source = Application.app_dir(:ecsx, "/priv/templates/manager.ex") 40 | 41 | binding = [ 42 | app_name: Helpers.root_module(), 43 | components_list: @components_list, 44 | systems_list: @systems_list 45 | ] 46 | 47 | create_file(target, EEx.eval_file(source, binding)) 48 | end 49 | 50 | defp inject_config do 51 | config = Mix.Project.config() 52 | config_path = config[:config_path] || "config/config.exs" 53 | opts = [root_module: Helpers.root_module()] 54 | 55 | case File.read(config_path) do 56 | {:ok, file} -> 57 | [header | chunks] = String.split(file, "\n\n") 58 | header = String.trim(header) 59 | chunks = List.insert_at(chunks, -2, config_template(opts)) 60 | new_contents = Enum.join([header | chunks], "\n\n") 61 | 62 | Mix.shell().info([:green, "* injecting ", :reset, config_path]) 63 | File.write(config_path, String.trim(new_contents) <> "\n") 64 | 65 | {:error, _} -> 66 | create_file(config_path, "import Config\n\n" <> config_template(opts) <> "\n") 67 | end 68 | end 69 | 70 | defp create_folders do 71 | otp_app = Helpers.otp_app() 72 | create_directory("lib/#{otp_app}/components") 73 | create_directory("lib/#{otp_app}/systems") 74 | end 75 | 76 | embed_template( 77 | :config, 78 | "config :ecsx,\n tick_rate: 20,\n manager: <%= @root_module %>.Manager" 79 | ) 80 | end 81 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecsx/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ECSx.Helpers do 2 | @moduledoc false 3 | 4 | def otp_app do 5 | Mix.Project.config() 6 | |> Keyword.fetch!(:app) 7 | end 8 | 9 | def root_module do 10 | config = Mix.Project.config() 11 | 12 | case Keyword.get(config, :name) do 13 | nil -> config |> Keyword.fetch!(:app) |> root_module() 14 | name -> name 15 | end 16 | end 17 | 18 | defp root_module(otp_app) do 19 | otp_app 20 | |> to_string() 21 | |> Macro.camelize() 22 | |> List.wrap() 23 | |> Module.concat() 24 | |> inspect() 25 | end 26 | 27 | def write_file(contents, path), do: File.write!(path, contents) 28 | 29 | def inject_component_module_into_manager(component_type) do 30 | manager_path = ECSx.manager_path() 31 | {before_components, after_components, list} = parse_manager_components(manager_path) 32 | 33 | new_list = 34 | component_type 35 | |> add_component_to_list(list) 36 | |> ensure_list_format() 37 | 38 | new_contents = 39 | [before_components, "def components do\n ", new_list, "\n end\n", after_components] 40 | |> IO.iodata_to_binary() 41 | |> Code.format_string!() 42 | 43 | Mix.shell().info([:green, "* injecting ", :reset, manager_path]) 44 | File.write!(manager_path, [new_contents, "\n"]) 45 | end 46 | 47 | defp parse_manager_components(path) do 48 | file = read_manager_file!(path) 49 | [top, rest] = String.split(file, "def components do", parts: 2) 50 | [list, bottom] = String.split(rest, ~r"\send\n", parts: 2) 51 | 52 | {top, bottom, list} 53 | end 54 | 55 | defp add_component_to_list(component_type, list_as_string) do 56 | {result, _binding} = Code.eval_string(list_as_string) 57 | 58 | component_type 59 | |> full_component_module() 60 | |> then(&[&1 | result]) 61 | |> inspect() 62 | end 63 | 64 | defp full_component_module(component_type) do 65 | Module.concat([root_module(), "Components", component_type]) 66 | end 67 | 68 | # Adds a newline to ensure the list is formatted with one component per line 69 | defp ensure_list_format(list_as_string) do 70 | ["[" | rest] = String.graphemes(list_as_string) 71 | 72 | ["[\n" | rest] 73 | end 74 | 75 | def read_manager_file!(path) do 76 | case File.read(path) do 77 | {:ok, file} -> 78 | file 79 | 80 | {:error, :enoent} -> 81 | Mix.raise(""" 82 | ECSx manager missing - please run `mix ecsx.setup` first! 83 | If you've already run the setup but moved or renamed your 84 | manager file, you might need to configure the path: 85 | 86 | config :ecsx, manager: {ManagerModule, path: "path/to/manager.ex"} 87 | """) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.MixProject do 2 | use Mix.Project 3 | 4 | @gh_url "https://github.com/ecsx-framework/ECSx" 5 | @version "0.5.2" 6 | 7 | def project do 8 | [ 9 | app: :ecsx, 10 | version: @version, 11 | elixir: "~> 1.13", 12 | deps: deps(), 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [ 15 | coveralls: :test, 16 | "coveralls.detail": :test, 17 | "coveralls.post": :test, 18 | "coveralls.html": :test, 19 | coverage_report: :test 20 | ], 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | start_permanent: Mix.env() == :prod, 23 | 24 | # Hex 25 | description: "An Entity-Component-System framework for Elixir", 26 | package: package(), 27 | 28 | # Docs 29 | name: "ECSx", 30 | docs: docs(), 31 | aliases: aliases() 32 | ] 33 | end 34 | 35 | # Run "mix help compile.app" to learn about applications. 36 | def application do 37 | [ 38 | mod: {ECSx, []}, 39 | extra_applications: [:logger, :eex, :mix] 40 | ] 41 | end 42 | 43 | # Run "mix help deps" to learn about dependencies. 44 | defp deps do 45 | [ 46 | {:ex_doc, "~> 0.36", only: :dev, runtime: false}, 47 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 48 | {:excoveralls, "~> 0.14", only: :test}, 49 | {:telemetry, "~> 1.0"}, 50 | {:mix_test_watch, "~> 1.1", only: [:dev], runtime: false} 51 | ] 52 | end 53 | 54 | # Specifies which paths to compile per environment. 55 | defp elixirc_paths(:test), do: ["lib", "test/support"] 56 | defp elixirc_paths(_), do: ["lib"] 57 | 58 | def aliases do 59 | [ 60 | coverage_report: [&coverage_report/1] 61 | ] 62 | end 63 | 64 | defp package do 65 | [ 66 | maintainers: ["Andrew P Berrien", "Mike Binns"], 67 | licenses: ["GPL-3.0"], 68 | links: %{ 69 | "Changelog" => "#{@gh_url}/blob/master/CHANGELOG.md", 70 | "GitHub" => @gh_url 71 | } 72 | ] 73 | end 74 | 75 | defp docs do 76 | [ 77 | main: "ECSx", 78 | source_ref: "v#{@version}", 79 | logo: nil, 80 | extra_section: "GUIDES", 81 | source_url: @gh_url, 82 | extras: extras(), 83 | groups_for_extras: groups_for_extras() 84 | ] 85 | end 86 | 87 | defp extras do 88 | [ 89 | "guides/installation.md", 90 | "CHANGELOG.md", 91 | "guides/upgrade_guide.md", 92 | "guides/ecs_design.md", 93 | "guides/common_caveats.md", 94 | "guides/tutorial/initial_setup.md", 95 | "guides/tutorial/backend_basics.md", 96 | "guides/tutorial/web_frontend_liveview.md" 97 | ] 98 | end 99 | 100 | defp coverage_report(_) do 101 | Mix.Task.run("coveralls.html") 102 | 103 | open_cmd = 104 | case :os.type() do 105 | {:win32, _} -> 106 | "start" 107 | 108 | {:unix, :darwin} -> 109 | "open" 110 | 111 | {:unix, _} -> 112 | "xdg-open" 113 | end 114 | 115 | System.cmd(open_cmd, ["cover/excoveralls.html"]) 116 | end 117 | 118 | defp groups_for_extras do 119 | [ 120 | "Tutorial Project": ~r/guides\/tutorial\/.?/ 121 | ] 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 5 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 9 | "excoveralls": {:hex, :excoveralls, "0.15.3", "54bb54043e1cf5fe431eb3db36b25e8fd62cf3976666bafe491e3fa5e29eba47", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8eb5d8134d84c327685f7bb8f1db4147f1363c3c9533928234e496e3070114e"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 12 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 13 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 14 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 23 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 24 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 26 | } 27 | -------------------------------------------------------------------------------- /priv/templates/component.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= app_name %>.Components.<%= component_type %> do 2 | @moduledoc """ 3 | Documentation for <%= component_type %> components. 4 | """ 5 | use ECSx.Component, 6 | value: <%= inspect(value) %><%= if index, do: ",\n index: true", else: "" %> 7 | end 8 | -------------------------------------------------------------------------------- /priv/templates/manager.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= app_name %>.Manager do 2 | @moduledoc """ 3 | ECSx manager. 4 | """ 5 | use ECSx.Manager 6 | 7 | def setup do 8 | # Seed persistent components only for the first server start 9 | # (This will not be run on subsequent app restarts) 10 | :ok 11 | end 12 | 13 | def startup do 14 | # Load ephemeral components during first server start and again 15 | # on every subsequent app restart 16 | :ok 17 | end 18 | 19 | # Declare all valid Component types 20 | def components do 21 | <%= components_list %> 22 | end 23 | 24 | # Declare all Systems to run 25 | def systems do 26 | <%= systems_list %> 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/templates/system.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= app_name %>.Systems.<%= system_name %> do 2 | @moduledoc """ 3 | Documentation for <%= system_name %> system. 4 | """ 5 | @behaviour ECSx.System 6 | 7 | @impl ECSx.System 8 | def run do 9 | # System logic 10 | :ok 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/templates/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= app_name %>.Components.<%= tag_name %> do 2 | @moduledoc """ 3 | Documentation for <%= tag_name %> components. 4 | """ 5 | use ECSx.Tag 6 | end 7 | -------------------------------------------------------------------------------- /test/ecsx/base_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.BaseTest do 2 | use ExUnit.Case 3 | 4 | alias ECSx.Base 5 | 6 | setup do 7 | table_name = :sample_component 8 | :ets.new(table_name, [:named_table]) 9 | 10 | index_table = Module.concat(table_name, "Index") 11 | :ets.new(index_table, [:named_table, :bag]) 12 | 13 | :ok 14 | end 15 | 16 | describe "#add/4" do 17 | test "successful" do 18 | Base.add(:sample_component, 123, "test", []) 19 | 20 | assert :ets.lookup(:sample_component, 123) == [{123, "test", false}] 21 | end 22 | 23 | test "raises when already exists" do 24 | :ets.insert(:sample_component, {123, "test", false}) 25 | 26 | assert_raise ECSx.AlreadyExistsError, 27 | "`add` expects component to not exist yet from entity 123\n", 28 | fn -> 29 | Base.add(:sample_component, 123, "test", []) 30 | end 31 | end 32 | 33 | test "with index" do 34 | assert :ok == Base.add(:sample_component, 123, "test", index: true) 35 | 36 | index_table = Module.concat(:sample_component, "Index") 37 | 38 | assert :ets.tab2list(index_table) == [{"test", 123, false}] 39 | end 40 | end 41 | 42 | describe "#update/4" do 43 | test "successful" do 44 | :ets.insert(:sample_component, {123, "test", false}) 45 | Base.update(:sample_component, 123, "test2", []) 46 | assert [{123, "test2", false}] == :ets.tab2list(:sample_component) 47 | end 48 | 49 | test "raises when doesn't exist" do 50 | assert_raise ECSx.NoResultsError, 51 | "`update` expects an existing value from entity 123\n", 52 | fn -> 53 | Base.update(:sample_component, 123, "test2", []) 54 | end 55 | end 56 | 57 | test "with index" do 58 | :ets.insert(:sample_component, {123, "test", false}) 59 | index_table = Module.concat(:sample_component, "Index") 60 | :ets.insert(index_table, {"test", 123, false}) 61 | 62 | assert :ok == Base.update(:sample_component, 123, "test2", index: true) 63 | assert :ets.tab2list(index_table) == [{"test2", 123, false}] 64 | end 65 | end 66 | 67 | describe "#get/2" do 68 | test "when component exists" do 69 | :ets.insert(:sample_component, {123, "shazam"}) 70 | 71 | assert Base.get(:sample_component, 123, []) == "shazam" 72 | end 73 | 74 | test "returns default when component does not exist" do 75 | assert Base.get(:sample_component, 123, :some_val) == :some_val 76 | end 77 | 78 | test "raises when component does not exist" do 79 | assert_raise ECSx.NoResultsError, 80 | "`get` expects one result, got 0 from entity 123\n", 81 | fn -> Base.get(:sample_component, 123, :raise) end 82 | end 83 | end 84 | 85 | describe "#get_all/1" do 86 | test "when components exist" do 87 | :ets.insert(:sample_component, {123, "foo"}) 88 | :ets.insert(:sample_component, {456, "bar"}) 89 | 90 | assert Base.get_all(:sample_component) |> Enum.sort() == [{123, "foo"}, {456, "bar"}] 91 | end 92 | 93 | test "for zero components" do 94 | assert Base.get_all(:sample_component) == [] 95 | end 96 | end 97 | 98 | describe "#between/3" do 99 | test "integers" do 100 | :ets.insert(:sample_component, {123, 1, false}) 101 | :ets.insert(:sample_component, {234, 2, false}) 102 | :ets.insert(:sample_component, {345, 3, true}) 103 | 104 | assert :sample_component 105 | |> Base.between(2, 3) 106 | |> Enum.sort() == [{234, 2}, {345, 3}] 107 | end 108 | end 109 | 110 | describe "#at_least/2" do 111 | test "integers" do 112 | :ets.insert(:sample_component, {123, 1, true}) 113 | :ets.insert(:sample_component, {234, 2, true}) 114 | :ets.insert(:sample_component, {345, 3, false}) 115 | 116 | assert :sample_component 117 | |> Base.at_least(2) 118 | |> Enum.sort() == [{234, 2}, {345, 3}] 119 | end 120 | end 121 | 122 | describe "#at_most/2" do 123 | test "integers" do 124 | :ets.insert(:sample_component, {123, 1, true}) 125 | :ets.insert(:sample_component, {234, 2, true}) 126 | :ets.insert(:sample_component, {345, 3, true}) 127 | 128 | assert :sample_component 129 | |> Base.at_most(2) 130 | |> Enum.sort() == [{123, 1}, {234, 2}] 131 | end 132 | end 133 | 134 | describe "#remove/2" do 135 | test "test" do 136 | :ets.insert(:sample_component, {123, "uno", false}) 137 | :ets.insert(:sample_component, {456, "dos", false}) 138 | 139 | Base.remove(:sample_component, 123, []) 140 | 141 | assert :ets.lookup(:sample_component, 123) == [] 142 | assert :ets.lookup(:sample_component, 456) == [{456, "dos", false}] 143 | end 144 | 145 | test "with index" do 146 | index_table = Module.concat(:sample_component, "Index") 147 | 148 | :ets.insert(:sample_component, {123, "uno", false}) 149 | :ets.insert(index_table, {"uno", 123, false}) 150 | 151 | :ets.insert(:sample_component, {456, "dos", false}) 152 | :ets.insert(index_table, {"dos", 456, false}) 153 | 154 | Base.remove(:sample_component, 123, index: true) 155 | 156 | assert :ets.tab2list(index_table) == [{"dos", 456, false}] 157 | end 158 | end 159 | 160 | describe "#exists?/2" do 161 | test "test" do 162 | :ets.insert(:sample_component, {123, "test", false}) 163 | 164 | assert Base.exists?(:sample_component, 123) 165 | refute Base.exists?(:sample_component, 456) 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/ecsx/client_events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.ClientEventsTest do 2 | use ExUnit.Case 3 | 4 | alias ECSx.ClientEvents 5 | 6 | test "add" do 7 | entity = "123" 8 | assert {:noreply, [{entity, "a"}]} == ClientEvents.handle_cast({:add, entity, "a"}, []) 9 | 10 | assert {:noreply, [{entity, "Z"}, {entity, "a"}]} == 11 | ClientEvents.handle_cast({:add, entity, "Z"}, [{entity, "a"}]) 12 | end 13 | 14 | test "get_and_clear" do 15 | state = [{"123", "C"}, {"123", "B"}, {"456", "A"}] 16 | 17 | assert {:reply, Enum.reverse(state), []} == 18 | ClientEvents.handle_call(:get_and_clear, self(), state) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/ecsx/component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.ComponentTest do 2 | use ExUnit.Case 3 | 4 | alias ECSx.IntegerComponent 5 | alias ECSx.StringComponent 6 | 7 | describe "__using__" do 8 | test "generates functions for a component type" do 9 | assert :ok == StringComponent.init() 10 | 11 | assert :ok == StringComponent.add(11, "Andy") 12 | 13 | assert "Andy" == StringComponent.get(11) 14 | 15 | for {id, foo} <- [{1, "A"}, {2, "B"}, {3, "C"}], 16 | do: StringComponent.add(id, foo) 17 | 18 | assert :ok == StringComponent.remove(3) 19 | 20 | refute StringComponent.exists?(3) 21 | 22 | assert StringComponent.exists?(1) 23 | assert StringComponent.exists?(2) 24 | assert StringComponent.exists?(11) 25 | 26 | all_components = StringComponent.get_all() 27 | 28 | assert Enum.sort(all_components) == [ 29 | {1, "A"}, 30 | {2, "B"}, 31 | {11, "Andy"} 32 | ] 33 | end 34 | 35 | defmodule BadReadConcurrency do 36 | use ECSx.Component, 37 | value: :integer, 38 | read_concurrency: :invalid 39 | end 40 | 41 | test "invalid options are passed" do 42 | assert_raise ArgumentError, fn -> 43 | BadReadConcurrency.init() 44 | end 45 | end 46 | end 47 | 48 | describe "#between/2" do 49 | test "exists for integer component type" do 50 | Code.ensure_loaded(IntegerComponent) 51 | assert function_exported?(IntegerComponent, :between, 2) 52 | end 53 | 54 | test "does not exist for non-numerical component types" do 55 | Code.ensure_loaded(StringComponent) 56 | refute function_exported?(StringComponent, :between, 2) 57 | end 58 | 59 | test "arguments must be numerical" do 60 | Code.ensure_loaded(IntegerComponent) 61 | assert_raise FunctionClauseError, fn -> IntegerComponent.between(0, "five") end 62 | assert_raise FunctionClauseError, fn -> IntegerComponent.between(:zero, 5) end 63 | end 64 | end 65 | 66 | describe "#at_least/1" do 67 | test "exists for integer component type" do 68 | Code.ensure_loaded(IntegerComponent) 69 | assert function_exported?(IntegerComponent, :at_least, 1) 70 | end 71 | 72 | test "does not exist for non-numerical component types" do 73 | Code.ensure_loaded(StringComponent) 74 | refute function_exported?(StringComponent, :at_least, 1) 75 | end 76 | 77 | test "argument must be numerical" do 78 | Code.ensure_loaded(IntegerComponent) 79 | assert_raise FunctionClauseError, fn -> IntegerComponent.at_least("five") end 80 | assert_raise FunctionClauseError, fn -> IntegerComponent.at_least(:five) end 81 | end 82 | end 83 | 84 | describe "#at_most/1" do 85 | test "exists for integer component type" do 86 | Code.ensure_loaded(IntegerComponent) 87 | assert function_exported?(IntegerComponent, :at_most, 1) 88 | end 89 | 90 | test "does not exist for non-numerical component types" do 91 | Code.ensure_loaded(StringComponent) 92 | refute function_exported?(StringComponent, :at_most, 1) 93 | end 94 | 95 | test "argument must be numerical" do 96 | Code.ensure_loaded(IntegerComponent) 97 | assert_raise FunctionClauseError, fn -> IntegerComponent.at_most("five") end 98 | assert_raise FunctionClauseError, fn -> IntegerComponent.at_most(:five) end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/ecsx/ecsx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSxTest do 2 | use ExUnit.Case, async: false 3 | 4 | describe "manager/0" do 5 | test "standard module" do 6 | Application.put_env(:ecsx, :manager, FooApp.BarManager) 7 | 8 | assert ECSx.manager() == FooApp.BarManager 9 | end 10 | 11 | test "module with path" do 12 | Application.put_env(:ecsx, :manager, {FooApp.BarManager, path: "foo/bar/baz.ex"}) 13 | 14 | assert ECSx.manager() == FooApp.BarManager 15 | end 16 | 17 | test "unconfigured" do 18 | Application.delete_env(:ecsx, :manager) 19 | 20 | assert ECSx.manager() == nil 21 | end 22 | end 23 | 24 | describe "manager_path/0" do 25 | test "standard module" do 26 | Application.put_env(:ecsx, :manager, FooApp.BarManager) 27 | 28 | assert ECSx.manager_path() == "lib/foo_app/bar_manager.ex" 29 | end 30 | 31 | test "module with path" do 32 | Application.put_env(:ecsx, :manager, {FooApp.BarManager, path: "foo/bar/baz.ex"}) 33 | 34 | assert ECSx.manager_path() == "foo/bar/baz.ex" 35 | end 36 | 37 | test "unconfigured" do 38 | Application.delete_env(:ecsx, :manager) 39 | 40 | assert ECSx.manager_path() == nil 41 | end 42 | end 43 | 44 | describe "tick_rate/0" do 45 | test "fetches from app config" do 46 | Application.put_env(:ecsx, :tick_rate, 101) 47 | 48 | assert ECSx.tick_rate() == 101 49 | end 50 | 51 | test "defaults to 20 when unconfigured" do 52 | Application.delete_env(:ecsx, :tick_rate) 53 | 54 | assert ECSx.tick_rate() == 20 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/ecsx/manager_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.ManagerTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | defmodule AppToSetup do 7 | use ECSx.Manager 8 | 9 | def setup do 10 | send(self(), :setup) 11 | :ok 12 | end 13 | 14 | def startup do 15 | send(self(), :startup) 16 | :ok 17 | end 18 | 19 | def components, do: [] 20 | def systems, do: [] 21 | end 22 | 23 | describe "setup/1" do 24 | test "handle_continue/2 runs startup code block" do 25 | {result, log} = 26 | with_log(fn -> 27 | AppToSetup.handle_continue(:start_systems, "state") 28 | end) 29 | 30 | assert result == {:noreply, "state"} 31 | assert log =~ "[info] Retrieved Components" 32 | assert_receive :startup 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/ecsx/persistence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.PersistenceTest do 2 | use ExUnit.Case, async: false 3 | 4 | describe "#persist_components/1" do 5 | test "persists all components tagged with persist: true" do 6 | Application.put_env(:ecsx, :manager, ECSx.MockManager) 7 | ECSx.MockComponent1.init() 8 | ECSx.MockComponent2.init() 9 | :ets.insert(ECSx.MockComponent1, {123, "foo", true}) 10 | :ets.insert(ECSx.MockComponent1, {234, "bar", false}) 11 | :ets.insert(ECSx.MockComponent2, {345, "baz", true}) 12 | :ets.insert(ECSx.MockComponent2, {456, "foobaz", false}) 13 | ECSx.Persistence.persist_components(target: self()) 14 | 15 | assert_receive {:persist_components, 16 | %{ 17 | ECSx.MockComponent1 => [{123, "foo", true}], 18 | ECSx.MockComponent2 => [{345, "baz", true}] 19 | }} 20 | end 21 | end 22 | 23 | describe "#retrieve_components/1" do 24 | Application.put_env(:ecsx, :manager, ECSx.MockManager) 25 | ECSx.MockComponent1.init() 26 | ECSx.MockComponent2.init() 27 | 28 | ECSx.Persistence.retrieve_components( 29 | test_components: %{ 30 | ECSx.MockComponent1 => [{123, "foo", true}], 31 | ECSx.MockComponent2 => [{345, "baz", true}] 32 | } 33 | ) 34 | 35 | assert ECSx.MockComponent1.get_all() == [{123, "foo"}] 36 | assert ECSx.MockComponent2.get_all() == [{345, "baz"}] 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/ecsx/system_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ECSx.SystemTest do 2 | use ExUnit.Case 3 | 4 | alias ECSx.IntegerComponent 5 | 6 | defmodule Incrementer do 7 | @behaviour ECSx.System 8 | 9 | @impl ECSx.System 10 | def run do 11 | for {id, value} <- IntegerComponent.get_all() do 12 | IntegerComponent.remove(id) 13 | IntegerComponent.add(id, value + 1) 14 | end 15 | end 16 | end 17 | 18 | setup do 19 | IntegerComponent.init() 20 | IntegerComponent.add(1, 1) 21 | IntegerComponent.add(100, 100) 22 | end 23 | 24 | describe "Incrementer system" do 25 | test "#run/0" do 26 | Incrementer.run() 27 | 28 | assert :ets.lookup(IntegerComponent, 1) == [{1, 2, false}] 29 | assert :ets.lookup(IntegerComponent, 100) == [{100, 101, false}] 30 | 31 | Incrementer.run() 32 | 33 | assert :ets.lookup(IntegerComponent, 1) == [{1, 3, false}] 34 | assert :ets.lookup(IntegerComponent, 100) == [{100, 102, false}] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/mix/tasks/ecsx.gen.component_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../support/mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Ecsx.Gen.ComponentTest do 4 | use ExUnit.Case 5 | 6 | import ECSx.MixHelper 7 | 8 | setup do 9 | create_sample_ecsx_project() 10 | on_exit(&clean_tmp_dir/0) 11 | :ok 12 | end 13 | 14 | test "generates component type module" do 15 | Mix.Project.in_project(:my_app, ".", fn _module -> 16 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent", "binary"]) 17 | 18 | component_file = File.read!("lib/my_app/components/foo_component.ex") 19 | 20 | assert component_file == 21 | """ 22 | defmodule MyApp.Components.FooComponent do 23 | @moduledoc \"\"\" 24 | Documentation for FooComponent components. 25 | \"\"\" 26 | use ECSx.Component, 27 | value: :binary 28 | end 29 | """ 30 | end) 31 | end 32 | 33 | test "injects component type into manager" do 34 | Mix.Project.in_project(:my_app, ".", fn _module -> 35 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent", "integer"]) 36 | 37 | manager_file = File.read!("lib/my_app/manager.ex") 38 | 39 | assert manager_file == 40 | """ 41 | defmodule MyApp.Manager do 42 | @moduledoc \"\"\" 43 | ECSx manager. 44 | \"\"\" 45 | use ECSx.Manager 46 | 47 | def setup do 48 | # Seed persistent components only for the first server start 49 | # (This will not be run on subsequent app restarts) 50 | :ok 51 | end 52 | 53 | def startup do 54 | # Load ephemeral components during first server start and again 55 | # on every subsequent app restart 56 | :ok 57 | end 58 | 59 | # Declare all valid Component types 60 | def components do 61 | [ 62 | MyApp.Components.FooComponent 63 | ] 64 | end 65 | 66 | # Declare all Systems to run 67 | def systems do 68 | [ 69 | # MyApp.Systems.SampleSystem 70 | ] 71 | end 72 | end 73 | """ 74 | end) 75 | end 76 | 77 | test "multiple component types injected into manager" do 78 | Mix.Project.in_project(:my_app, ".", fn _module -> 79 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent", "binary"]) 80 | Mix.Tasks.Ecsx.Gen.Component.run(["BarComponent", "integer"]) 81 | Mix.Tasks.Ecsx.Gen.Component.run(["BazComponent", "float"]) 82 | 83 | manager_file = File.read!("lib/my_app/manager.ex") 84 | 85 | assert manager_file == 86 | """ 87 | defmodule MyApp.Manager do 88 | @moduledoc \"\"\" 89 | ECSx manager. 90 | \"\"\" 91 | use ECSx.Manager 92 | 93 | def setup do 94 | # Seed persistent components only for the first server start 95 | # (This will not be run on subsequent app restarts) 96 | :ok 97 | end 98 | 99 | def startup do 100 | # Load ephemeral components during first server start and again 101 | # on every subsequent app restart 102 | :ok 103 | end 104 | 105 | # Declare all valid Component types 106 | def components do 107 | [ 108 | MyApp.Components.BazComponent, 109 | MyApp.Components.BarComponent, 110 | MyApp.Components.FooComponent 111 | ] 112 | end 113 | 114 | # Declare all Systems to run 115 | def systems do 116 | [ 117 | # MyApp.Systems.SampleSystem 118 | ] 119 | end 120 | end 121 | """ 122 | end) 123 | end 124 | 125 | test "accepts index option" do 126 | Mix.Project.in_project(:my_app, ".", fn _module -> 127 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent", "binary", "--index"]) 128 | 129 | component_file = File.read!("lib/my_app/components/foo_component.ex") 130 | 131 | assert component_file == 132 | """ 133 | defmodule MyApp.Components.FooComponent do 134 | @moduledoc \"\"\" 135 | Documentation for FooComponent components. 136 | \"\"\" 137 | use ECSx.Component, 138 | value: :binary, 139 | index: true 140 | end 141 | """ 142 | end) 143 | end 144 | 145 | test "fails with invalid arguments" do 146 | Mix.Project.in_project(:my_app, ".", fn _module -> 147 | # Missing argument 148 | assert_raise(Mix.Error, fn -> 149 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent"]) 150 | end) 151 | 152 | # No arguments 153 | assert_raise(Mix.Error, fn -> 154 | Mix.Tasks.Ecsx.Gen.Component.run([]) 155 | end) 156 | 157 | # Bad value type 158 | assert_raise(Mix.Error, fn -> 159 | Mix.Tasks.Ecsx.Gen.Component.run(["FooComponent", "invalid"]) 160 | end) 161 | end) 162 | end 163 | 164 | test "handles component types with 'end' in the name" do 165 | Mix.Project.in_project(:my_app, ".", fn _module -> 166 | Mix.Tasks.Ecsx.Gen.Component.run(["LegendaryComponent", "binary"]) 167 | Mix.Tasks.Ecsx.Gen.Component.run(["AnotherComponent", "integer"]) 168 | 169 | manager_file = File.read!("lib/my_app/manager.ex") 170 | 171 | assert manager_file == 172 | """ 173 | defmodule MyApp.Manager do 174 | @moduledoc \"\"\" 175 | ECSx manager. 176 | \"\"\" 177 | use ECSx.Manager 178 | 179 | def setup do 180 | # Seed persistent components only for the first server start 181 | # (This will not be run on subsequent app restarts) 182 | :ok 183 | end 184 | 185 | def startup do 186 | # Load ephemeral components during first server start and again 187 | # on every subsequent app restart 188 | :ok 189 | end 190 | 191 | # Declare all valid Component types 192 | def components do 193 | [ 194 | MyApp.Components.AnotherComponent, 195 | MyApp.Components.LegendaryComponent 196 | ] 197 | end 198 | 199 | # Declare all Systems to run 200 | def systems do 201 | [ 202 | # MyApp.Systems.SampleSystem 203 | ] 204 | end 205 | end 206 | """ 207 | end) 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/mix/tasks/ecsx.gen.system_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../support/mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Ecsx.Gen.SystemTest do 4 | use ExUnit.Case 5 | 6 | import ECSx.MixHelper 7 | 8 | setup do 9 | create_sample_ecsx_project() 10 | on_exit(&clean_tmp_dir/0) 11 | :ok 12 | end 13 | 14 | test "generates system module" do 15 | Mix.Project.in_project(:my_app, ".", fn _module -> 16 | Mix.Tasks.Ecsx.Gen.System.run(["FooSystem"]) 17 | 18 | system_file = File.read!("lib/my_app/systems/foo_system.ex") 19 | 20 | assert system_file == 21 | """ 22 | defmodule MyApp.Systems.FooSystem do 23 | @moduledoc \"\"\" 24 | Documentation for FooSystem system. 25 | \"\"\" 26 | @behaviour ECSx.System 27 | 28 | @impl ECSx.System 29 | def run do 30 | # System logic 31 | :ok 32 | end 33 | end 34 | """ 35 | end) 36 | end 37 | 38 | test "injects system into manager" do 39 | Mix.Project.in_project(:my_app, ".", fn _module -> 40 | Mix.Tasks.Ecsx.Gen.System.run(["FooSystem"]) 41 | 42 | manager_file = File.read!("lib/my_app/manager.ex") 43 | 44 | assert manager_file == 45 | """ 46 | defmodule MyApp.Manager do 47 | @moduledoc \"\"\" 48 | ECSx manager. 49 | \"\"\" 50 | use ECSx.Manager 51 | 52 | def setup do 53 | # Seed persistent components only for the first server start 54 | # (This will not be run on subsequent app restarts) 55 | :ok 56 | end 57 | 58 | def startup do 59 | # Load ephemeral components during first server start and again 60 | # on every subsequent app restart 61 | :ok 62 | end 63 | 64 | # Declare all valid Component types 65 | def components do 66 | [ 67 | # MyApp.Components.SampleComponent 68 | ] 69 | end 70 | 71 | # Declare all Systems to run 72 | def systems do 73 | [ 74 | MyApp.Systems.FooSystem 75 | ] 76 | end 77 | end 78 | """ 79 | end) 80 | end 81 | 82 | test "multiple systems injected into manager" do 83 | Mix.Project.in_project(:my_app, ".", fn _module -> 84 | Mix.Tasks.Ecsx.Gen.System.run(["FooSystem"]) 85 | Mix.Tasks.Ecsx.Gen.System.run(["BarSystem"]) 86 | 87 | manager_file = File.read!("lib/my_app/manager.ex") 88 | 89 | assert manager_file == 90 | """ 91 | defmodule MyApp.Manager do 92 | @moduledoc \"\"\" 93 | ECSx manager. 94 | \"\"\" 95 | use ECSx.Manager 96 | 97 | def setup do 98 | # Seed persistent components only for the first server start 99 | # (This will not be run on subsequent app restarts) 100 | :ok 101 | end 102 | 103 | def startup do 104 | # Load ephemeral components during first server start and again 105 | # on every subsequent app restart 106 | :ok 107 | end 108 | 109 | # Declare all valid Component types 110 | def components do 111 | [ 112 | # MyApp.Components.SampleComponent 113 | ] 114 | end 115 | 116 | # Declare all Systems to run 117 | def systems do 118 | [ 119 | MyApp.Systems.BarSystem, 120 | MyApp.Systems.FooSystem 121 | ] 122 | end 123 | end 124 | """ 125 | end) 126 | end 127 | 128 | test "fails with missing argument" do 129 | Mix.Project.in_project(:my_app, ".", fn _module -> 130 | assert_raise(Mix.Error, fn -> 131 | Mix.Tasks.Ecsx.Gen.System.run([]) 132 | end) 133 | end) 134 | end 135 | 136 | test "handles systems with 'end' in the name" do 137 | Mix.Project.in_project(:my_app, ".", fn _module -> 138 | Mix.Tasks.Ecsx.Gen.System.run(["LegendSystem"]) 139 | Mix.Tasks.Ecsx.Gen.System.run(["BarSystem"]) 140 | 141 | manager_file = File.read!("lib/my_app/manager.ex") 142 | 143 | assert manager_file == 144 | """ 145 | defmodule MyApp.Manager do 146 | @moduledoc \"\"\" 147 | ECSx manager. 148 | \"\"\" 149 | use ECSx.Manager 150 | 151 | def setup do 152 | # Seed persistent components only for the first server start 153 | # (This will not be run on subsequent app restarts) 154 | :ok 155 | end 156 | 157 | def startup do 158 | # Load ephemeral components during first server start and again 159 | # on every subsequent app restart 160 | :ok 161 | end 162 | 163 | # Declare all valid Component types 164 | def components do 165 | [ 166 | # MyApp.Components.SampleComponent 167 | ] 168 | end 169 | 170 | # Declare all Systems to run 171 | def systems do 172 | [ 173 | MyApp.Systems.BarSystem, 174 | MyApp.Systems.LegendSystem 175 | ] 176 | end 177 | end 178 | """ 179 | end) 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /test/mix/tasks/ecsx.gen.tag_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../support/mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Ecsx.Gen.TagTest do 4 | use ExUnit.Case 5 | 6 | import ECSx.MixHelper 7 | 8 | setup do 9 | create_sample_ecsx_project() 10 | on_exit(&clean_tmp_dir/0) 11 | :ok 12 | end 13 | 14 | test "generates tag module" do 15 | Mix.Project.in_project(:my_app, ".", fn _module -> 16 | Mix.Tasks.Ecsx.Gen.Tag.run(["FooTag"]) 17 | 18 | component_file = File.read!("lib/my_app/components/foo_tag.ex") 19 | 20 | assert component_file == 21 | """ 22 | defmodule MyApp.Components.FooTag do 23 | @moduledoc \"\"\" 24 | Documentation for FooTag components. 25 | \"\"\" 26 | use ECSx.Tag 27 | end 28 | """ 29 | end) 30 | end 31 | 32 | test "injects component type into manager" do 33 | Mix.Project.in_project(:my_app, ".", fn _module -> 34 | Mix.Tasks.Ecsx.Gen.Tag.run(["FooTag"]) 35 | 36 | manager_file = File.read!("lib/my_app/manager.ex") 37 | 38 | assert manager_file == 39 | """ 40 | defmodule MyApp.Manager do 41 | @moduledoc \"\"\" 42 | ECSx manager. 43 | \"\"\" 44 | use ECSx.Manager 45 | 46 | def setup do 47 | # Seed persistent components only for the first server start 48 | # (This will not be run on subsequent app restarts) 49 | :ok 50 | end 51 | 52 | def startup do 53 | # Load ephemeral components during first server start and again 54 | # on every subsequent app restart 55 | :ok 56 | end 57 | 58 | # Declare all valid Component types 59 | def components do 60 | [ 61 | MyApp.Components.FooTag 62 | ] 63 | end 64 | 65 | # Declare all Systems to run 66 | def systems do 67 | [ 68 | # MyApp.Systems.SampleSystem 69 | ] 70 | end 71 | end 72 | """ 73 | end) 74 | end 75 | 76 | test "multiple component types injected into manager" do 77 | Mix.Project.in_project(:my_app, ".", fn _module -> 78 | Mix.Tasks.Ecsx.Gen.Tag.run(["FooTag"]) 79 | Mix.Tasks.Ecsx.Gen.Tag.run(["BarTag"]) 80 | Mix.Tasks.Ecsx.Gen.Tag.run(["BazTag"]) 81 | 82 | manager_file = File.read!("lib/my_app/manager.ex") 83 | 84 | assert manager_file == 85 | """ 86 | defmodule MyApp.Manager do 87 | @moduledoc \"\"\" 88 | ECSx manager. 89 | \"\"\" 90 | use ECSx.Manager 91 | 92 | def setup do 93 | # Seed persistent components only for the first server start 94 | # (This will not be run on subsequent app restarts) 95 | :ok 96 | end 97 | 98 | def startup do 99 | # Load ephemeral components during first server start and again 100 | # on every subsequent app restart 101 | :ok 102 | end 103 | 104 | # Declare all valid Component types 105 | def components do 106 | [ 107 | MyApp.Components.BazTag, 108 | MyApp.Components.BarTag, 109 | MyApp.Components.FooTag 110 | ] 111 | end 112 | 113 | # Declare all Systems to run 114 | def systems do 115 | [ 116 | # MyApp.Systems.SampleSystem 117 | ] 118 | end 119 | end 120 | """ 121 | end) 122 | end 123 | 124 | test "fails with invalid arguments" do 125 | Mix.Project.in_project(:my_app, ".", fn _module -> 126 | # No arguments 127 | assert_raise(Mix.Error, fn -> 128 | Mix.Tasks.Ecsx.Gen.Tag.run([]) 129 | end) 130 | end) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/mix/tasks/ecsx.setup_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../support/mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Ecsx.SetupTest do 4 | use ExUnit.Case 5 | 6 | import ECSx.MixHelper, only: [clean_tmp_dir: 0, sample_mixfile: 0] 7 | 8 | @config_path "config/config.exs" 9 | 10 | setup do 11 | File.mkdir!("tmp") 12 | File.cd!("tmp") 13 | File.mkdir!("lib") 14 | File.mkdir!("config") 15 | File.write!("mix.exs", sample_mixfile()) 16 | 17 | on_exit(&clean_tmp_dir/0) 18 | :ok 19 | end 20 | 21 | test "generates manager and folders" do 22 | Mix.Project.in_project(:my_app, ".", fn _module -> 23 | Mix.Tasks.Ecsx.Setup.run([]) 24 | 25 | manager_file = File.read!("lib/my_app/manager.ex") 26 | 27 | assert manager_file == 28 | """ 29 | defmodule MyApp.Manager do 30 | @moduledoc \"\"\" 31 | ECSx manager. 32 | \"\"\" 33 | use ECSx.Manager 34 | 35 | def setup do 36 | # Seed persistent components only for the first server start 37 | # (This will not be run on subsequent app restarts) 38 | :ok 39 | end 40 | 41 | def startup do 42 | # Load ephemeral components during first server start and again 43 | # on every subsequent app restart 44 | :ok 45 | end 46 | 47 | # Declare all valid Component types 48 | def components do 49 | [ 50 | # MyApp.Components.SampleComponent 51 | ] 52 | end 53 | 54 | # Declare all Systems to run 55 | def systems do 56 | [ 57 | # MyApp.Systems.SampleSystem 58 | ] 59 | end 60 | end 61 | """ 62 | 63 | assert File.dir?("lib/my_app/components") 64 | assert File.dir?("lib/my_app/systems") 65 | end) 66 | end 67 | 68 | test "injects into basic config" do 69 | Mix.Project.in_project(:my_app, ".", fn _module -> 70 | File.write!(@config_path, "import Config\n") 71 | 72 | Mix.Tasks.Ecsx.Setup.run([]) 73 | 74 | assert File.read!(@config_path) == 75 | """ 76 | import Config 77 | 78 | config :ecsx, 79 | tick_rate: 20, 80 | manager: MyApp.Manager 81 | """ 82 | end) 83 | end 84 | 85 | test "injects into missing config" do 86 | Mix.Project.in_project(:my_app, ".", fn _module -> 87 | Mix.Tasks.Ecsx.Setup.run([]) 88 | 89 | assert File.read!(@config_path) == 90 | """ 91 | import Config 92 | 93 | config :ecsx, 94 | tick_rate: 20, 95 | manager: MyApp.Manager 96 | """ 97 | end) 98 | end 99 | 100 | test "injects into realistic config" do 101 | Mix.Project.in_project(:my_app, ".", fn _module -> 102 | config = """ 103 | # This file is responsible for configuring your application 104 | # and its dependencies with the aid of the Config module. 105 | # 106 | # This configuration file is loaded before any dependency and 107 | # is restricted to this project. 108 | 109 | # General application configuration 110 | import Config 111 | 112 | config :my_app, 113 | ecto_repos: [MyApp.Repo] 114 | 115 | # Configures the endpoint 116 | config :my_app, MyAppWeb.Endpoint, 117 | url: [host: "localhost"], 118 | render_errors: [view: MyAppWeb.ErrorView, accepts: ~w(html json), layout: false], 119 | pubsub_server: MyApp.PubSub, 120 | live_view: [signing_salt: "foobar"] 121 | 122 | # Configures the mailer 123 | # 124 | # By default it uses the "Local" adapter which stores the emails 125 | # locally. You can see the emails in your browser, at "/dev/mailbox". 126 | # 127 | # For production it's recommended to configure a different adapter 128 | # at the `config/runtime.exs`. 129 | config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Local 130 | 131 | # Swoosh API client is needed for adapters other than SMTP. 132 | config :swoosh, :api_client, false 133 | 134 | # Configure esbuild (the version is required) 135 | config :esbuild, 136 | version: "0.14.41", 137 | default: [ 138 | args: 139 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 140 | cd: Path.expand("../assets", __DIR__), 141 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 142 | ] 143 | 144 | # Configures Elixir's Logger 145 | config :logger, :console, 146 | format: "$time $metadata[$level] $message\n", 147 | metadata: [:request_id] 148 | 149 | # Use Jason for JSON parsing in Phoenix 150 | config :phoenix, :json_library, Jason 151 | 152 | # Import environment specific config. This must remain at the bottom 153 | # of this file so it overrides the configuration defined above. 154 | import_config "\#{config_env()}.exs" 155 | """ 156 | 157 | File.write!(@config_path, config) 158 | 159 | Mix.Tasks.Ecsx.Setup.run([]) 160 | 161 | assert File.read!(@config_path) == """ 162 | # This file is responsible for configuring your application 163 | # and its dependencies with the aid of the Config module. 164 | # 165 | # This configuration file is loaded before any dependency and 166 | # is restricted to this project. 167 | 168 | # General application configuration 169 | import Config 170 | 171 | config :my_app, 172 | ecto_repos: [MyApp.Repo] 173 | 174 | # Configures the endpoint 175 | config :my_app, MyAppWeb.Endpoint, 176 | url: [host: "localhost"], 177 | render_errors: [view: MyAppWeb.ErrorView, accepts: ~w(html json), layout: false], 178 | pubsub_server: MyApp.PubSub, 179 | live_view: [signing_salt: "foobar"] 180 | 181 | # Configures the mailer 182 | # 183 | # By default it uses the "Local" adapter which stores the emails 184 | # locally. You can see the emails in your browser, at "/dev/mailbox". 185 | # 186 | # For production it's recommended to configure a different adapter 187 | # at the `config/runtime.exs`. 188 | config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Local 189 | 190 | # Swoosh API client is needed for adapters other than SMTP. 191 | config :swoosh, :api_client, false 192 | 193 | # Configure esbuild (the version is required) 194 | config :esbuild, 195 | version: "0.14.41", 196 | default: [ 197 | args: 198 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 199 | cd: Path.expand("../assets", __DIR__), 200 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 201 | ] 202 | 203 | # Configures Elixir's Logger 204 | config :logger, :console, 205 | format: "$time $metadata[$level] $message\n", 206 | metadata: [:request_id] 207 | 208 | # Use Jason for JSON parsing in Phoenix 209 | config :phoenix, :json_library, Jason 210 | 211 | config :ecsx, 212 | tick_rate: 20, 213 | manager: MyApp.Manager 214 | 215 | # Import environment specific config. This must remain at the bottom 216 | # of this file so it overrides the configuration defined above. 217 | import_config "\#{config_env()}.exs" 218 | """ 219 | end) 220 | end 221 | 222 | test "--no-folders option" do 223 | Mix.Project.in_project(:my_app, ".", fn _module -> 224 | Mix.Tasks.Ecsx.Setup.run(["--no-folders"]) 225 | 226 | assert File.exists?("lib/my_app/manager.ex") 227 | refute File.dir?("lib/my_app/components") 228 | refute File.dir?("lib/my_app/systems") 229 | end) 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /test/support/integer_component.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.IntegerComponent do 2 | use ECSx.Component, 3 | value: :integer 4 | end 5 | -------------------------------------------------------------------------------- /test/support/mix_helper.exs: -------------------------------------------------------------------------------- 1 | # Get Mix output sent to the current process to avoid polluting tests. 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule ECSx.MixHelper do 5 | @moduledoc """ 6 | Conveniently creates a new ECSx project for testing generators. 7 | """ 8 | 9 | @sample_mixfile """ 10 | defmodule MyApp.MixProject do 11 | use Mix.Project 12 | 13 | def project do 14 | [ 15 | app: :my_app 16 | ] 17 | end 18 | end 19 | """ 20 | 21 | @components_list """ 22 | [ 23 | # MyApp.Components.SampleComponent 24 | ] 25 | """ 26 | 27 | @systems_list """ 28 | [ 29 | # MyApp.Systems.SampleSystem 30 | ] 31 | """ 32 | 33 | def create_sample_ecsx_project do 34 | File.rm_rf!("tmp") 35 | File.mkdir!("tmp") 36 | File.cd!("tmp") 37 | 38 | File.mkdir!("lib") 39 | File.mkdir!("lib/my_app") 40 | File.mkdir!("lib/my_app/components") 41 | Application.put_env(:ecsx, :manager, MyApp.Manager) 42 | File.write!("mix.exs", @sample_mixfile) 43 | 44 | source = Application.app_dir(:ecsx, "/priv/templates/manager.ex") 45 | 46 | content = 47 | EEx.eval_file(source, 48 | app_name: "MyApp", 49 | components_list: @components_list, 50 | systems_list: @systems_list 51 | ) 52 | 53 | File.write!("lib/my_app/manager.ex", content) 54 | end 55 | 56 | def clean_tmp_dir do 57 | File.cd!("..") 58 | File.rm_rf!("tmp") 59 | Application.delete_env(:ecsx, :manager) 60 | end 61 | 62 | def sample_mixfile, do: @sample_mixfile 63 | end 64 | -------------------------------------------------------------------------------- /test/support/mock_persistence_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.Persistence.MockPersistenceAdapter do 2 | @behaviour ECSx.Persistence.Behaviour 3 | 4 | def retrieve_components(opts \\ []) do 5 | {:ok, Keyword.get(opts, :test_components, [])} 6 | end 7 | 8 | def persist_components(components, opts) do 9 | target = Keyword.fetch!(opts, :target) 10 | send(target, {:persist_components, components}) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/mocks.ex: -------------------------------------------------------------------------------- 1 | # coveralls-ignore-start 2 | defmodule ECSx.MockManager do 3 | use ECSx.Manager 4 | 5 | def systems do 6 | [ 7 | ECSx.MockSystem1, 8 | ECSx.MockSystem2 9 | ] 10 | end 11 | 12 | def components do 13 | [ 14 | ECSx.MockComponent1, 15 | ECSx.MockComponent2 16 | ] 17 | end 18 | end 19 | 20 | defmodule ECSx.MockComponent1 do 21 | use ECSx.Component, 22 | value: :binary 23 | end 24 | 25 | defmodule ECSx.MockComponent2 do 26 | use ECSx.Component, 27 | value: :binary 28 | end 29 | 30 | # coveralls-ignore-stop 31 | -------------------------------------------------------------------------------- /test/support/string_component.ex: -------------------------------------------------------------------------------- 1 | defmodule ECSx.StringComponent do 2 | use ECSx.Component, 3 | value: :binary 4 | end 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:ecsx, :persistence_adapter, ECSx.Persistence.MockPersistenceAdapter) 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------