├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
├── assets
├── blue1.png
├── blue2.png
├── blue3.png
├── blue4.png
├── blue5.png
├── blue6.png
├── blue7.png
├── blue8.png
├── blue_goalie.png
├── pitch_alt.png
├── pitch_basket.png
├── pitch_classic.png
├── pitch_empty.png
├── puck_black.png
├── puck_gold.png
├── puck_white.png
├── red1.png
├── red2.png
├── red3.png
├── red4.png
├── red5.png
├── red6.png
├── red7.png
├── red8.png
└── red_goalie.png
├── demo.gif
└── src
├── big_text.rs
├── game.rs
├── lib.rs
├── main.rs
├── server.rs
├── types.rs
└── utils.rs
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 |
14 | .DS_Store
15 | keys
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "sshattrick"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | anyhow = "1.0.79"
8 | async-trait = "0.1.77"
9 | clap = { version = "4.5.1", features = ["derive"] }
10 | crossterm = "0.27.0"
11 | directories = "5.0.1"
12 | ed25519-dalek = { version = "2.1.0", features = ["serde"] }
13 | env_logger = "0.11.1"
14 | futures = "0.3.30"
15 | image = "0.24.8"
16 | imageproc = "0.23.0"
17 | include_dir = "0.7.3"
18 | log = "0.4.20"
19 | once_cell = "1.19.0"
20 | rand = "0.8.5"
21 | ratatui = "0.26.0"
22 | russh = "0.43.0"
23 | russh-keys = "0.43.0"
24 | serde = "1.0.196"
25 | serde_json = "1.0.113"
26 | tokio = "1.36.0"
27 | uuid = { version = "1.7.0", features = ["v4", "fast-rng"] }
28 |
29 | [dev-dependencies]
30 | gilrs = "0.10.4"
31 |
--------------------------------------------------------------------------------
/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 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ssHattrick
2 |
3 | 
4 |
5 | ssHattrick is a multiplayer game that you can play over SSH. It is a clone of the popular game [Hattrick](https://www.retrogames.cz/play_1368-Atari7800.php).
6 |
7 | ## Just play!
8 |
9 | `ssh frittura.org -p 2020`
10 |
11 | Remember to set the terminal to a minimum size of 160x50. Some terminals don't support the game colors, so you might need to try different ones. Here is a list of tested terminals:
12 |
13 | - Linux: whatever the default terminal is, it should work
14 | - MacOs: [iTerm2](https://iterm2.com/)
15 | - Windows: need someone to test it
16 |
17 | ## Build and Run
18 |
19 | My server will probably be down very often :) so you might want to run the server yourself.
20 |
21 | You need to have the rust toolchain installed --> https://www.rust-lang.org/tools/install. Then you can build the game with
22 |
23 | `cargo build --release`
24 |
25 | To run the server, you can run the executable and pass the port as an argument (2020 is the default port)
26 |
27 | `./target/release/sshattrick -p 2020`
28 |
29 | ## Contribution
30 |
31 | It is almost guaranteed that you will encounter bugs along your journey. If you do, please open an issue and describe what happened. If you are a developer and want to contribute, feel free to open a pull request.
32 |
33 | ## License
34 |
35 | This software is released under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) license.
36 |
--------------------------------------------------------------------------------
/assets/blue1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue1.png
--------------------------------------------------------------------------------
/assets/blue2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue2.png
--------------------------------------------------------------------------------
/assets/blue3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue3.png
--------------------------------------------------------------------------------
/assets/blue4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue4.png
--------------------------------------------------------------------------------
/assets/blue5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue5.png
--------------------------------------------------------------------------------
/assets/blue6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue6.png
--------------------------------------------------------------------------------
/assets/blue7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue7.png
--------------------------------------------------------------------------------
/assets/blue8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue8.png
--------------------------------------------------------------------------------
/assets/blue_goalie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/blue_goalie.png
--------------------------------------------------------------------------------
/assets/pitch_alt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/pitch_alt.png
--------------------------------------------------------------------------------
/assets/pitch_basket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/pitch_basket.png
--------------------------------------------------------------------------------
/assets/pitch_classic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/pitch_classic.png
--------------------------------------------------------------------------------
/assets/pitch_empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/pitch_empty.png
--------------------------------------------------------------------------------
/assets/puck_black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/puck_black.png
--------------------------------------------------------------------------------
/assets/puck_gold.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/puck_gold.png
--------------------------------------------------------------------------------
/assets/puck_white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/puck_white.png
--------------------------------------------------------------------------------
/assets/red1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red1.png
--------------------------------------------------------------------------------
/assets/red2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red2.png
--------------------------------------------------------------------------------
/assets/red3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red3.png
--------------------------------------------------------------------------------
/assets/red4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red4.png
--------------------------------------------------------------------------------
/assets/red5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red5.png
--------------------------------------------------------------------------------
/assets/red6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red6.png
--------------------------------------------------------------------------------
/assets/red7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red7.png
--------------------------------------------------------------------------------
/assets/red8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red8.png
--------------------------------------------------------------------------------
/assets/red_goalie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/assets/red_goalie.png
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ricott1/sshattrick/e91f5c42a45bf784925fc47bfc372dc8865448de/demo.gif
--------------------------------------------------------------------------------
/src/big_text.rs:
--------------------------------------------------------------------------------
1 | use ratatui::{prelude::*, widgets::Paragraph};
2 |
3 | fn big_text(text: Vec<&str>, color_1: Color, color_2: Color) -> Paragraph {
4 | let lines = text
5 | .iter()
6 | .map(|line| {
7 | let mut spans = vec![];
8 | for c in line.chars() {
9 | if c == '█' {
10 | spans.push(Span::styled("█", color_1));
11 | } else {
12 | spans.push(Span::styled(c.to_string(), color_2));
13 | }
14 | }
15 | Line::from(spans)
16 | })
17 | .collect::>();
18 | Paragraph::new(lines).alignment(Alignment::Center)
19 | }
20 |
21 | fn big_text_from_string<'a>(text: Vec, color_1: Color, color_2: Color) -> Paragraph<'a> {
22 | let lines = text
23 | .iter()
24 | .map(|line| {
25 | let mut spans = vec![];
26 | for c in line.chars() {
27 | if c == '█' {
28 | spans.push(Span::styled("█", color_1));
29 | } else {
30 | spans.push(Span::styled(c.to_string(), color_2));
31 | }
32 | }
33 | Line::from(spans)
34 | })
35 | .collect::>();
36 | Paragraph::new(lines).alignment(Alignment::Center)
37 | }
38 |
39 | pub fn dots(color_1: Color, color_2: Color) -> Paragraph<'static> {
40 | big_text(
41 | vec![" ", "██╗", "╚═╝", "██╗", "╚═╝", " "],
42 | color_1,
43 | color_2,
44 | )
45 | }
46 |
47 | pub fn hyphen(color_1: Color, color_2: Color) -> Paragraph<'static> {
48 | big_text(
49 | vec![" ", " ", "████╗", "╚═══╝", " ", " "],
50 | color_1,
51 | color_2,
52 | )
53 | }
54 |
55 | pub fn zero<'a>() -> Vec<&'a str> {
56 | vec![
57 | " ██████╗ ",
58 | "██╔═████╗",
59 | "██║██╔██║",
60 | "████╔╝██║",
61 | "╚██████╔╝",
62 | " ╚═════╝ ",
63 | ]
64 | }
65 |
66 | pub fn one<'a>() -> Vec<&'a str> {
67 | vec![" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"]
68 | }
69 |
70 | pub fn two<'a>() -> Vec<&'a str> {
71 | vec![
72 | "██████╗ ",
73 | "╚════██╗",
74 | " █████╔╝",
75 | "██╔═══╝ ",
76 | "███████╗",
77 | "╚══════╝",
78 | ]
79 | }
80 |
81 | pub fn three<'a>() -> Vec<&'a str> {
82 | vec![
83 | "██████╗ ",
84 | "╚════██╗",
85 | " █████╔╝",
86 | " ╚═══██╗",
87 | "██████╔╝",
88 | "╚═════╝ ",
89 | ]
90 | }
91 |
92 | pub fn four<'a>() -> Vec<&'a str> {
93 | vec![
94 | "██╗ ██╗",
95 | "██║ ██║",
96 | "███████║",
97 | "╚════██║",
98 | " ██║",
99 | " ╚═╝",
100 | ]
101 | }
102 |
103 | pub fn five<'a>() -> Vec<&'a str> {
104 | vec![
105 | "███████╗",
106 | "██╔════╝",
107 | "███████╗",
108 | "╚════██║",
109 | "███████║",
110 | "╚══════╝",
111 | ]
112 | }
113 |
114 | pub fn six<'a>() -> Vec<&'a str> {
115 | vec![
116 | " ██████╗ ",
117 | "██╔════╝ ",
118 | "███████╗ ",
119 | "██╔═══██╗",
120 | "╚██████╔╝",
121 | " ╚═════╝ ",
122 | ]
123 | }
124 |
125 | pub fn seven<'a>() -> Vec<&'a str> {
126 | vec![
127 | "███████╗",
128 | "╚════██║",
129 | " ██╔╝",
130 | " ██╔╝ ",
131 | " ██║ ",
132 | " ╚═╝ ",
133 | ]
134 | }
135 |
136 | pub fn eight<'a>() -> Vec<&'a str> {
137 | vec![
138 | " █████╗ ",
139 | "██╔══██╗",
140 | "╚█████╔╝",
141 | "██╔══██╗",
142 | "╚█████╔╝",
143 | " ╚════╝ ",
144 | ]
145 | }
146 |
147 | pub fn nine<'a>() -> Vec<&'a str> {
148 | vec![
149 | " █████╗ ",
150 | "██╔══██╗",
151 | "╚██████║",
152 | " ╚═══██║",
153 | " █████╔╝",
154 | " ╚════╝ ",
155 | ]
156 | }
157 |
158 | fn digit_to_str<'a>(x: u8) -> Vec<&'a str> {
159 | match x {
160 | 0 => zero(),
161 | 1 => one(),
162 | 2 => two(),
163 | 3 => three(),
164 | 4 => four(),
165 | 5 => five(),
166 | 6 => six(),
167 | 7 => seven(),
168 | 8 => eight(),
169 | 9 => nine(),
170 | _ => panic!("Invalid digit"),
171 | }
172 | }
173 |
174 | pub trait BigNumberFont {
175 | fn big_font(&self) -> Paragraph<'static>;
176 | fn big_font_styled(&self, color_1: Color, color_2: Color) -> Paragraph<'static>;
177 | }
178 | impl BigNumberFont for u8 {
179 | fn big_font(&self) -> Paragraph<'static> {
180 | match self {
181 | x if x.clone() < 10 => big_text(digit_to_str(x.clone()), Color::Cyan, Color::White),
182 | x if x.clone() < 100 => {
183 | let tens = digit_to_str(x / 10);
184 | let units = digit_to_str(x % 10);
185 | let mut total = vec![];
186 | for idx in 0..6 {
187 | total.push(tens[idx].to_string() + units[idx]);
188 | }
189 | big_text_from_string(total, Color::Cyan, Color::White)
190 | }
191 | _ => dots(Color::Cyan, Color::White),
192 | }
193 | }
194 | fn big_font_styled(&self, color_1: Color, color_2: Color) -> Paragraph<'static> {
195 | match self {
196 | x if x.clone() < 10 => big_text(digit_to_str(x.clone()), color_1, color_2),
197 | x if x.clone() < 100 => {
198 | let tens = digit_to_str(x / 10);
199 | let units = digit_to_str(x % 10);
200 | let mut total = vec![];
201 | for idx in 0..6 {
202 | total.push(tens[idx].to_string() + units[idx]);
203 | }
204 | big_text_from_string(total, color_1, color_2)
205 | }
206 | _ => dots(color_1, color_2),
207 | }
208 | }
209 | }
210 |
211 | pub fn red_scored(color_1: Color, color_2: Color) -> Paragraph<'static> {
212 | big_text(
213 | vec![
214 | "██████╗ ███████╗██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗",
215 | "██╔══██╗██╔════╝██╔══██╗ ██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║",
216 | "██████╔╝█████╗ ██║ ██║ ███████╗██║ ██║ ██║██████╔╝█████╗ ██║ ██║██║",
217 | "██╔══██╗██╔══╝ ██║ ██║ ╚════██║██║ ██║ ██║██╔══██╗██╔══╝ ██║ ██║╚═╝",
218 | "██║ ██║███████╗██████╔╝ ███████║╚██████╗╚██████╔╝██║ ██║███████╗██████╔╝██╗",
219 | "╚═╝ ╚═╝╚══════╝╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝",
220 | ],
221 | color_1,
222 | color_2,
223 | )
224 | }
225 |
226 | pub fn blue_scored(color_1: Color, color_2: Color) -> Paragraph<'static> {
227 | big_text(
228 | vec![
229 | "██████╗ ██╗ ██╗ ██╗███████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗",
230 | "██╔══██╗██║ ██║ ██║██╔════╝ ██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║",
231 | "██████╔╝██║ ██║ ██║█████╗ ███████╗██║ ██║ ██║██████╔╝█████╗ ██║ ██║██║",
232 | "██╔══██╗██║ ██║ ██║██╔══╝ ╚════██║██║ ██║ ██║██╔══██╗██╔══╝ ██║ ██║╚═╝",
233 | "██████╔╝███████╗╚██████╔╝███████╗ ███████║╚██████╗╚██████╔╝██║ ██║███████╗██████╔╝██╗",
234 | "╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝",
235 | ],
236 | color_1,
237 | color_2,
238 | )
239 | }
240 |
241 | pub fn red_won(color_1: Color, color_2: Color) -> Paragraph<'static> {
242 | big_text(
243 | vec![
244 | "██████╗ ███████╗██████╗ ██╗ ██╗ ██████╗ ███╗ ██╗██╗",
245 | "██╔══██╗██╔════╝██╔══██╗ ██║ ██║██╔═══██╗████╗ ██║██║",
246 | "██████╔╝█████╗ ██║ ██║ ██║ █╗ ██║██║ ██║██╔██╗ ██║██║",
247 | "██╔══██╗██╔══╝ ██║ ██║ ██║███╗██║██║ ██║██║╚██╗██║╚═╝",
248 | "██║ ██║███████╗██████╔╝ ╚███╔███╔╝╚██████╔╝██║ ╚████║██╗",
249 | "╚═╝ ╚═╝╚══════╝╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝",
250 | ],
251 | color_1,
252 | color_2,
253 | )
254 | }
255 |
256 | pub fn blue_won(color_1: Color, color_2: Color) -> Paragraph<'static> {
257 | big_text(
258 | vec![
259 | "██████╗ ██╗ ██╗ ██╗███████╗ ██╗ ██╗ ██████╗ ███╗ ██╗██╗",
260 | "██╔══██╗██║ ██║ ██║██╔════╝ ██║ ██║██╔═══██╗████╗ ██║██║",
261 | "██████╔╝██║ ██║ ██║█████╗ ██║ █╗ ██║██║ ██║██╔██╗ ██║██║",
262 | "██╔══██╗██║ ██║ ██║██╔══╝ ██║███╗██║██║ ██║██║╚██╗██║╚═╝",
263 | "██████╔╝███████╗╚██████╔╝███████╗ ╚███╔███╔╝╚██████╔╝██║ ╚████║██╗",
264 | "╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚══╝╚══╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝",
265 | ],
266 | color_1,
267 | color_2,
268 | )
269 | }
270 |
271 | pub fn draw(color_1: Color, color_2: Color) -> Paragraph<'static> {
272 | big_text(
273 | vec![
274 | "██████╗ ██████╗ █████╗ ██╗ ██╗██╗",
275 | "██╔══██╗██╔══██╗██╔══██╗██║ ██║██║",
276 | "██║ ██║██████╔╝███████║██║ █╗ ██║██║",
277 | "██║ ██║██╔══██╗██╔══██║██║███╗██║╚═╝",
278 | "██████╔╝██║ ██║██║ ██║╚███╔███╔╝██╗",
279 | "╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝",
280 | ],
281 | color_1,
282 | color_2,
283 | )
284 | }
285 |
286 | pub fn disconnection(color_1: Color, color_2: Color) -> Paragraph<'static> {
287 | big_text(vec![
288 | "██████╗ ██╗███████╗ ██████╗ ██████╗ ███╗ ██╗███╗ ██╗███████╗ ██████╗████████╗██╗ ██████╗ ███╗ ██╗",
289 | "██╔══██╗██║██╔════╝██╔════╝██╔═══██╗████╗ ██║████╗ ██║██╔════╝██╔════╝╚══██╔══╝██║██╔═══██╗████╗ ██║",
290 | "██║ ██║██║███████╗██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██║ ██║██║ ██║██╔██╗ ██║",
291 | "██║ ██║██║╚════██║██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██║ ██║██║ ██║██║╚██╗██║",
292 | "██████╔╝██║███████║╚██████╗╚██████╔╝██║ ╚████║██║ ╚████║███████╗╚██████╗ ██║ ██║╚██████╔╝██║ ╚████║",
293 | "╚═════╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝"
294 | ],
295 | color_1,
296 | color_2
297 | )
298 | }
299 |
--------------------------------------------------------------------------------
/src/game.rs:
--------------------------------------------------------------------------------
1 | use crate::{
2 | big_text::{blue_scored, blue_won, dots, draw, red_scored, red_won, BigNumberFont},
3 | types::*,
4 | utils::*,
5 | };
6 | use crossterm::event::KeyCode;
7 | use image::{Rgba, RgbaImage};
8 | use once_cell::sync::Lazy;
9 | use rand::Rng;
10 | use ratatui::{
11 | layout::{Constraint, Layout, Margin, Position, Rect},
12 | style::Color,
13 | text::Line,
14 | widgets::Paragraph,
15 | Frame,
16 | };
17 | use std::time::Instant;
18 |
19 | const MINIMUM_DELTATIME_MILLISECONDS: f32 = 18.0;
20 | const GAME_DURATION_MILLISECONDS: u128 = 90 * 1000;
21 | const STARTING_DELAY_MILLISECONDS: u128 = 3000;
22 | const AFTER_GOAL_DELAY_MILLISECONDS: u128 = 2000;
23 | const ENDING_DELAY_MILLISECONDS: u128 = 1000;
24 |
25 | const MIN_X: f32 = 3.0;
26 | const MAX_X: f32 = 157.0;
27 | const MIN_Y: f32 = 3.0;
28 | const MAX_Y: f32 = 83.0;
29 |
30 | const GOALIE_AREA_WIDTH: f32 = 8.0;
31 | const GOALIE_AREA_HEIGHT: f32 = 26.0;
32 | const GOALIE_AREA_MIN_Y: f32 = 31.0;
33 | const GOALIE_AREA_MAX_Y: f32 = 55.0;
34 |
35 | const RED_INITIAL_POSITION: (f32, f32) = (20.0, 40.0);
36 | const BLUE_INITIAL_POSITION: (f32, f32) = (132.0, 40.0);
37 |
38 | const PUCK_WIDTH: f32 = 2.0;
39 | const PUCK_HEIGHT: f32 = 2.0;
40 | const GOALIE_WIDTH: f32 = 6.0;
41 | const GOALIE_HEIGHT: f32 = 7.0;
42 |
43 | const GOALIE_MIN_Y: f32 = 31.0;
44 | const GOALIE_MAX_Y: f32 = 48.0;
45 |
46 | const ACCELERATION: f32 = 0.2;
47 | const DECELERATION: f32 = 0.4;
48 | const MAX_PLAYER_VELOCITY: f32 = 1.3;
49 | const MAX_PUCK_VELOCITY: f32 = 2.2;
50 |
51 | const GOALIE_MASS: f32 = 1000.0;
52 | const PLAYER_MASS: f32 = 20.0;
53 | const PUCK_MASS: f32 = 1.0;
54 |
55 | const PLAYER_FRICTION_VELOCITY_LOSS: f32 = 0.975;
56 | const PUCK_FRICTION_VELOCITY_LOSS: f32 = 0.99;
57 | const COEFFICIENT_OF_RESTITUTION: f32 = 0.7;
58 | const COFFICIENT_OF_WALL_BOUNCING: f32 = 0.25;
59 |
60 | const SKATE_TRACE_LENGTH: usize = 512;
61 |
62 | const SHOOTING_COUNTER_MILLISECONDS: f32 = 350.0;
63 | const AFTER_SHOOTING_COUNTER_MILLISECONDS: f32 = 50.0;
64 | const AFTER_GOT_STOLEN_COUNTER_MILLISECONDS: f32 = 50.0;
65 | const SHOOTING_DIRECTION_MODIFIER: f32 = 0.35;
66 | const SHOOTING_POWER: f32 = 3.0;
67 |
68 | static PITCH_EMPTY: Lazy =
69 | Lazy::new(|| read_image("pitch_empty.png").expect("Could not read pitch_empty.png."));
70 |
71 | static PITCH_CLASSIC: Lazy =
72 | Lazy::new(|| read_image("pitch_classic.png").expect("Could not read pitch_classic.png."));
73 |
74 | static PITCH_BASKET: Lazy =
75 | Lazy::new(|| read_image("pitch_basket.png").expect("Could not read pitch_basket.png."));
76 |
77 | static PITCH_ALT: Lazy =
78 | Lazy::new(|| read_image("pitch_alt.png").expect("Could not read pitch_alt.png."));
79 |
80 | static PUCK_DARK: Lazy =
81 | Lazy::new(|| read_image("puck_white.png").expect("Could not read puck.png."));
82 |
83 | static PUCK_LIGHT: Lazy =
84 | Lazy::new(|| read_image("puck_black.png").expect("Could not read puck.png."));
85 |
86 | static PUCK_GOLD: Lazy =
87 | Lazy::new(|| read_image("puck_gold.png").expect("Could not read puck.png."));
88 |
89 | static RED_PLAYER: Lazy> = Lazy::new(|| {
90 | let mut images = vec![];
91 | for i in 1..=8 {
92 | images.push(
93 | read_image(format!("red{i}.png").as_str())
94 | .expect(format!("Could not read red{i}.png.").as_str()),
95 | );
96 | }
97 | images
98 | });
99 |
100 | static RED_GOALIE: Lazy =
101 | Lazy::new(|| read_image("red_goalie.png").expect("Could not read red_goalie.png."));
102 |
103 | static BLUE_PLAYER: Lazy> = Lazy::new(|| {
104 | let mut images = vec![];
105 | for i in 1..=8 {
106 | images.push(
107 | read_image(format!("blue{i}.png").as_str())
108 | .expect(format!("Could not read blue{i}.png.").as_str()),
109 | );
110 | }
111 | images
112 | });
113 |
114 | static BLUE_GOALIE: Lazy =
115 | Lazy::new(|| read_image("blue_goalie.png").expect("Could not read blue_goalie.png."));
116 |
117 | fn base_image(palette: Palette) -> RgbaImage {
118 | match palette {
119 | Palette::Dark => PITCH_EMPTY.clone(),
120 | Palette::Light => PITCH_CLASSIC.clone(),
121 | Palette::Basket => PITCH_BASKET.clone(),
122 | Palette::Alt => PITCH_ALT.clone(),
123 | }
124 | }
125 |
126 | fn skate_trace_color(palette: Palette) -> Rgba {
127 | match palette {
128 | Palette::Dark => Rgba([55, 55, 85, 255]),
129 | Palette::Light => Rgba([145, 215, 255, 255]),
130 | Palette::Basket => Rgba([55, 55, 85, 255]),
131 | Palette::Alt => Rgba([105, 55, 55, 255]),
132 | }
133 | }
134 |
135 | fn puck_catcher_offset(orientation: Orientation) -> (f32, f32) {
136 | match orientation {
137 | Orientation::Up => (18.0, 0.0),
138 | Orientation::UpLeft => (12.0, -2.0),
139 | Orientation::Left => (0.0, 0.0),
140 | Orientation::DownLeft => (-2.0, 1.0),
141 | Orientation::Down => (0.0, 6.0),
142 | Orientation::DownRight => (1.0, 15.0),
143 | Orientation::Right => (6.0, 18.0),
144 | Orientation::UpRight => (15.0, 12.0),
145 | }
146 | }
147 |
148 | /// Simple collision detection using rectangles.
149 | /// Returns a boolean indicating if the two sprites are colliding.
150 | fn are_sprites_colliding(rect1: Rect, rect2: Rect) -> bool {
151 | rect1.x < rect2.x + rect2.width
152 | && rect1.x + rect1.width > rect2.x
153 | && rect1.y < rect2.y + rect2.height
154 | && rect1.y + rect1.height > rect2.y
155 | }
156 |
157 | #[derive(Clone, Copy, PartialEq)]
158 | enum Orientation {
159 | Up,
160 | UpLeft,
161 | Left,
162 | DownLeft,
163 | Down,
164 | DownRight,
165 | Right,
166 | UpRight,
167 | }
168 |
169 | impl Orientation {
170 | pub fn next(self) -> Self {
171 | ((self as u8 + 1) % 8).into()
172 | }
173 |
174 | pub fn previous(self) -> Self {
175 | ((self as u8 + 7) % 8).into()
176 | }
177 | }
178 |
179 | impl From for Orientation {
180 | fn from(value: u8) -> Self {
181 | match value {
182 | 0 => Orientation::Up,
183 | 1 => Orientation::UpLeft,
184 | 2 => Orientation::Left,
185 | 3 => Orientation::DownLeft,
186 | 4 => Orientation::Down,
187 | 5 => Orientation::DownRight,
188 | 6 => Orientation::Right,
189 | 7 => Orientation::UpRight,
190 | _ => panic!("Invalid orientation"),
191 | }
192 | }
193 | }
194 |
195 | #[derive(Clone, Copy, PartialEq)]
196 | enum GameState {
197 | // TODO: add character selection with different stats
198 | Starting { time: Instant },
199 | Running,
200 | AfterGoal { time: Instant, scored: GameSide },
201 | Ending { time: Instant },
202 | }
203 |
204 | enum CollisionType {
205 | Minimal,
206 | Full,
207 | }
208 | #[derive(Clone, Copy, PartialEq)]
209 | enum Palette {
210 | Dark,
211 | Light,
212 | Basket,
213 | Alt,
214 | }
215 |
216 | impl Palette {
217 | pub fn next(&self) -> Self {
218 | match self {
219 | Palette::Dark => Palette::Light,
220 | Palette::Light => Palette::Basket,
221 | Palette::Basket => Palette::Alt,
222 | Palette::Alt => Palette::Dark,
223 | }
224 | }
225 | }
226 |
227 | fn resolve_collision(
228 | sprite1: &mut impl Body,
229 | sprite2: &mut impl Body,
230 | collision_type1: CollisionType,
231 | collision_type2: CollisionType,
232 | ) -> bool {
233 | let rect1 = match collision_type1 {
234 | CollisionType::Minimal => sprite1.minimal_collision_rect(),
235 | CollisionType::Full => sprite1.full_collision_rect(),
236 | };
237 |
238 | let rect2 = match collision_type2 {
239 | CollisionType::Minimal => sprite2.minimal_collision_rect(),
240 | CollisionType::Full => sprite2.full_collision_rect(),
241 | };
242 |
243 | // Check collisions between players
244 | if are_sprites_colliding(rect1, rect2) {
245 | // Calculate new velocities by conservation of momentum
246 | // Energy is dissipated in the collision by a factor ENERGY_LOSS
247 | let velocity_com = (
248 | (sprite1.velocity().0 * sprite1.mass() + sprite2.velocity().0 * sprite2.mass())
249 | / (sprite1.mass() + sprite2.mass()),
250 | (sprite1.velocity().1 * sprite1.mass() + sprite2.velocity().1 * sprite2.mass())
251 | / (sprite1.mass() + sprite2.mass()),
252 | );
253 |
254 | sprite1.set_velocity((
255 | (1.0 + COEFFICIENT_OF_RESTITUTION) * velocity_com.0
256 | - COEFFICIENT_OF_RESTITUTION * sprite1.velocity().0,
257 | (1.0 + COEFFICIENT_OF_RESTITUTION) * velocity_com.1
258 | - COEFFICIENT_OF_RESTITUTION * sprite1.velocity().1,
259 | ));
260 |
261 | sprite2.set_velocity((
262 | (1.0 + COEFFICIENT_OF_RESTITUTION) * velocity_com.0
263 | - COEFFICIENT_OF_RESTITUTION * sprite2.velocity().0,
264 | (1.0 + COEFFICIENT_OF_RESTITUTION) * velocity_com.1
265 | - COEFFICIENT_OF_RESTITUTION * sprite2.velocity().1,
266 | ));
267 | return true;
268 | }
269 |
270 | false
271 | }
272 |
273 | trait Body {
274 | fn position(&self) -> (f32, f32);
275 | fn set_position(&mut self, position: (f32, f32));
276 | fn velocity(&self) -> (f32, f32);
277 | fn set_velocity(&mut self, velocity: (f32, f32));
278 | fn size(&self) -> (f32, f32);
279 | fn full_collision_rect(&self) -> Rect {
280 | let (x, y) = self.position();
281 | let (w, h) = self.size();
282 | Rect {
283 | x: x as u16,
284 | y: y as u16,
285 | width: w as u16,
286 | height: h as u16,
287 | }
288 | }
289 | fn minimal_collision_rect(&self) -> Rect {
290 | let (x, y) = self.position();
291 | let (w, h) = self.size();
292 | Rect {
293 | x: x as u16,
294 | y: y as u16,
295 | width: w as u16,
296 | height: h as u16,
297 | }
298 | }
299 | fn mass(&self) -> f32;
300 | fn update(&mut self, _deltatime: f32) {}
301 | fn image(&self, palette: Palette) -> RgbaImage;
302 | }
303 |
304 | #[derive(Clone)]
305 | pub struct Puck {
306 | position: (f32, f32),
307 | velocity: (f32, f32),
308 | possession: Option,
309 | }
310 |
311 | impl Puck {
312 | pub fn new() -> Self {
313 | // Pick random number from o or 1
314 | if rand::thread_rng().gen_range(0..=1) == 0 {
315 | Self {
316 | position: (79.0, MIN_Y),
317 | velocity: (0.0, 1.0),
318 | possession: None,
319 | }
320 | } else {
321 | Self {
322 | position: (79.0, MAX_Y),
323 | velocity: (0.0, -1.0),
324 | possession: None,
325 | }
326 | }
327 | }
328 |
329 | pub fn has_scored(&self) -> Option {
330 | if self.position.0 <= MIN_X
331 | && self.position.1 >= GOALIE_AREA_MIN_Y
332 | && self.position.1 <= GOALIE_AREA_MAX_Y - PUCK_HEIGHT
333 | {
334 | return Some(GameSide::Blue);
335 | }
336 | if self.position.0 >= MAX_X - PUCK_WIDTH
337 | && self.position.1 >= GOALIE_AREA_MIN_Y
338 | && self.position.1 <= GOALIE_AREA_MAX_Y - PUCK_HEIGHT
339 | {
340 | return Some(GameSide::Red);
341 | }
342 | None
343 | }
344 |
345 | pub fn attach_to_player(&mut self, player: &Player) {
346 | let offset = puck_catcher_offset(player.orientation);
347 | self.set_position((player.position.0 + offset.0, player.position.1 + offset.1));
348 | self.velocity = player.velocity;
349 | }
350 |
351 | pub fn can_be_catched_by_player(&self, player: &Player) -> bool {
352 | // Puck can be catched if it is not in possession of any player
353 | // and the player catcher pixel overlaps with the puck full collision rect.
354 | let catcher_position = player.catcher_position();
355 | let position = Position {
356 | x: catcher_position.0 as u16,
357 | y: catcher_position.1 as u16,
358 | };
359 | self.possession.is_none()
360 | && player.after_shooting_counter == 0.0
361 | && self.full_collision_rect().contains(position)
362 | }
363 |
364 | pub fn can_be_stolen_by_player(&self, player: &Player) -> bool {
365 | // Puck can be stolen if it is in possession of the other player
366 | // and the player catcher pixel overlaps with the puck full collision rect.
367 | let catcher_position = player.catcher_position();
368 | let position = Position {
369 | x: catcher_position.0 as u16,
370 | y: catcher_position.1 as u16,
371 | };
372 | self.possession.is_some()
373 | && self.possession.unwrap() != player.side
374 | && player.after_got_stolen_counter == 0.0
375 | && self.minimal_collision_rect().contains(position)
376 | }
377 | }
378 |
379 | impl Body for Puck {
380 | fn position(&self) -> (f32, f32) {
381 | self.position
382 | }
383 |
384 | fn set_position(&mut self, position: (f32, f32)) {
385 | let (w1, h1) = self.size();
386 | let (mut new_x1, mut new_y1) = position;
387 | if new_x1 < MIN_X {
388 | new_x1 = MIN_X;
389 | self.set_velocity((-self.velocity.0, self.velocity.1));
390 | } else if new_x1 + w1 > MAX_X {
391 | new_x1 = MAX_X - w1;
392 | self.set_velocity((-self.velocity.0, self.velocity.1));
393 | }
394 |
395 | if new_y1 < MIN_Y {
396 | new_y1 = MIN_Y;
397 | self.set_velocity((self.velocity.0, -self.velocity.1));
398 | } else if new_y1 + h1 > MAX_Y {
399 | new_y1 = MAX_Y - h1;
400 | self.set_velocity((self.velocity.0, -self.velocity.1));
401 | }
402 |
403 | self.position = (new_x1, new_y1);
404 | }
405 |
406 | fn velocity(&self) -> (f32, f32) {
407 | self.velocity
408 | }
409 |
410 | fn set_velocity(&mut self, velocity: (f32, f32)) {
411 | let (mut vx, mut vy) = velocity;
412 | let speed = (vx.powf(2.0) + vy.powf(2.0)).sqrt();
413 | if speed > MAX_PUCK_VELOCITY {
414 | vx = vx * MAX_PUCK_VELOCITY / speed;
415 | vy = vy * MAX_PUCK_VELOCITY / speed;
416 | }
417 | self.velocity = (vx, vy);
418 | }
419 |
420 | fn size(&self) -> (f32, f32) {
421 | (PUCK_WIDTH, PUCK_HEIGHT)
422 | }
423 |
424 | fn mass(&self) -> f32 {
425 | PUCK_MASS
426 | }
427 |
428 | fn full_collision_rect(&self) -> Rect {
429 | // A 6x6 rect around the puck
430 | let (x, y) = self.position();
431 | let (w, h) = self.size();
432 | Rect {
433 | x: x as u16 - 2,
434 | y: y as u16 - 2,
435 | width: w as u16 + 4,
436 | height: h as u16 + 4,
437 | }
438 | }
439 |
440 | fn update(&mut self, deltatime: f32) {
441 | let (x, y) = self.position();
442 | let (vx, vy) = self.velocity();
443 | // Apply friction
444 | self.set_velocity((
445 | vx * PUCK_FRICTION_VELOCITY_LOSS,
446 | vy * PUCK_FRICTION_VELOCITY_LOSS,
447 | ));
448 | let (vx, vy) = self.velocity();
449 | self.set_position((x + vx * deltatime, y + vy * deltatime));
450 | }
451 |
452 | fn image(&self, palette: Palette) -> RgbaImage {
453 | match palette {
454 | Palette::Dark => PUCK_DARK.clone(),
455 | Palette::Light => PUCK_LIGHT.clone(),
456 | Palette::Basket => PUCK_DARK.clone(),
457 | Palette::Alt => PUCK_GOLD.clone(),
458 | }
459 | }
460 | }
461 |
462 | #[derive(Clone)]
463 | pub struct Player {
464 | side: GameSide,
465 | position: (f32, f32),
466 | velocity: (f32, f32),
467 | orientation: Orientation,
468 | new_orientation: Option,
469 | shooting_direction: Option<(f32, f32)>,
470 | shooting_counter: f32,
471 | after_shooting_counter: f32,
472 | after_got_stolen_counter: f32,
473 | }
474 |
475 | impl Player {
476 | pub fn new(side: GameSide) -> Self {
477 | let position = match side {
478 | GameSide::Red => RED_INITIAL_POSITION,
479 | GameSide::Blue => BLUE_INITIAL_POSITION,
480 | };
481 | let orientation = match side {
482 | GameSide::Red => Orientation::Right,
483 | GameSide::Blue => Orientation::Left,
484 | };
485 | Self {
486 | side,
487 | position,
488 | velocity: (0.0, 0.0),
489 | orientation,
490 | new_orientation: None,
491 | shooting_direction: None,
492 | shooting_counter: 0.0,
493 | after_shooting_counter: 0.0,
494 | after_got_stolen_counter: 0.0,
495 | }
496 | }
497 |
498 | pub fn reset(&mut self) {
499 | self.position = match self.side {
500 | GameSide::Red => RED_INITIAL_POSITION,
501 | GameSide::Blue => BLUE_INITIAL_POSITION,
502 | };
503 | self.velocity = (0.0, 0.0);
504 | self.orientation = match self.side {
505 | GameSide::Red => Orientation::Right,
506 | GameSide::Blue => Orientation::Left,
507 | };
508 | self.new_orientation = None;
509 | self.shooting_direction = None;
510 | self.shooting_counter = 0.0;
511 | self.after_shooting_counter = 0.0;
512 | }
513 |
514 | pub fn catcher_position(&self) -> (f32, f32) {
515 | let offset = puck_catcher_offset(self.orientation);
516 | (self.position.0 + offset.0, self.position.1 + offset.1)
517 | }
518 |
519 | fn head_position_offset(&self) -> (u16, u16) {
520 | match self.orientation {
521 | Orientation::Up => (4, 3),
522 | Orientation::UpLeft => (5, 10),
523 | Orientation::Left => (3, 13),
524 | Orientation::DownLeft => (10, 7),
525 | Orientation::Down => (13, 2),
526 | Orientation::DownRight => (7, 2),
527 | Orientation::Right => (2, 4),
528 | Orientation::UpRight => (2, 5),
529 | }
530 | }
531 |
532 | fn rotate(&mut self, new_orientation: Orientation) {
533 | let previous_head_position = (
534 | self.position.0 + self.head_position_offset().0 as f32,
535 | self.position.1 + self.head_position_offset().1 as f32,
536 | );
537 | self.orientation = new_orientation;
538 | self.new_orientation = None;
539 | // After rotating, realign the player so that the head position did not change
540 | let new_head_position = (
541 | self.position.0 + self.head_position_offset().0 as f32,
542 | self.position.1 + self.head_position_offset().1 as f32,
543 | );
544 | let (dx, dy) = (
545 | previous_head_position.0 - new_head_position.0,
546 | previous_head_position.1 - new_head_position.1,
547 | );
548 | self.position = (self.position.0 + dx, self.position.1 + dy);
549 | }
550 | }
551 |
552 | impl Body for Player {
553 | fn position(&self) -> (f32, f32) {
554 | self.position
555 | }
556 |
557 | fn set_position(&mut self, position: (f32, f32)) {
558 | let (w1, h1) = self.size();
559 | let (mut new_x1, mut new_y1) = position;
560 | if new_x1 < MIN_X {
561 | new_x1 = MIN_X;
562 | self.set_velocity((
563 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.0,
564 | self.velocity.1,
565 | ));
566 | } else if new_x1 + w1 > MAX_X {
567 | new_x1 = MAX_X - w1;
568 | self.set_velocity((
569 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.0,
570 | self.velocity.1,
571 | ));
572 | }
573 |
574 | if new_y1 < MIN_Y {
575 | new_y1 = MIN_Y;
576 | self.set_velocity((
577 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.0,
578 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.1,
579 | ));
580 | } else if new_y1 + h1 > MAX_Y {
581 | new_y1 = MAX_Y - h1;
582 | self.set_velocity((
583 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.0,
584 | -COFFICIENT_OF_WALL_BOUNCING * self.velocity.1,
585 | ));
586 | }
587 |
588 | self.position = (new_x1, new_y1);
589 | }
590 |
591 | fn velocity(&self) -> (f32, f32) {
592 | self.velocity
593 | }
594 |
595 | fn set_velocity(&mut self, velocity: (f32, f32)) {
596 | let (mut vx, mut vy) = velocity;
597 | let speed = (vx.powf(2.0) + vy.powf(2.0)).sqrt();
598 | if speed > MAX_PLAYER_VELOCITY {
599 | vx = vx * MAX_PLAYER_VELOCITY / speed;
600 | vy = vy * MAX_PLAYER_VELOCITY / speed;
601 | }
602 | self.velocity = (vx, vy);
603 | }
604 |
605 | fn size(&self) -> (f32, f32) {
606 | match self.orientation {
607 | Orientation::Up | Orientation::Down => (20.0, 8.0),
608 | Orientation::Left | Orientation::Right => (8.0, 20.0),
609 | _ => (15.0, 15.0),
610 | }
611 | }
612 |
613 | fn minimal_collision_rect(&self) -> Rect {
614 | let (x, y) = self.position();
615 |
616 | match self.orientation {
617 | Orientation::Up => Rect {
618 | x: x as u16,
619 | y: y as u16,
620 | width: 14,
621 | height: 8,
622 | },
623 | Orientation::UpLeft => Rect {
624 | x: x as u16,
625 | y: y as u16 + 5,
626 | width: 13,
627 | height: 10,
628 | },
629 | Orientation::Left => Rect {
630 | x: x as u16,
631 | y: y as u16 + 6,
632 | width: 8,
633 | height: 14,
634 | },
635 | Orientation::DownLeft => Rect {
636 | x: x as u16 + 5,
637 | y: y as u16 + 2,
638 | width: 10,
639 | height: 13,
640 | },
641 | Orientation::Down => Rect {
642 | x: x as u16 + 6,
643 | y: y as u16,
644 | width: 14,
645 | height: 8,
646 | },
647 | Orientation::DownRight => Rect {
648 | x: x as u16 + 2,
649 | y: y as u16,
650 | width: 13,
651 | height: 10,
652 | },
653 | Orientation::Right => Rect {
654 | x: x as u16,
655 | y: y as u16,
656 | width: 8,
657 | height: 14,
658 | },
659 | Orientation::UpRight => Rect {
660 | x: x as u16,
661 | y: y as u16,
662 | width: 10,
663 | height: 13,
664 | },
665 | }
666 | }
667 |
668 | fn mass(&self) -> f32 {
669 | PLAYER_MASS
670 | }
671 |
672 | fn update(&mut self, deltatime: f32) {
673 | if let Some(new_orientation) = self.new_orientation {
674 | self.rotate(new_orientation);
675 | }
676 |
677 | let (x, y) = self.position();
678 | let (vx, vy) = self.velocity();
679 | // Apply friction
680 | self.set_velocity((
681 | vx * PLAYER_FRICTION_VELOCITY_LOSS,
682 | vy * PLAYER_FRICTION_VELOCITY_LOSS,
683 | ));
684 | let (vx, vy) = self.velocity();
685 |
686 | self.set_position((x + vx * deltatime, y + vy * deltatime));
687 |
688 | if self.after_shooting_counter > 0.0 {
689 | self.after_shooting_counter = (self.after_shooting_counter - deltatime).max(0.0);
690 | }
691 |
692 | if self.after_got_stolen_counter > 0.0 {
693 | self.after_got_stolen_counter = (self.after_got_stolen_counter - deltatime).max(0.0);
694 | }
695 | }
696 |
697 | fn image(&self, _: Palette) -> RgbaImage {
698 | match self.side {
699 | GameSide::Red => RED_PLAYER[self.orientation as usize].clone(),
700 | GameSide::Blue => BLUE_PLAYER[self.orientation as usize].clone(),
701 | }
702 | }
703 | }
704 |
705 | #[derive(Clone)]
706 | pub struct Goalie {
707 | side: GameSide,
708 | position: (f32, f32),
709 | velocity: (f32, f32),
710 | saves: usize,
711 | }
712 |
713 | impl Goalie {
714 | pub fn new(side: GameSide) -> Self {
715 | let position = match side {
716 | GameSide::Red => (MIN_X, RED_INITIAL_POSITION.1),
717 | GameSide::Blue => (MAX_X - GOALIE_WIDTH, BLUE_INITIAL_POSITION.1),
718 | };
719 | let velocity = (0.0, 0.0);
720 |
721 | Self {
722 | side,
723 | position,
724 | velocity,
725 | saves: 0,
726 | }
727 | }
728 | }
729 |
730 | impl Body for Goalie {
731 | fn position(&self) -> (f32, f32) {
732 | self.position
733 | }
734 |
735 | fn set_position(&mut self, position: (f32, f32)) {
736 | self.position.1 = position.1.min(GOALIE_MAX_Y).max(GOALIE_MIN_Y);
737 | }
738 |
739 | fn velocity(&self) -> (f32, f32) {
740 | self.velocity
741 | }
742 |
743 | fn set_velocity(&mut self, velocity: (f32, f32)) {
744 | // Goalies only have a vertical velocity
745 | self.velocity = (0.0, velocity.1);
746 | }
747 |
748 | fn size(&self) -> (f32, f32) {
749 | (GOALIE_WIDTH, GOALIE_HEIGHT)
750 | }
751 |
752 | fn full_collision_rect(&self) -> Rect {
753 | match self.side {
754 | GameSide::Red => Rect {
755 | x: MIN_X as u16,
756 | y: GOALIE_MIN_Y as u16 - 1,
757 | width: GOALIE_AREA_WIDTH as u16,
758 | height: GOALIE_AREA_HEIGHT as u16,
759 | },
760 | GameSide::Blue => Rect {
761 | x: (MAX_X - GOALIE_AREA_WIDTH) as u16,
762 | y: GOALIE_MIN_Y as u16 - 1,
763 | width: GOALIE_AREA_WIDTH as u16,
764 | height: GOALIE_AREA_HEIGHT as u16,
765 | },
766 | }
767 | }
768 |
769 | fn mass(&self) -> f32 {
770 | GOALIE_MASS
771 | }
772 |
773 | fn update(&mut self, _deltatime: f32) {}
774 |
775 | fn image(&self, _: Palette) -> RgbaImage {
776 | match self.side {
777 | GameSide::Red => RED_GOALIE.clone(),
778 | GameSide::Blue => BLUE_GOALIE.clone(),
779 | }
780 | }
781 | }
782 |
783 | #[derive(Clone)]
784 | pub struct Client {
785 | id: usize,
786 | terminal: SshTerminal,
787 | is_connected: bool,
788 | palette: Palette,
789 | }
790 |
791 | impl Client {
792 | pub fn new(id: usize, terminal: SshTerminal) -> Self {
793 | Self {
794 | id,
795 | terminal,
796 | is_connected: true,
797 | palette: Palette::Dark,
798 | }
799 | }
800 |
801 | pub fn clear(&mut self) -> AppResult<()> {
802 | if self.is_connected {
803 | self.terminal.draw(|f| {
804 | let mut lines = vec![];
805 | for _ in 0..f.size().height {
806 | lines.push(Line::from(" ".repeat(f.size().width.into())));
807 | }
808 | let clear = Paragraph::new(lines).style(Color::White);
809 | f.render_widget(clear, f.size());
810 | })?;
811 | }
812 | Ok(())
813 | }
814 | }
815 |
816 | #[derive(Clone)]
817 | pub struct Game {
818 | red_client: Client,
819 | blue_client: Client,
820 | red_player: Player,
821 | blue_player: Player,
822 | red_goalie: Goalie,
823 | blue_goalie: Goalie,
824 | red_score: u8,
825 | blue_score: u8,
826 | puck: Puck,
827 | skate_traces: Vec<(f32, f32)>,
828 | pub id: uuid::Uuid,
829 | timer: u128,
830 | last_tick: Instant,
831 | fps: f32,
832 | state: GameState,
833 | }
834 |
835 | impl Game {
836 | pub fn new(red_client: (usize, SshTerminal), blue_client: (usize, SshTerminal)) -> Self {
837 | let mut game = Self {
838 | red_client: Client::new(red_client.0, red_client.1),
839 | blue_client: Client::new(blue_client.0, blue_client.1),
840 | red_player: Player::new(GameSide::Red),
841 | blue_player: Player::new(GameSide::Blue),
842 | red_goalie: Goalie::new(GameSide::Red),
843 | blue_goalie: Goalie::new(GameSide::Blue),
844 | red_score: 0,
845 | blue_score: 0,
846 | puck: Puck::new(),
847 | skate_traces: vec![],
848 | id: uuid::Uuid::new_v4(),
849 | timer: 0,
850 | last_tick: Instant::now(),
851 | fps: 0.0,
852 | state: GameState::Starting {
853 | time: Instant::now(),
854 | },
855 | };
856 |
857 | game.red_client
858 | .clear()
859 | .unwrap_or_else(|e| log::error!("Failed to clear red client terminal: {e}"));
860 | game.blue_client
861 | .clear()
862 | .unwrap_or_else(|e| log::error!("Failed to clear red client terminal: {e}"));
863 | game
864 | }
865 |
866 | pub fn clear_client(&mut self, client_id: usize) {
867 | if self.red_client.id == client_id {
868 | self.red_client
869 | .clear()
870 | .unwrap_or_else(|e| log::error!("Failed to clear red client terminal: {e}"));
871 | } else {
872 | self.blue_client
873 | .clear()
874 | .unwrap_or_else(|e| log::error!("Failed to clear red client terminal: {e}"));
875 | }
876 | }
877 |
878 | fn reset(&mut self) {
879 | self.red_player.reset();
880 | self.blue_player.reset();
881 | self.puck = Puck::new();
882 | self.state = GameState::Starting {
883 | time: Instant::now(),
884 | };
885 | self.skate_traces.clear();
886 | }
887 |
888 | fn close(&mut self) {
889 | self.red_client.is_connected = false;
890 | self.blue_client.is_connected = false;
891 | }
892 |
893 | pub fn disconnect(&mut self, client_id: usize) {
894 | if self.red_client.id == client_id {
895 | self.red_client.is_connected = false;
896 | } else {
897 | self.blue_client.is_connected = false;
898 | }
899 | }
900 |
901 | pub fn is_over(&self) -> bool {
902 | matches!(self.state, GameState::Ending { .. })
903 | }
904 |
905 | pub fn is_running(&self) -> bool {
906 | self.red_client.is_connected && self.blue_client.is_connected
907 | }
908 |
909 | pub fn client_ids(&self) -> (usize, usize) {
910 | (self.red_client.id, self.blue_client.id)
911 | }
912 |
913 | pub fn handle_input(&mut self, client_id: usize, key_code: KeyCode) {
914 | if key_code == KeyCode::Esc {
915 | self.disconnect(client_id);
916 | return;
917 | }
918 |
919 | if key_code == KeyCode::Char('p') {
920 | if self.red_client.id == client_id {
921 | self.red_client.palette = self.red_client.palette.next();
922 | } else {
923 | self.blue_client.palette = self.blue_client.palette.next();
924 | }
925 | return;
926 | }
927 |
928 | if self.state != GameState::Running {
929 | return;
930 | }
931 |
932 | let player = if self.red_client.id == client_id {
933 | &mut self.red_player
934 | } else {
935 | &mut self.blue_player
936 | };
937 |
938 | if player.shooting_counter > 0.0 {
939 | let mut shooting_direction = player.shooting_direction.unwrap_or(player.velocity);
940 |
941 | match key_code {
942 | KeyCode::Up => {
943 | shooting_direction = (
944 | shooting_direction.0,
945 | shooting_direction.1 - SHOOTING_DIRECTION_MODIFIER,
946 | );
947 | }
948 | KeyCode::Down => {
949 | shooting_direction = (
950 | shooting_direction.0,
951 | shooting_direction.1 + SHOOTING_DIRECTION_MODIFIER,
952 | );
953 | }
954 | KeyCode::Left => {
955 | shooting_direction = (
956 | shooting_direction.0 - SHOOTING_DIRECTION_MODIFIER,
957 | shooting_direction.1,
958 | );
959 | }
960 | KeyCode::Right => {
961 | shooting_direction = (
962 | shooting_direction.0 + SHOOTING_DIRECTION_MODIFIER,
963 | shooting_direction.1,
964 | );
965 | }
966 | _ => {}
967 | }
968 | player.shooting_direction = Some(shooting_direction);
969 | } else {
970 | // Shooting
971 | if key_code == KeyCode::Char(' ') && player.after_shooting_counter == 0.0 {
972 | if let Some(side) = self.puck.possession {
973 | if side == player.side {
974 | player.shooting_counter = SHOOTING_COUNTER_MILLISECONDS;
975 | player.velocity.0 *= 0.85;
976 | player.velocity.1 *= 0.85;
977 | self.puck.velocity.0 *= 0.85;
978 | self.puck.velocity.1 *= 0.85;
979 | player.new_orientation = Some(player.orientation.previous());
980 | // Set shooting direction to the current orientation
981 | // offset by 1 so that we shoot in the movement direction
982 | player.shooting_direction = match player.orientation {
983 | Orientation::Up => Some((1.0, -1.0).normalize()),
984 | Orientation::UpLeft => Some((0.0, -1.0).normalize()),
985 | Orientation::Left => Some((-1.0, -1.0).normalize()),
986 | Orientation::DownLeft => Some((-1.0, 0.0).normalize()),
987 | Orientation::Down => Some((-1.0, 1.0).normalize()),
988 | Orientation::DownRight => Some((0.0, 1.0).normalize()),
989 | Orientation::Right => Some((1.0, 1.0).normalize()),
990 | Orientation::UpRight => Some((1.0, 0.0).normalize()),
991 | };
992 | }
993 | }
994 | } else {
995 | // Movement
996 | let current_speed = player.velocity.magnitude();
997 |
998 | let natural_orientation = match key_code {
999 | KeyCode::Up => {
1000 | if player.velocity.1 > 0.0 {
1001 | player.velocity.1 -= DECELERATION;
1002 | } else {
1003 | player.velocity.1 -= ACCELERATION;
1004 | }
1005 | Orientation::UpLeft
1006 | }
1007 | KeyCode::Down => {
1008 | if player.velocity.1 < 0.0 {
1009 | player.velocity.1 += DECELERATION;
1010 | } else {
1011 | player.velocity.1 += ACCELERATION;
1012 | }
1013 | Orientation::DownRight
1014 | }
1015 | KeyCode::Left => {
1016 | if player.velocity.0 > 0.0 {
1017 | player.velocity.0 -= DECELERATION;
1018 | } else {
1019 | player.velocity.0 -= ACCELERATION;
1020 | }
1021 | Orientation::DownLeft
1022 | }
1023 | KeyCode::Right => {
1024 | if player.velocity.0 < 0.0 {
1025 | player.velocity.0 += DECELERATION;
1026 | } else {
1027 | player.velocity.0 += ACCELERATION;
1028 | }
1029 | Orientation::UpRight
1030 | }
1031 | _ => player.orientation,
1032 | };
1033 |
1034 | // If player current orientation is not the natural orientation,
1035 | // try to align one step at the time
1036 | if current_speed > 0.0 && player.orientation != natural_orientation {
1037 | let diff = (natural_orientation as isize - player.orientation as isize + 8) % 8;
1038 | if diff > 4 {
1039 | player.new_orientation = Some(player.orientation.previous());
1040 | } else {
1041 | player.new_orientation = Some(player.orientation.next());
1042 | }
1043 | }
1044 | }
1045 | }
1046 | }
1047 |
1048 | pub fn update(&mut self) -> AppResult<()> {
1049 | let now = Instant::now();
1050 | let deltatime = now.duration_since(self.last_tick).as_millis() as f32;
1051 | if deltatime < MINIMUM_DELTATIME_MILLISECONDS {
1052 | return Ok(());
1053 | }
1054 |
1055 | match self.state {
1056 | GameState::Starting { time } => {
1057 | if now.duration_since(time).as_millis() >= STARTING_DELAY_MILLISECONDS {
1058 | self.state = GameState::Running;
1059 | }
1060 | }
1061 | GameState::Running => {
1062 | self.update_running(deltatime)?;
1063 | self.timer += deltatime as u128;
1064 | if self.timer > GAME_DURATION_MILLISECONDS {
1065 | self.state = GameState::Ending {
1066 | time: Instant::now(),
1067 | };
1068 | }
1069 | }
1070 | GameState::AfterGoal { time, scored: _ } => {
1071 | if now.duration_since(time).as_millis() >= AFTER_GOAL_DELAY_MILLISECONDS {
1072 | self.reset();
1073 | }
1074 | }
1075 | GameState::Ending { time } => {
1076 | if now.duration_since(time).as_millis() >= ENDING_DELAY_MILLISECONDS {
1077 | self.close();
1078 | }
1079 | }
1080 | }
1081 | self.fps = 1000.0 / deltatime;
1082 | self.last_tick = now;
1083 |
1084 | Ok(())
1085 | }
1086 |
1087 | fn update_running(&mut self, deltatime: f32) -> AppResult<()> {
1088 | let red_previous_position = self.red_player.position;
1089 | let red_previous_orientation = self.red_player.orientation;
1090 | let blue_previous_position = self.blue_player.position;
1091 | let blue_previous_orientation = self.blue_player.orientation;
1092 |
1093 | let normalized_deltatime = deltatime / MINIMUM_DELTATIME_MILLISECONDS;
1094 |
1095 | self.red_player.update(normalized_deltatime);
1096 | let red_goalie_head_position_y =
1097 | self.red_player.position.1 + self.red_player.head_position_offset().1 as f32 - 2.0; // -2 is the goalie head offset.
1098 | self.red_goalie
1099 | .set_position((MIN_X, red_goalie_head_position_y));
1100 | self.red_goalie.set_velocity(self.red_player.velocity);
1101 |
1102 | self.blue_player.update(normalized_deltatime);
1103 | let blue_goalie_head_position_y =
1104 | self.blue_player.position.1 + self.blue_player.head_position_offset().1 as f32 - 2.0; // -2 is the goalie head offset.
1105 | self.blue_goalie
1106 | .set_position((MAX_X - GOALIE_WIDTH, blue_goalie_head_position_y));
1107 | self.blue_goalie.set_velocity(self.blue_player.velocity);
1108 | self.puck.update(normalized_deltatime);
1109 |
1110 | // Check collisions between players
1111 | if resolve_collision(
1112 | &mut self.red_player,
1113 | &mut self.blue_player,
1114 | CollisionType::Minimal,
1115 | CollisionType::Minimal,
1116 | ) {
1117 | self.red_player.rotate(red_previous_orientation);
1118 | self.red_player.set_position(red_previous_position);
1119 | self.blue_player.rotate(blue_previous_orientation);
1120 | self.blue_player.set_position(blue_previous_position);
1121 | }
1122 |
1123 | // Check collision between red and goalies.
1124 | if resolve_collision(
1125 | &mut self.red_player,
1126 | &mut self.red_goalie,
1127 | CollisionType::Minimal,
1128 | CollisionType::Full,
1129 | ) || resolve_collision(
1130 | &mut self.red_player,
1131 | &mut self.red_goalie,
1132 | CollisionType::Full,
1133 | CollisionType::Minimal,
1134 | ) || resolve_collision(
1135 | &mut self.red_player,
1136 | &mut self.blue_goalie,
1137 | CollisionType::Minimal,
1138 | CollisionType::Full,
1139 | ) || resolve_collision(
1140 | &mut self.red_player,
1141 | &mut self.blue_goalie,
1142 | CollisionType::Full,
1143 | CollisionType::Minimal,
1144 | ) {
1145 | self.red_player.rotate(red_previous_orientation);
1146 | self.red_player.set_position(red_previous_position);
1147 | self.red_player.set_velocity((0.0, 0.0));
1148 | }
1149 |
1150 | // Check collision between blue and goalies
1151 | if resolve_collision(
1152 | &mut self.blue_player,
1153 | &mut self.blue_goalie,
1154 | CollisionType::Minimal,
1155 | CollisionType::Full,
1156 | ) || resolve_collision(
1157 | &mut self.blue_player,
1158 | &mut self.blue_goalie,
1159 | CollisionType::Full,
1160 | CollisionType::Minimal,
1161 | ) || resolve_collision(
1162 | &mut self.blue_player,
1163 | &mut self.red_goalie,
1164 | CollisionType::Minimal,
1165 | CollisionType::Full,
1166 | ) || resolve_collision(
1167 | &mut self.blue_player,
1168 | &mut self.red_goalie,
1169 | CollisionType::Full,
1170 | CollisionType::Minimal,
1171 | ) {
1172 | self.blue_player.rotate(blue_previous_orientation);
1173 | self.blue_player.set_position(blue_previous_position);
1174 | self.blue_player.set_velocity((0.0, 0.0));
1175 | }
1176 |
1177 | if self.red_player.position != red_previous_position {
1178 | let head_position = (
1179 | self.red_player.position.0 + self.red_player.head_position_offset().0 as f32,
1180 | self.red_player.position.1 + self.red_player.head_position_offset().1 as f32,
1181 | );
1182 | self.skate_traces.push(head_position);
1183 | }
1184 | if self.blue_player.position != blue_previous_position {
1185 | let head_position = (
1186 | self.blue_player.position.0 + self.blue_player.head_position_offset().0 as f32,
1187 | self.blue_player.position.1 + self.blue_player.head_position_offset().1 as f32,
1188 | );
1189 | self.skate_traces.push(head_position);
1190 | }
1191 |
1192 | while self.skate_traces.len() > SKATE_TRACE_LENGTH {
1193 | self.skate_traces.remove(0);
1194 | }
1195 |
1196 | let puck_previous_position = self.puck.position;
1197 | // Check collision between puck and goalies
1198 | // FIXME: sometimes puck is tucked inside goalie
1199 | if resolve_collision(
1200 | &mut self.puck,
1201 | &mut self.red_goalie,
1202 | CollisionType::Minimal,
1203 | CollisionType::Minimal,
1204 | ) {
1205 | self.puck.set_position(puck_previous_position);
1206 | self.red_goalie.saves += 1;
1207 | } else if resolve_collision(
1208 | &mut self.puck,
1209 | &mut self.blue_goalie,
1210 | CollisionType::Minimal,
1211 | CollisionType::Minimal,
1212 | ) {
1213 | self.puck.set_position(puck_previous_position);
1214 | self.blue_goalie.saves += 1;
1215 | }
1216 |
1217 | // Check collision between puck and players
1218 | if resolve_collision(
1219 | &mut self.puck,
1220 | &mut self.red_player,
1221 | CollisionType::Minimal,
1222 | CollisionType::Minimal,
1223 | ) || resolve_collision(
1224 | &mut self.puck,
1225 | &mut self.blue_player,
1226 | CollisionType::Minimal,
1227 | CollisionType::Minimal,
1228 | ) {
1229 | self.puck.set_position(puck_previous_position);
1230 | }
1231 |
1232 | // Check for goals!
1233 | match self.puck.has_scored() {
1234 | Some(GameSide::Red) => {
1235 | self.red_score += 1;
1236 | self.state = GameState::AfterGoal {
1237 | time: Instant::now(),
1238 | scored: GameSide::Red,
1239 | };
1240 | return Ok(());
1241 | }
1242 | Some(GameSide::Blue) => {
1243 | self.blue_score += 1;
1244 | self.state = GameState::AfterGoal {
1245 | time: Instant::now(),
1246 | scored: GameSide::Blue,
1247 | };
1248 | return Ok(());
1249 | }
1250 | None => {}
1251 | }
1252 |
1253 | // Logic related to puck possession
1254 | match self.puck.possession {
1255 | Some(GameSide::Red) => {
1256 | if self.puck.can_be_stolen_by_player(&self.blue_player) {
1257 | self.puck.possession = Some(GameSide::Blue);
1258 | self.red_player.after_got_stolen_counter =
1259 | AFTER_GOT_STOLEN_COUNTER_MILLISECONDS;
1260 | }
1261 | }
1262 | Some(GameSide::Blue) => {
1263 | if self.puck.can_be_stolen_by_player(&self.red_player) {
1264 | self.puck.possession = Some(GameSide::Red);
1265 | self.blue_player.after_got_stolen_counter =
1266 | AFTER_GOT_STOLEN_COUNTER_MILLISECONDS;
1267 | }
1268 | }
1269 | None => {
1270 | match (
1271 | self.puck.can_be_catched_by_player(&self.red_player),
1272 | self.puck.can_be_catched_by_player(&self.blue_player),
1273 | ) {
1274 | (true, true) => {
1275 | // Puck goes to the fastest moving player
1276 | if self.red_player.velocity.magnitude()
1277 | > self.blue_player.velocity.magnitude()
1278 | {
1279 | self.puck.possession = Some(GameSide::Red);
1280 | } else if self.blue_player.velocity.magnitude()
1281 | > self.red_player.velocity.magnitude()
1282 | {
1283 | self.puck.possession = Some(GameSide::Blue);
1284 | } else {
1285 | // do nothing
1286 | }
1287 | }
1288 | (true, false) => {
1289 | self.puck.possession = Some(GameSide::Red);
1290 | }
1291 | (false, true) => {
1292 | self.puck.possession = Some(GameSide::Blue);
1293 | }
1294 | (false, false) => {
1295 | self.puck.possession = None;
1296 | }
1297 | }
1298 | }
1299 | }
1300 |
1301 | // Puck positioning logic.
1302 | // If the puck is in possession, it follows the player unless the player is shooting.
1303 | if let Some(side) = self.puck.possession {
1304 | let (player, other) = if side == GameSide::Red {
1305 | (&mut self.red_player, &mut self.blue_player)
1306 | } else {
1307 | (&mut self.blue_player, &mut self.red_player)
1308 | };
1309 |
1310 | if player.shooting_counter > 0.0 {
1311 | player.shooting_counter -= deltatime;
1312 | // If the player is shooting counter went to 0, the puck follows the shooting direction.
1313 | if player.shooting_counter <= 0.0 {
1314 | player.shooting_counter = 0.0;
1315 | player.after_shooting_counter = AFTER_SHOOTING_COUNTER_MILLISECONDS;
1316 | player.new_orientation = Some(((player.orientation as u8 + 1) % 8).into());
1317 | self.puck.possession = None;
1318 |
1319 | // FIXME: put shooting direction and counter together in a single variable
1320 | self.puck.velocity = player
1321 | .shooting_direction
1322 | .unwrap_or(player.velocity)
1323 | .mul(SHOOTING_POWER);
1324 |
1325 | player.shooting_direction = None;
1326 | }
1327 | } else {
1328 | self.puck.attach_to_player(&player);
1329 | }
1330 |
1331 | if other.shooting_counter > 0.0 {
1332 | other.shooting_counter = 0.0;
1333 | other.shooting_direction = None;
1334 | }
1335 | }
1336 |
1337 | Ok(())
1338 | }
1339 |
1340 | pub fn draw(&mut self) -> AppResult<()> {
1341 | let timer = if self.timer > GAME_DURATION_MILLISECONDS {
1342 | 0
1343 | } else {
1344 | (GAME_DURATION_MILLISECONDS - self.timer) / 1000
1345 | };
1346 |
1347 | if self.red_client.is_connected {
1348 | if self
1349 | .red_client
1350 | .terminal
1351 | .draw(|f| {
1352 | Self::render(
1353 | f,
1354 | self.red_client.palette,
1355 | &self.red_player,
1356 | &self.red_goalie,
1357 | &self.blue_player,
1358 | &self.blue_goalie,
1359 | &self.puck,
1360 | &self.skate_traces,
1361 | self.red_score,
1362 | self.blue_score,
1363 | self.red_goalie.saves,
1364 | self.blue_goalie.saves,
1365 | timer,
1366 | self.fps,
1367 | self.state,
1368 | GameSide::Red,
1369 | )
1370 | .unwrap_or_else(|e| {
1371 | log::error!("Failed to draw game: {}", e);
1372 | })
1373 | })
1374 | .is_err()
1375 | {
1376 | self.red_client.is_connected = false;
1377 | }
1378 | }
1379 | if self.blue_client.is_connected {
1380 | if self
1381 | .blue_client
1382 | .terminal
1383 | .draw(|f| {
1384 | Self::render(
1385 | f,
1386 | self.blue_client.palette,
1387 | &self.red_player,
1388 | &self.red_goalie,
1389 | &self.blue_player,
1390 | &self.blue_goalie,
1391 | &self.puck,
1392 | &self.skate_traces,
1393 | self.red_score,
1394 | self.blue_score,
1395 | self.red_goalie.saves,
1396 | self.blue_goalie.saves,
1397 | timer,
1398 | self.fps,
1399 | self.state,
1400 | GameSide::Blue,
1401 | )
1402 | .unwrap_or_else(|e| {
1403 | log::error!("Failed to draw game: {}", e);
1404 | })
1405 | })
1406 | .is_err()
1407 | {
1408 | self.blue_client.is_connected = false;
1409 | }
1410 | }
1411 |
1412 | Ok(())
1413 | }
1414 |
1415 | fn render(
1416 | frame: &mut Frame,
1417 | palette: Palette,
1418 | red_player: &impl Body,
1419 | red_goalie: &impl Body,
1420 | blue_player: &impl Body,
1421 | blue_goalie: &impl Body,
1422 | puck: &impl Body,
1423 | skate_traces: &[(f32, f32)],
1424 | red_score: u8,
1425 | blue_score: u8,
1426 | red_saves: usize,
1427 | blue_saves: usize,
1428 | timer: u128,
1429 | fps: f32,
1430 | state: GameState,
1431 | rules_side: GameSide,
1432 | ) -> AppResult<()> {
1433 | let split =
1434 | Layout::vertical([Constraint::Length(7), Constraint::Min(1)]).split(frame.size());
1435 |
1436 | let mut img = base_image(palette);
1437 |
1438 | for (x, y) in skate_traces {
1439 | img.put_pixel(*x as u32, *y as u32, skate_trace_color(palette));
1440 | }
1441 |
1442 | img.copy_non_trasparent_from(
1443 | &red_player.image(palette),
1444 | red_player.position().0 as u32,
1445 | red_player.position().1 as u32,
1446 | )?;
1447 |
1448 | img.copy_non_trasparent_from(
1449 | &red_goalie.image(palette),
1450 | red_goalie.position().0 as u32,
1451 | red_goalie.position().1 as u32,
1452 | )?;
1453 |
1454 | img.copy_non_trasparent_from(
1455 | &blue_player.image(palette),
1456 | blue_player.position().0 as u32,
1457 | blue_player.position().1 as u32,
1458 | )?;
1459 | img.copy_non_trasparent_from(
1460 | &blue_goalie.image(palette),
1461 | blue_goalie.position().0 as u32,
1462 | blue_goalie.position().1 as u32,
1463 | )?;
1464 |
1465 | img.copy_non_trasparent_from(
1466 | &puck.image(palette),
1467 | puck.position().0 as u32,
1468 | puck.position().1 as u32,
1469 | )?;
1470 |
1471 | let paragraph = Paragraph::new(img_to_lines(&img));
1472 | frame.render_widget(paragraph, split[1]);
1473 |
1474 | let info_rect = Rect::new(frame.size().width - 20, frame.size().height - 1, 10, 1);
1475 | frame.render_widget(Paragraph::new(format!("FPS:{}", fps as u32)), info_rect);
1476 |
1477 | let top_split = Layout::horizontal([
1478 | Constraint::Length(20),
1479 | Constraint::Length(43),
1480 | Constraint::Length(34),
1481 | Constraint::Length(43),
1482 | Constraint::Length(20),
1483 | ])
1484 | .split(Rect {
1485 | x: 0,
1486 | y: 1,
1487 | width: frame.size().width,
1488 | height: 6,
1489 | });
1490 |
1491 | let red_score_paragraph = red_score.big_font_styled(Color::Red, Color::Yellow);
1492 |
1493 | let horizontal = if red_score < 10 { 5 } else { 1 };
1494 | let area = top_split[0].inner(&Margin {
1495 | horizontal,
1496 | vertical: 0,
1497 | });
1498 | frame.render_widget(red_score_paragraph, area);
1499 |
1500 | match rules_side {
1501 | GameSide::Red => {
1502 | frame.render_widget(
1503 | Paragraph::new(vec![
1504 | Line::from(format!("Saves {}", red_saves)),
1505 | Line::from("← ↑ → ↓: move"),
1506 | Line::from("space: shoot"),
1507 | Line::from("p: change palette"),
1508 | Line::from("Esc: close game"),
1509 | ])
1510 | .centered(),
1511 | top_split[1],
1512 | );
1513 | frame.render_widget(
1514 | Paragraph::new(format!("Saves {}", blue_saves)).centered(),
1515 | top_split[3],
1516 | );
1517 | }
1518 | GameSide::Blue => {
1519 | frame.render_widget(
1520 | Paragraph::new(format!("Saves {}", red_saves)).centered(),
1521 | top_split[1],
1522 | );
1523 | frame.render_widget(
1524 | Paragraph::new(vec![
1525 | Line::from(format!("Saves {}", blue_saves)),
1526 | Line::from("← ↑ → ↓: move"),
1527 | Line::from("space: shoot"),
1528 | Line::from("p: change palette"),
1529 | Line::from("Esc: close game"),
1530 | ])
1531 | .centered(),
1532 | top_split[3],
1533 | );
1534 | }
1535 | }
1536 |
1537 | let blue_score_paragraph = blue_score.big_font_styled(Color::Blue, Color::LightMagenta);
1538 | let horizontal = if blue_score < 10 { 5 } else { 1 };
1539 | let area = top_split[4].inner(&Margin {
1540 | horizontal,
1541 | vertical: 0,
1542 | });
1543 | frame.render_widget(blue_score_paragraph, area);
1544 |
1545 | let timer_split = Layout::horizontal([
1546 | Constraint::Length(10),
1547 | Constraint::Length(4),
1548 | Constraint::Length(10),
1549 | Constraint::Length(10),
1550 | ])
1551 | .split(top_split[2]);
1552 |
1553 | let (color_1, color_2) = match palette {
1554 | Palette::Dark => (Color::Cyan, Color::White),
1555 | Palette::Light => (Color::DarkGray, Color::Gray),
1556 | Palette::Basket => (Color::Magenta, Color::LightMagenta),
1557 | Palette::Alt => (Color::Green, Color::Red),
1558 | };
1559 |
1560 | let minutes_paragraph = ((timer / 60) as u8).big_font_styled(color_1, color_2);
1561 | let seconds_tens_paragraph = (((timer % 60) / 10) as u8).big_font_styled(color_1, color_2);
1562 | let seconds_units_paragraph = (((timer % 60) % 10) as u8).big_font_styled(color_1, color_2);
1563 |
1564 | frame.render_widget(minutes_paragraph, timer_split[0]);
1565 | frame.render_widget(dots(color_1, color_2), timer_split[1]);
1566 | frame.render_widget(seconds_tens_paragraph, timer_split[2]);
1567 | frame.render_widget(seconds_units_paragraph, timer_split[3]);
1568 |
1569 | match state {
1570 | GameState::Starting { time } => {
1571 | let rect = Rect::new(
1572 | (MIN_X + MAX_X) as u16 / 2 - 5,
1573 | (MIN_Y + MAX_Y) as u16 / 4 + 5,
1574 | 10,
1575 | 10,
1576 | );
1577 | let elapsed = time.elapsed().as_millis();
1578 | let countdown_paragraph = if STARTING_DELAY_MILLISECONDS > elapsed {
1579 | (((STARTING_DELAY_MILLISECONDS - elapsed) / 1000) as u8 + 1)
1580 | .big_font_styled(color_1, color_2)
1581 | } else {
1582 | Paragraph::new("")
1583 | };
1584 |
1585 | frame.render_widget(countdown_paragraph, rect);
1586 | }
1587 | GameState::AfterGoal { time: _, scored } => {
1588 | let rect = Rect::new(
1589 | (MIN_X + MAX_X) as u16 / 2 - 44,
1590 | (MIN_Y + MAX_Y) as u16 / 4 + 5,
1591 | 88,
1592 | 10,
1593 | );
1594 | let scored = if scored == GameSide::Red {
1595 | red_scored(color_1, color_2)
1596 | } else {
1597 | blue_scored(color_1, color_2)
1598 | };
1599 | frame.render_widget(scored, rect);
1600 | }
1601 | GameState::Ending { .. } => {
1602 | let rect = Rect::new(
1603 | (MIN_X + MAX_X) as u16 / 2 - 36,
1604 | (MIN_Y + MAX_Y) as u16 / 4 + 5,
1605 | 72,
1606 | 10,
1607 | );
1608 | let congrats = if red_score > blue_score {
1609 | red_won(color_1, color_2)
1610 | } else if blue_score > red_score {
1611 | blue_won(color_1, color_2)
1612 | } else {
1613 | draw(color_1, color_2)
1614 | };
1615 | frame.render_widget(congrats, rect);
1616 | }
1617 | _ => {}
1618 | }
1619 |
1620 | Ok(())
1621 | }
1622 |
1623 | pub fn connections_state(&self) -> (bool, bool) {
1624 | (self.red_client.is_connected, self.blue_client.is_connected)
1625 | }
1626 | }
1627 |
1628 | #[cfg(test)]
1629 |
1630 | mod test {
1631 | use super::*;
1632 | use core::time;
1633 | use ratatui::backend::CrosstermBackend;
1634 | use ratatui::Terminal;
1635 |
1636 | #[test]
1637 | fn test_puck_position() {
1638 | let mut player = Player::new(GameSide::Red);
1639 | player.set_position((50.0, 40.0));
1640 | let mut puck = Puck::new();
1641 |
1642 | let offset = puck_catcher_offset(player.orientation);
1643 | puck.set_position((player.position.0 + offset.0, player.position.1 + offset.1));
1644 |
1645 | // create crossterm terminal to stdout
1646 | let backend = CrosstermBackend::new(std::io::stdout());
1647 | let mut terminal = Terminal::new(backend).unwrap();
1648 |
1649 | terminal.clear().unwrap();
1650 |
1651 | let palette = Palette::Dark;
1652 |
1653 | for _ in 0..16 {
1654 | let offset = puck_catcher_offset(player.orientation);
1655 | puck.set_position((player.position.0 + offset.0, player.position.1 + offset.1));
1656 | terminal
1657 | .draw(|frame| {
1658 | let mut img = base_image(palette);
1659 |
1660 | img.copy_non_trasparent_from(
1661 | &player.image(palette),
1662 | player.position().0 as u32,
1663 | player.position().1 as u32,
1664 | )
1665 | .unwrap();
1666 |
1667 | img.copy_non_trasparent_from(
1668 | &puck.image(palette),
1669 | puck.position().0 as u32,
1670 | puck.position().1 as u32,
1671 | )
1672 | .unwrap();
1673 |
1674 | let split = Layout::vertical([Constraint::Length(5), Constraint::Min(1)])
1675 | .split(frame.size());
1676 |
1677 | let info = Paragraph::new(format!("Orientation {}", player.orientation as u8));
1678 | frame.render_widget(info, split[0]);
1679 |
1680 | let paragraph = Paragraph::new(img_to_lines(&img));
1681 | frame.render_widget(paragraph, split[1]);
1682 | })
1683 | .unwrap();
1684 | player.rotate(player.orientation.next());
1685 | std::thread::sleep(time::Duration::from_millis(500));
1686 | }
1687 | }
1688 |
1689 | #[test]
1690 | fn test_player_collision_boxes() {
1691 | let mut full_box_player = Player::new(GameSide::Red);
1692 | full_box_player.set_position((50.0, 40.0));
1693 |
1694 | let mut minimal_box_player = Player::new(GameSide::Blue);
1695 | minimal_box_player.set_position((100.0, 40.0));
1696 |
1697 | // create crossterm terminal to stdout
1698 | let backend = CrosstermBackend::new(std::io::stdout());
1699 | let mut terminal = Terminal::new(backend).unwrap();
1700 |
1701 | terminal.clear().unwrap();
1702 | let palette = Palette::Dark;
1703 |
1704 | for _ in 0..16 {
1705 | terminal
1706 | .draw(|frame| {
1707 | let mut img = base_image(palette);
1708 |
1709 | img.copy_non_trasparent_from(
1710 | &full_box_player.image(palette),
1711 | full_box_player.position().0 as u32,
1712 | full_box_player.position().1 as u32,
1713 | )
1714 | .unwrap();
1715 |
1716 | img.copy_non_trasparent_from(
1717 | &minimal_box_player.image(palette),
1718 | minimal_box_player.position().0 as u32,
1719 | minimal_box_player.position().1 as u32,
1720 | )
1721 | .unwrap();
1722 |
1723 | // Color in white the border of the collision boxes
1724 | let full_box = full_box_player.full_collision_rect();
1725 |
1726 | for x in full_box.x..full_box.x + full_box.width {
1727 | img.put_pixel(
1728 | x as u32,
1729 | full_box.y as u32 - 1,
1730 | image::Rgba([255, 255, 255, 255]),
1731 | );
1732 | img.put_pixel(
1733 | x as u32,
1734 | full_box.y as u32 + full_box.height as u32,
1735 | image::Rgba([255, 255, 255, 255]),
1736 | );
1737 | }
1738 |
1739 | for y in full_box.y..full_box.y + full_box.height {
1740 | img.put_pixel(
1741 | full_box.x as u32 - 1,
1742 | y as u32,
1743 | image::Rgba([255, 255, 255, 255]),
1744 | );
1745 | img.put_pixel(
1746 | full_box.x as u32 + full_box.width as u32,
1747 | y as u32,
1748 | image::Rgba([255, 255, 255, 255]),
1749 | );
1750 | }
1751 | let minimal_box = minimal_box_player.minimal_collision_rect();
1752 |
1753 | for x in minimal_box.x..minimal_box.x + minimal_box.width {
1754 | img.put_pixel(
1755 | x as u32,
1756 | minimal_box.y as u32 - 1,
1757 | image::Rgba([255, 255, 255, 255]),
1758 | );
1759 | img.put_pixel(
1760 | x as u32,
1761 | minimal_box.y as u32 + minimal_box.height as u32,
1762 | image::Rgba([255, 255, 255, 255]),
1763 | );
1764 | }
1765 |
1766 | for y in minimal_box.y..minimal_box.y + minimal_box.height {
1767 | img.put_pixel(
1768 | minimal_box.x as u32 - 1,
1769 | y as u32,
1770 | image::Rgba([255, 255, 255, 255]),
1771 | );
1772 | img.put_pixel(
1773 | minimal_box.x as u32 + minimal_box.width as u32,
1774 | y as u32,
1775 | image::Rgba([255, 255, 255, 255]),
1776 | );
1777 | }
1778 |
1779 | let paragraph = Paragraph::new(img_to_lines(&img));
1780 | frame.render_widget(paragraph, frame.size());
1781 | })
1782 | .unwrap();
1783 | full_box_player.rotate(full_box_player.orientation.next());
1784 | minimal_box_player.rotate(minimal_box_player.orientation.previous());
1785 |
1786 | std::thread::sleep(time::Duration::from_millis(500));
1787 | }
1788 | }
1789 |
1790 | #[test]
1791 | fn test_player_rotation_center() {
1792 | let mut player = Player::new(GameSide::Red);
1793 | player.set_position((50.0, 40.0));
1794 |
1795 | // create crossterm terminal to stdout
1796 | let backend = CrosstermBackend::new(std::io::stdout());
1797 | let mut terminal = Terminal::new(backend).unwrap();
1798 |
1799 | terminal.clear().unwrap();
1800 | let palette = Palette::Dark;
1801 |
1802 | for _ in 0..16 {
1803 | terminal
1804 | .draw(|frame| {
1805 | let mut img = base_image(palette);
1806 |
1807 | img.copy_non_trasparent_from(
1808 | &player.image(palette),
1809 | player.position().0 as u32,
1810 | player.position().1 as u32,
1811 | )
1812 | .unwrap();
1813 |
1814 | let paragraph = Paragraph::new(img_to_lines(&img));
1815 | frame.render_widget(paragraph, frame.size());
1816 | })
1817 | .unwrap();
1818 | let new_orientation = player.orientation.next();
1819 | player.rotate(new_orientation);
1820 | std::thread::sleep(time::Duration::from_millis(500));
1821 | }
1822 | }
1823 |
1824 | #[test]
1825 | fn test_goalie_collision_boxes() {
1826 | let mut red_goalie = Goalie::new(GameSide::Red);
1827 | let mut blue_goalie = Goalie::new(GameSide::Blue);
1828 |
1829 | // create crossterm terminal to stdout
1830 | let backend = CrosstermBackend::new(std::io::stdout());
1831 | let mut terminal = Terminal::new(backend).unwrap();
1832 |
1833 | terminal.clear().unwrap();
1834 | let palette = Palette::Dark;
1835 |
1836 | for idx in 0..40 {
1837 | let dy = if idx < 20 { 1.0 } else { -1.0 };
1838 | red_goalie.set_position((red_goalie.position.0, red_goalie.position.1 + dy));
1839 | blue_goalie.set_position((blue_goalie.position.0, blue_goalie.position.1 + dy));
1840 |
1841 | terminal
1842 | .draw(|frame| {
1843 | let mut img = base_image(palette);
1844 |
1845 | img.copy_non_trasparent_from(
1846 | &red_goalie.image(palette),
1847 | red_goalie.position().0 as u32,
1848 | red_goalie.position().1 as u32,
1849 | )
1850 | .unwrap();
1851 |
1852 | img.copy_non_trasparent_from(
1853 | &blue_goalie.image(palette),
1854 | blue_goalie.position().0 as u32,
1855 | blue_goalie.position().1 as u32,
1856 | )
1857 | .unwrap();
1858 |
1859 | for goalie in [&red_goalie, &blue_goalie].iter() {
1860 | let full_box = goalie.full_collision_rect();
1861 |
1862 | for x in full_box.x..full_box.x + full_box.width {
1863 | img.put_pixel(
1864 | x as u32,
1865 | full_box.y as u32 - 1,
1866 | image::Rgba([255, 255, 0, 255]),
1867 | );
1868 | img.put_pixel(
1869 | x as u32,
1870 | full_box.y as u32 + full_box.height as u32,
1871 | image::Rgba([255, 255, 0, 255]),
1872 | );
1873 | }
1874 |
1875 | for y in full_box.y..full_box.y + full_box.height {
1876 | img.put_pixel(
1877 | full_box.x as u32 - 1,
1878 | y as u32,
1879 | image::Rgba([255, 255, 0, 255]),
1880 | );
1881 | img.put_pixel(
1882 | full_box.x as u32 + full_box.width as u32,
1883 | y as u32,
1884 | image::Rgba([255, 255, 0, 255]),
1885 | );
1886 | }
1887 | let minimal_box = goalie.minimal_collision_rect();
1888 |
1889 | for x in minimal_box.x..minimal_box.x + minimal_box.width {
1890 | img.put_pixel(
1891 | x as u32,
1892 | minimal_box.y as u32 - 1,
1893 | image::Rgba([0, 0, 255, 255]),
1894 | );
1895 | img.put_pixel(
1896 | x as u32,
1897 | minimal_box.y as u32 + minimal_box.height as u32,
1898 | image::Rgba([0, 0, 255, 255]),
1899 | );
1900 | }
1901 |
1902 | for y in minimal_box.y..minimal_box.y + minimal_box.height {
1903 | img.put_pixel(
1904 | minimal_box.x as u32 - 1,
1905 | y as u32,
1906 | image::Rgba([0, 0, 255, 255]),
1907 | );
1908 | img.put_pixel(
1909 | minimal_box.x as u32 + minimal_box.width as u32,
1910 | y as u32,
1911 | image::Rgba([0, 0, 255, 255]),
1912 | );
1913 | }
1914 | }
1915 |
1916 | let paragraph = Paragraph::new(img_to_lines(&img));
1917 | frame.render_widget(paragraph, frame.size());
1918 | })
1919 | .unwrap();
1920 |
1921 | std::thread::sleep(time::Duration::from_millis(200));
1922 | }
1923 | }
1924 |
1925 | #[test]
1926 | fn test_goal_areas() {
1927 | let mut puck = Puck::new();
1928 | puck.set_position((MAX_X as f32 - 20.0, 30.0));
1929 | puck.set_velocity((0.02, 0.0));
1930 | // create crossterm terminal to stdout
1931 | let backend = CrosstermBackend::new(std::io::stdout());
1932 | let mut terminal = Terminal::new(backend).unwrap();
1933 |
1934 | terminal.clear().unwrap();
1935 | let palette = Palette::Dark;
1936 |
1937 | let mut last_tick = Instant::now();
1938 |
1939 | let mut score = 0;
1940 | let mut y = 0.0;
1941 | loop {
1942 | let now = Instant::now();
1943 | let deltatime = now.duration_since(last_tick).as_millis() as f32;
1944 |
1945 | puck.update(deltatime);
1946 |
1947 | terminal
1948 | .draw(|frame| {
1949 | let split = Layout::vertical([Constraint::Length(5), Constraint::Min(1)])
1950 | .split(frame.size());
1951 | let mut img = base_image(palette);
1952 |
1953 | img.copy_non_trasparent_from(
1954 | &puck.image(palette),
1955 | puck.position().0 as u32,
1956 | puck.position().1 as u32,
1957 | )
1958 | .unwrap();
1959 |
1960 | for y in GOALIE_AREA_MIN_Y as u32..=(GOALIE_AREA_MAX_Y - PUCK_HEIGHT) as u32 {
1961 | img.put_pixel(
1962 | (MAX_X - PUCK_WIDTH) as u32,
1963 | y,
1964 | image::Rgba([255, 255, 0, 255]),
1965 | );
1966 | img.put_pixel(MIN_X as u32, y, image::Rgba([255, 255, 0, 255]));
1967 | }
1968 |
1969 | let info = format!("Score {}", score);
1970 | let paragraph = Paragraph::new(info);
1971 | frame.render_widget(paragraph, split[0]);
1972 |
1973 | let paragraph = Paragraph::new(img_to_lines(&img));
1974 | frame.render_widget(paragraph, split[1]);
1975 | })
1976 | .unwrap();
1977 |
1978 | if puck.has_scored().is_some() {
1979 | score += 1;
1980 | y += 1.0;
1981 | puck.set_position((MAX_X as f32 - 20.0, 30.0 + y));
1982 | puck.set_velocity((0.025, 0.0));
1983 | } else if puck.velocity.0 < 0.0 {
1984 | y += 1.0;
1985 | puck.set_position((MAX_X as f32 - 20.0, 30.0 + y));
1986 | puck.set_velocity((0.025, 0.0));
1987 | }
1988 |
1989 | if y > 30.0 {
1990 | break;
1991 | }
1992 |
1993 | std::thread::sleep(time::Duration::from_millis(20));
1994 | last_tick = now;
1995 | }
1996 | }
1997 |
1998 | #[test]
1999 | fn test_puck_possession() {
2000 | let mut red_player = Player::new(GameSide::Red);
2001 | red_player.set_position((50.0, 40.0));
2002 | let mut blue_player = Player::new(GameSide::Blue);
2003 | blue_player.set_position((100.0, 40.0));
2004 | let mut puck = Puck::new();
2005 |
2006 | // create crossterm terminal to stdout
2007 | let backend = CrosstermBackend::new(std::io::stdout());
2008 | let mut terminal = Terminal::new(backend).unwrap();
2009 |
2010 | terminal.clear().unwrap();
2011 | let palette = Palette::Dark;
2012 |
2013 | puck.possession = Some(GameSide::Red);
2014 | puck.attach_to_player(&red_player);
2015 |
2016 | for _ in 0..16 {
2017 | terminal
2018 | .draw(|frame| {
2019 | let mut img = base_image(palette);
2020 |
2021 | img.copy_non_trasparent_from(
2022 | &red_player.image(palette),
2023 | red_player.position().0 as u32,
2024 | red_player.position().1 as u32,
2025 | )
2026 | .unwrap();
2027 |
2028 | img.copy_non_trasparent_from(
2029 | &blue_player.image(palette),
2030 | blue_player.position().0 as u32,
2031 | blue_player.position().1 as u32,
2032 | )
2033 | .unwrap();
2034 |
2035 | img.copy_non_trasparent_from(
2036 | &puck.image(palette),
2037 | puck.position().0 as u32,
2038 | puck.position().1 as u32,
2039 | )
2040 | .unwrap();
2041 |
2042 | //Color in red all pixels within puck full collision rect
2043 | let full_box = puck.full_collision_rect();
2044 | for x in full_box.x..full_box.x + full_box.width {
2045 | for y in full_box.y..full_box.y + full_box.height {
2046 | img.put_pixel(x as u32, y as u32, image::Rgba([255, 0, 0, 255]));
2047 | }
2048 | }
2049 |
2050 | let paragraph = Paragraph::new(img_to_lines(&img));
2051 | frame.render_widget(paragraph, frame.size());
2052 | })
2053 | .unwrap();
2054 | red_player.rotate(red_player.orientation.next());
2055 | puck.attach_to_player(&red_player);
2056 | std::thread::sleep(time::Duration::from_millis(500));
2057 | }
2058 | }
2059 |
2060 | #[test]
2061 | fn test_goalie_collision() {
2062 | let mut puck = Puck::new();
2063 | puck.set_position((MAX_X as f32 - 25.0, 42.0));
2064 | puck.set_velocity((0.85, 0.0));
2065 |
2066 | let mut goalie = Goalie::new(GameSide::Blue);
2067 |
2068 | // create crossterm terminal to stdout
2069 | let backend = CrosstermBackend::new(std::io::stdout());
2070 | let mut terminal = Terminal::new(backend).unwrap();
2071 |
2072 | terminal.clear().unwrap();
2073 | let palette = Palette::Dark;
2074 |
2075 | let mut last_tick = Instant::now();
2076 |
2077 | let start = Instant::now();
2078 |
2079 | loop {
2080 | let now = Instant::now();
2081 | let deltatime = now.duration_since(last_tick).as_millis() as f32;
2082 | let puck_previous_position = puck.position;
2083 | puck.update(deltatime);
2084 |
2085 | if resolve_collision(
2086 | &mut puck,
2087 | &mut goalie,
2088 | CollisionType::Minimal,
2089 | CollisionType::Minimal,
2090 | ) {
2091 | puck.set_position(puck_previous_position);
2092 | }
2093 |
2094 | terminal
2095 | .draw(|frame| {
2096 | let split = Layout::vertical([Constraint::Length(5), Constraint::Min(1)])
2097 | .split(frame.size());
2098 | let mut img = base_image(palette);
2099 |
2100 | img.copy_non_trasparent_from(
2101 | &goalie.image(palette),
2102 | goalie.position().0 as u32,
2103 | goalie.position().1 as u32,
2104 | )
2105 | .unwrap();
2106 |
2107 | img.copy_non_trasparent_from(
2108 | &puck.image(palette),
2109 | puck.position().0 as u32,
2110 | puck.position().1 as u32,
2111 | )
2112 | .unwrap();
2113 |
2114 | let paragraph = Paragraph::new(img_to_lines(&img));
2115 | frame.render_widget(paragraph, split[1]);
2116 | })
2117 | .unwrap();
2118 |
2119 | std::thread::sleep(time::Duration::from_millis(20));
2120 | last_tick = now;
2121 |
2122 | if start.elapsed() > time::Duration::from_secs(5) {
2123 | break;
2124 | }
2125 | }
2126 | }
2127 | }
2128 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod big_text;
2 | pub mod game;
3 | pub mod server;
4 | pub mod types;
5 | pub mod utils;
6 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use clap::{ArgAction, Parser};
2 | use sshattrick::server::GameServer;
3 |
4 | #[derive(Parser, Debug)]
5 | #[clap(name="ssHattrick", about = "Hockey in the terminal via ssh", author, version, long_about = None)]
6 | struct Args {
7 | #[clap(long, short = 'p', action=ArgAction::Set, help = "Set port to listen on")]
8 | port: Option,
9 | }
10 |
11 | #[tokio::main]
12 | async fn main() {
13 | env_logger::builder()
14 | .filter_level(log::LevelFilter::Info)
15 | .init();
16 |
17 | let mut game_server = GameServer::new();
18 |
19 | let port = Args::parse().port.unwrap_or(2020);
20 |
21 | game_server.run(port).await.expect("Failed running server");
22 | }
23 |
--------------------------------------------------------------------------------
/src/server.rs:
--------------------------------------------------------------------------------
1 | use crate::{
2 | game::Game,
3 | types::{AppResult, TerminalHandle},
4 | };
5 | use async_trait::async_trait;
6 | use crossterm::event::KeyCode;
7 | use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal};
8 | use russh::{server::*, Channel, ChannelId};
9 | use russh_keys::key::{KeyPair, PublicKey};
10 | use std::{
11 | collections::HashMap,
12 | fs::File,
13 | io::{Read, Write},
14 | sync::Arc,
15 | time::Instant,
16 | };
17 | use tokio::sync::Mutex;
18 |
19 | const GAME_NAME: &str = "ssHattrick";
20 | const TERMINAL_WIDTH: u16 = 160;
21 | const TERMINAL_HEIGHT: u16 = 50;
22 | const INACTIVITY_TIMEOUT: u64 = 10;
23 |
24 | pub fn save_keys(signing_key: &ed25519_dalek::SigningKey) -> AppResult<()> {
25 | let file = File::create::<&str>("./keys".into())?;
26 | assert!(file.metadata()?.is_file());
27 | let mut buffer = std::io::BufWriter::new(file);
28 | buffer.write(&signing_key.to_bytes())?;
29 | Ok(())
30 | }
31 |
32 | pub fn load_keys() -> AppResult {
33 | let file = File::open::<&str>("./keys".into())?;
34 | let mut buffer = std::io::BufReader::new(file);
35 | let mut buf: [u8; 32] = [0; 32];
36 | buffer.read(&mut buf)?;
37 | Ok(ed25519_dalek::SigningKey::from_bytes(&buf))
38 | }
39 |
40 | fn convert_data_to_key_code(data: &[u8]) -> crossterm::event::KeyCode {
41 | match data {
42 | b"\x1b[A" => crossterm::event::KeyCode::Up,
43 | b"\x1b[B" => crossterm::event::KeyCode::Down,
44 | b"\x1b[C" => crossterm::event::KeyCode::Right,
45 | b"\x1b[D" => crossterm::event::KeyCode::Left,
46 | // ctrl+c is also converted to esc
47 | b"\x03" => crossterm::event::KeyCode::Esc,
48 | b"\x1b" => crossterm::event::KeyCode::Esc,
49 | b"\x0d" => crossterm::event::KeyCode::Enter,
50 | b"\x7f" => crossterm::event::KeyCode::Backspace,
51 | b"\x1b[3~" => crossterm::event::KeyCode::Delete,
52 | b"\x09" => crossterm::event::KeyCode::Tab,
53 | _ => crossterm::event::KeyCode::Char(data[0] as char),
54 | }
55 | }
56 |
57 | #[derive(Clone)]
58 | pub struct GameServer {
59 | clients: Arc>>,
60 | clients_to_game: Arc>>,
61 | client_id: usize,
62 | games: Arc>>,
63 | pending_client: Arc>>,
64 | }
65 |
66 | impl GameServer {
67 | pub fn new() -> Self {
68 | log::info!("Creating new server");
69 | Self {
70 | clients: Arc::new(Mutex::new(HashMap::new())),
71 | clients_to_game: Arc::new(Mutex::new(HashMap::new())),
72 | client_id: 0,
73 | games: Arc::new(Mutex::new(HashMap::new())),
74 | pending_client: Arc::new(Mutex::new(None)),
75 | }
76 | }
77 |
78 | pub async fn run(&mut self, port: u16) -> Result<(), anyhow::Error> {
79 | let games = self.games.clone();
80 | let clients = self.clients.clone();
81 | let pending_client = self.pending_client.clone();
82 | log::info!("Starting game loop");
83 | // TODO (maybe): spawn a new loop for each game. Not sure it's a good idea actually
84 | // To close the loop, check if both are disconnected or the game is over.
85 | tokio::spawn(async move {
86 | loop {
87 | tokio::time::sleep(tokio::time::Duration::from_millis(2)).await;
88 | let mut to_remove = vec![];
89 | for (_, game) in games.lock().await.iter_mut() {
90 | let (red_client_id, blue_client_id) = game.client_ids();
91 | if clients.lock().await.get(&red_client_id).is_none() {
92 | game.disconnect(red_client_id);
93 | }
94 | if clients.lock().await.get(&blue_client_id).is_none() {
95 | game.disconnect(blue_client_id);
96 | }
97 |
98 | log::info!("Connections state: {:?}", game.connections_state());
99 |
100 | if game.connections_state() == (false, false) {
101 | log::info!("Both players disconnected, removing game {}", game.id);
102 | to_remove.push(game.id);
103 | } else {
104 | game.update().unwrap_or_else(|e| {
105 | log::error!("Failed to update game: {:?}", e);
106 | to_remove.push(game.id);
107 | });
108 |
109 | game.draw().unwrap_or_else(|e| {
110 | log::error!("Failed to draw game: {:?}", e);
111 | to_remove.push(game.id);
112 | });
113 | }
114 |
115 | if game.is_over() {
116 | to_remove.push(game.id);
117 | }
118 | }
119 |
120 | for game_id in to_remove {
121 | log::info!("Removing game {game_id}");
122 | games.lock().await.remove(&game_id);
123 | }
124 |
125 | // Remove pending client if it's been waiting for too long
126 | let mut pending_client = pending_client.lock().await;
127 | log::debug!("Pending client: {:?}", pending_client);
128 | if pending_client.is_some() {
129 | let (pending_id, instant) = pending_client.as_ref().unwrap().clone();
130 | if instant.elapsed().as_secs() > INACTIVITY_TIMEOUT {
131 | log::info!("Pending client connection timed out");
132 | clients.lock().await.remove(&pending_id);
133 | *pending_client = None;
134 | }
135 | }
136 | }
137 | });
138 |
139 | let signing_key = load_keys().unwrap_or_else(|_| {
140 | let key_pair = russh_keys::key::KeyPair::generate_ed25519().unwrap();
141 | let signing_key = match key_pair {
142 | KeyPair::Ed25519(key) => key,
143 | };
144 | let _ = save_keys(&signing_key);
145 | signing_key
146 | });
147 |
148 | let key_pair = KeyPair::Ed25519(signing_key);
149 |
150 | let config = Config {
151 | inactivity_timeout: Some(std::time::Duration::from_secs(INACTIVITY_TIMEOUT)),
152 | auth_rejection_time: std::time::Duration::from_secs(3),
153 | auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
154 | keys: vec![key_pair],
155 | ..Default::default()
156 | };
157 |
158 | log::info!("Starting server on port {}", port);
159 |
160 | self.run_on_address(Arc::new(config), ("0.0.0.0", port))
161 | .await?;
162 | Ok(())
163 | }
164 |
165 | async fn close_session(
166 | &mut self,
167 | session: &mut Session,
168 | channel: ChannelId,
169 | ) -> Result<(), anyhow::Error> {
170 | self.clients.lock().await.remove(&self.client_id);
171 | self.clients_to_game.lock().await.remove(&self.client_id);
172 |
173 | session.eof(channel);
174 | session.disconnect(russh::Disconnect::ByApplication, "Quit", "");
175 | session.close(channel);
176 |
177 | let mut pending_client = self.pending_client.lock().await;
178 | if pending_client.is_some() && pending_client.unwrap().0 == self.client_id {
179 | *pending_client = None;
180 | log::info!("Removed player from pending list");
181 | }
182 | Ok(())
183 | }
184 | }
185 |
186 | impl Server for GameServer {
187 | type Handler = Self;
188 | fn new_client(&mut self, _: Option) -> Self {
189 | let s = self.clone();
190 | self.client_id += 1;
191 | s
192 | }
193 | }
194 |
195 | #[async_trait]
196 | impl Handler for GameServer {
197 | type Error = anyhow::Error;
198 |
199 | async fn channel_close(
200 | &mut self,
201 | channel: ChannelId,
202 | session: &mut Session,
203 | ) -> Result<(), Self::Error> {
204 | self.close_session(session, channel).await
205 | }
206 |
207 | async fn channel_eof(
208 | &mut self,
209 | channel: ChannelId,
210 | session: &mut Session,
211 | ) -> Result<(), Self::Error> {
212 | self.close_session(session, channel).await
213 | }
214 |
215 | async fn channel_open_session(
216 | &mut self,
217 | channel: Channel,
218 | session: &mut Session,
219 | ) -> Result {
220 | {
221 | log::info!("Opening new session");
222 | let mut terminal_handle = TerminalHandle::new(session.handle(), channel.id());
223 | let backend = CrosstermBackend::new(terminal_handle.clone());
224 | let terminal = Terminal::with_options(
225 | backend,
226 | ratatui::TerminalOptions {
227 | viewport: ratatui::Viewport::Fixed(Rect {
228 | x: 0,
229 | y: 0,
230 | width: TERMINAL_WIDTH,
231 | height: TERMINAL_HEIGHT,
232 | }),
233 | },
234 | )?;
235 |
236 | let mut clients = self.clients.lock().await;
237 | let mut pending_client_id = self.pending_client.lock().await;
238 |
239 | if pending_client_id.is_some() {
240 | let client_id = pending_client_id.as_ref().unwrap().0.clone();
241 | let pending_handle = clients.get(&client_id).unwrap();
242 | let backend = CrosstermBackend::new(pending_handle.clone());
243 | let pending_terminal = Terminal::with_options(
244 | backend,
245 | ratatui::TerminalOptions {
246 | viewport: ratatui::Viewport::Fixed(Rect {
247 | x: 0,
248 | y: 0,
249 | width: TERMINAL_WIDTH,
250 | height: TERMINAL_HEIGHT,
251 | }),
252 | },
253 | )?;
254 | clients.insert(self.client_id, terminal_handle);
255 | let game = Game::new(
256 | (client_id.clone(), pending_terminal),
257 | (self.client_id, terminal),
258 | );
259 |
260 | self.games.lock().await.insert(game.id, game.clone());
261 | let number_of_games = self.games.lock().await.len();
262 | self.clients_to_game.lock().await.insert(client_id, game.id);
263 | self.clients_to_game
264 | .lock()
265 | .await
266 | .insert(self.client_id, game.id);
267 | log::info!(
268 | "Added player to new game. There {} now {} game{} running",
269 | if number_of_games == 1 { "is" } else { "are" },
270 | number_of_games,
271 | if number_of_games == 1 { "" } else { "s" }
272 | );
273 | *pending_client_id = None;
274 | } else {
275 | *pending_client_id = Some((self.client_id, Instant::now()));
276 | log::info!("Added player to pending list");
277 | terminal_handle.message(
278 | format!(
279 | "Welcome to the {GAME_NAME}! Waiting for another player to join...\r\nIn the meanwhile, remember to set your terminal to a minimum of {TERMINAL_WIDTH}x{TERMINAL_HEIGHT} characters.\r\n\r\nPress Esc to close the game. Your connection will be closed after {INACTIVITY_TIMEOUT} seconds of inactivity.\r\n",
280 | )
281 | .as_str(),
282 | )?;
283 | clients.insert(self.client_id, terminal_handle);
284 | }
285 | }
286 |
287 | Ok(true)
288 | }
289 |
290 | async fn auth_none(&mut self, _: &str) -> Result {
291 | Ok(Auth::Accept)
292 | }
293 |
294 | async fn auth_password(&mut self, _: &str, _: &str) -> Result {
295 | Ok(Auth::Accept)
296 | }
297 |
298 | async fn auth_publickey(&mut self, _: &str, _: &PublicKey) -> Result {
299 | Ok(Auth::Accept)
300 | }
301 |
302 | async fn auth_keyboard_interactive(
303 | &mut self,
304 | _: &str,
305 | _: &str,
306 | _: Option>,
307 | ) -> Result {
308 | Ok(Auth::Accept)
309 | }
310 |
311 | async fn window_change_request(
312 | &mut self,
313 | _: ChannelId,
314 | _: u32,
315 | _: u32,
316 | _: u32,
317 | _: u32,
318 | _: &mut Session,
319 | ) -> Result<(), Self::Error> {
320 | if let Some(game_id) = &mut self.clients_to_game.lock().await.get_mut(&self.client_id) {
321 | if let Some(game) = self.games.lock().await.get_mut(game_id) {
322 | game.clear_client(self.client_id);
323 | }
324 | }
325 | Ok(())
326 | }
327 |
328 | async fn data(
329 | &mut self,
330 | channel: ChannelId,
331 | data: &[u8],
332 | session: &mut Session,
333 | ) -> Result<(), Self::Error> {
334 | let key_code = convert_data_to_key_code(data);
335 |
336 | if key_code == KeyCode::Esc {
337 | self.close_session(session, channel)
338 | .await
339 | .unwrap_or_else(|e| log::error!("Failed to close session: {:?}", e));
340 | return Ok(());
341 | }
342 |
343 | let pending_client = self.pending_client.lock().await;
344 | if pending_client.is_some() && pending_client.unwrap().0 == self.client_id {
345 | return Ok(());
346 | }
347 |
348 | if let Some(game_id) = &mut self.clients_to_game.lock().await.get_mut(&self.client_id) {
349 | if let Some(game) = self.games.lock().await.get_mut(game_id) {
350 | game.handle_input(self.client_id, key_code);
351 | return Ok(());
352 | }
353 | }
354 |
355 | self.clients.lock().await.remove(&self.client_id);
356 | self.clients_to_game.lock().await.remove(&self.client_id);
357 | session.eof(channel);
358 | session.disconnect(russh::Disconnect::ByApplication, "Quit", "");
359 | session.close(channel);
360 |
361 | Ok(())
362 | }
363 | }
364 |
--------------------------------------------------------------------------------
/src/types.rs:
--------------------------------------------------------------------------------
1 | use ratatui::{
2 | backend::CrosstermBackend,
3 | style::{Style, Stylize},
4 | Terminal,
5 | };
6 | use russh::{server::Handle, ChannelId, CryptoVec};
7 | use std::{
8 | fmt::{Debug, Formatter},
9 | io::Write,
10 | time::{SystemTime, UNIX_EPOCH},
11 | };
12 |
13 | pub type AppResult = std::result::Result>;
14 | pub type Tick = u128;
15 | pub type SshTerminal = Terminal>;
16 |
17 | pub trait SystemTimeTick {
18 | fn now() -> Self;
19 | fn from_system_time(time: SystemTime) -> Self;
20 | fn as_system_time(&self) -> SystemTime;
21 | }
22 |
23 | impl SystemTimeTick for Tick {
24 | fn now() -> Self {
25 | SystemTime::now()
26 | .duration_since(UNIX_EPOCH)
27 | .unwrap()
28 | .as_millis()
29 | }
30 |
31 | fn from_system_time(time: SystemTime) -> Tick {
32 | time.duration_since(UNIX_EPOCH).unwrap().as_millis()
33 | }
34 |
35 | fn as_system_time(&self) -> SystemTime {
36 | UNIX_EPOCH + std::time::Duration::from_millis(*self as u64)
37 | }
38 | }
39 |
40 | #[derive(Clone, Copy, PartialEq)]
41 | pub enum GameSide {
42 | Red,
43 | Blue,
44 | }
45 |
46 | impl GameSide {
47 | pub fn bar_style(&self) -> Style {
48 | match self {
49 | GameSide::Red => Style::new().red(),
50 | GameSide::Blue => Style::new().blue(),
51 | }
52 | }
53 | }
54 |
55 | #[derive(Clone)]
56 | pub struct TerminalHandle {
57 | handle: Handle,
58 | // The sink collects the data which is finally flushed to the handle.
59 | sink: Vec,
60 | channel_id: ChannelId,
61 | }
62 |
63 | impl Debug for TerminalHandle {
64 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
65 | f.debug_struct("TerminalHandle")
66 | .field("channel_id", &self.channel_id)
67 | .finish()
68 | }
69 | }
70 |
71 | impl TerminalHandle {
72 | pub fn new(handle: Handle, channel_id: ChannelId) -> Self {
73 | Self {
74 | handle,
75 | sink: Vec::new(),
76 | channel_id,
77 | }
78 | }
79 |
80 | pub async fn close(&self) -> Result<(), ()> {
81 | self.handle.close(self.channel_id).await?;
82 | Ok(())
83 | }
84 |
85 | pub async fn eof(&self) -> Result<(), ()> {
86 | self.handle.eof(self.channel_id).await?;
87 | Ok(())
88 | }
89 |
90 | pub fn message(&mut self, text: &str) -> std::io::Result<()> {
91 | let crypto_vec = CryptoVec::from_slice(text.as_bytes());
92 | self.write(crypto_vec.as_ref())?;
93 | self.flush()?;
94 | Ok(())
95 | }
96 |
97 | async fn _flush(&self) -> std::io::Result {
98 | let handle = self.handle.clone();
99 | let channel_id = self.channel_id.clone();
100 | let data: CryptoVec = self.sink.clone().into();
101 | let data_length = data.len();
102 | let result = handle.data(channel_id, data).await;
103 | if result.is_err() {
104 | log::error!("Failed to send data: {:?}", result);
105 | return Err(std::io::Error::new(
106 | std::io::ErrorKind::Other,
107 | "Failed to send data",
108 | ));
109 | }
110 | log::debug!(
111 | "Sent {} bytes of data to channel {}",
112 | data_length,
113 | channel_id
114 | );
115 | Ok(data_length)
116 | }
117 | }
118 |
119 | // The crossterm backend writes to the terminal handle.
120 | impl std::io::Write for TerminalHandle {
121 | fn write(&mut self, buf: &[u8]) -> std::io::Result {
122 | self.sink.extend_from_slice(buf);
123 | Ok(buf.len())
124 | }
125 |
126 | fn flush(&mut self) -> std::io::Result<()> {
127 | futures::executor::block_on(self._flush())?;
128 | self.sink.clear();
129 | Ok(())
130 | }
131 | }
132 |
133 | pub trait Vector2D {
134 | fn normalize(&self) -> Self;
135 | fn dot(&self, other: &Self) -> f32;
136 | fn magnitude(&self) -> f32;
137 | fn mul(self, rhs: f32) -> Self;
138 | }
139 |
140 | impl Vector2D for (f32, f32) {
141 | fn normalize(&self) -> Self {
142 | let length = self.magnitude();
143 | if length == 0.0 {
144 | return (0.0, 0.0);
145 | }
146 | self.mul(1.0 / length)
147 | }
148 |
149 | fn dot(&self, other: &Self) -> f32 {
150 | let (x1, y1) = self;
151 | let (x2, y2) = other;
152 | x1 * x2 + y1 * y2
153 | }
154 |
155 | fn magnitude(&self) -> f32 {
156 | let (x, y) = self;
157 | (x.powi(2) + y.powi(2)).sqrt()
158 | }
159 |
160 | fn mul(self, rhs: f32) -> Self {
161 | let (x, y) = self;
162 | (x * rhs, y * rhs)
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/utils.rs:
--------------------------------------------------------------------------------
1 | use image::error::{ParameterError, ParameterErrorKind};
2 | use image::io::Reader as ImageReader;
3 | use image::{ImageBuffer, ImageError, ImageResult, Pixel, Rgba, RgbaImage};
4 | use include_dir::{include_dir, Dir};
5 | use ratatui::{
6 | style::{Color, Style},
7 | text::{Line, Span},
8 | };
9 | use std::{error::Error, io::Cursor};
10 |
11 | pub static ASSETS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets/");
12 |
13 | pub trait ExtraImageUtils {
14 | fn copy_non_trasparent_from(
15 | &mut self,
16 | other: &ImageBuffer, Vec>,
17 | x: u32,
18 | y: u32,
19 | ) -> ImageResult<()>;
20 | }
21 |
22 | impl ExtraImageUtils for ImageBuffer, Vec> {
23 | /// Copies all non-transparent the pixels from another image into this image.
24 | ///
25 | /// The other image is copied with the top-left corner of the
26 | /// other image placed at (x, y).
27 | ///
28 | /// In order to copy only a piece of the other image, use [`GenericImageView::view`].
29 | ///
30 | /// You can use [`FlatSamples`] to source pixels from an arbitrary regular raster of channel
31 | /// values, for example from a foreign interface or a fixed image.
32 | ///
33 | /// # Returns
34 | /// Returns an error if the image is too large to be copied at the given position
35 | ///
36 | /// [`GenericImageView::view`]: trait.GenericImageView.html#method.view
37 | /// [`FlatSamples`]: flat/struct.FlatSamples.html
38 | fn copy_non_trasparent_from(
39 | &mut self,
40 | other: &ImageBuffer, Vec>,
41 | x: u32,
42 | y: u32,
43 | ) -> ImageResult<()> {
44 | // Do bounds checking here so we can use the non-bounds-checking
45 | // functions to copy pixels.
46 | if self.width() < other.width() + x || self.height() < other.height() + y {
47 | return Err(ImageError::Parameter(ParameterError::from_kind(
48 | ParameterErrorKind::DimensionMismatch,
49 | )));
50 | }
51 |
52 | for k in 0..other.height() {
53 | for i in 0..other.width() {
54 | let p = other.get_pixel(i, k);
55 | if p[3] > 0 {
56 | self.put_pixel(i + x, k + y, *p);
57 | }
58 | }
59 | }
60 | Ok(())
61 | }
62 | }
63 |
64 | pub fn read_image(path: &str) -> Result> {
65 | let file = ASSETS_DIR.get_file(path);
66 | if file.is_none() {
67 | return Err(format!("File {} not found", path).into());
68 | }
69 | let img = ImageReader::new(Cursor::new(file.unwrap().contents()))
70 | .with_guessed_format()?
71 | .decode()?
72 | .into_rgba8();
73 | Ok(img)
74 | }
75 |
76 | pub fn img_to_lines<'a>(img: &RgbaImage) -> Vec> {
77 | let mut lines: Vec = vec![];
78 | let width = img.width();
79 | let height = img.height();
80 |
81 | for y in (0..height - 1).step_by(2) {
82 | let mut line: Vec = vec![];
83 |
84 | for x in 0..width {
85 | let top_pixel = img.get_pixel(x, y).to_rgba();
86 | let btm_pixel = img.get_pixel(x, y + 1).to_rgba();
87 | if top_pixel[3] == 0 && btm_pixel[3] == 0 {
88 | line.push(Span::raw(" "));
89 | continue;
90 | }
91 |
92 | if top_pixel[3] > 0 && btm_pixel[3] == 0 {
93 | let [r, g, b, _] = top_pixel.0;
94 | let color = Color::Rgb(r, g, b);
95 | line.push(Span::styled("▀", Style::default().fg(color)));
96 | } else if top_pixel[3] == 0 && btm_pixel[3] > 0 {
97 | let [r, g, b, _] = btm_pixel.0;
98 | let color = Color::Rgb(r, g, b);
99 | line.push(Span::styled("▄", Style::default().fg(color)));
100 | } else {
101 | let [fr, fg, fb, _] = top_pixel.0;
102 | let fg_color = Color::Rgb(fr, fg, fb);
103 | let [br, bg, bb, _] = btm_pixel.0;
104 | let bg_color = Color::Rgb(br, bg, bb);
105 | line.push(Span::styled(
106 | "▀",
107 | Style::default().fg(fg_color).bg(bg_color),
108 | ));
109 | }
110 | }
111 | lines.push(Line::from(line));
112 | }
113 | // append last line if height is odd
114 | if height % 2 == 1 {
115 | let mut line: Vec = vec![];
116 | for x in 0..width {
117 | let top_pixel = img.get_pixel(x, height - 1).to_rgba();
118 | if top_pixel[3] == 0 {
119 | line.push(Span::raw(" "));
120 | continue;
121 | }
122 | let [r, g, b, _] = top_pixel.0;
123 | let color = Color::Rgb(r, g, b);
124 | line.push(Span::styled("▀", Style::default().fg(color)));
125 | }
126 | lines.push(Line::from(line));
127 | }
128 |
129 | lines
130 | }
131 |
--------------------------------------------------------------------------------