├── .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 | [](https://hex.pm/packages/ecsx)
4 | [](https://github.com/ecsx-framework/ECSx/blob/master/LICENSE)
5 | [](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 |
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 |
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 |
--------------------------------------------------------------------------------