diff --git a/.gitignore b/.gitignore index 7221e66..8499d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,21 @@ venv/ .pytest_cache/ __pycache__ +*.pyc + +# Ignore build data +build/ +dist/ +*.egg-info/ # Don't commit settings settings.json + +# Don't commit game save +save.json + +# Don't commit docs output +docs/_build + +# Don't commit compiled messages +*.mo diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ca51af..ff5c142 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,11 +1,13 @@ stages: - test - quality-assurance + - build py37: stage: test image: python:3.7-alpine before_script: + - apk add --no-cache gettext - pip install tox script: tox -e py3 @@ -13,6 +15,7 @@ py38: stage: test image: python:3.8-alpine before_script: + - apk add --no-cache gettext - pip install tox script: tox -e py3 @@ -21,6 +24,7 @@ py39: stage: test image: python:3.9-alpine before_script: + - apk add --no-cache gettext - pip install tox script: tox -e py3 @@ -31,3 +35,18 @@ linters: - pip install tox script: tox -e linters allow_failure: true + +build-deb: + image: debian:buster-slim + stage: build + before_script: + - apt-get update && apt-get -y --no-install-recommends install build-essential debmake dh-python debhelper gettext python3-all python3-setuptools + script: + - dpkg-buildpackage + - mkdir build && cp ../*.deb build/ + artifacts: + paths: + - build/*.deb + expire_in: 1 week + only: + - master diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..1bc08a5 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Squirrel Battle + Copyright (C) 2020 ÿnérant, eichhornchen, nicomarg, charlse + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Squirrel Battle Copyright (C) 2020 ÿnérant, eichhornchen, nicomarg, charlse + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 3e3e740..6ad3063 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,15 @@ -[![pipeline status](https://gitlab.crans.org/ynerant/dungeon-battle/badges/master/pipeline.svg)](https://gitlab.crans.org/ynerant/dungeon-battle/-/commits/master) -[![coverage report](https://gitlab.crans.org/ynerant/dungeon-battle/badges/master/coverage.svg)](https://gitlab.crans.org/ynerant/dungeon-battle/-/commits/master) +[![pipeline status](https://gitlab.crans.org/ynerant/squirrel-battle/badges/master/pipeline.svg)](https://gitlab.crans.org/ynerant/squirrel-battle/-/commits/master) +[![coverage report](https://gitlab.crans.org/ynerant/squirrel-battle/badges/master/coverage.svg)](https://gitlab.crans.org/ynerant/squirrel-battle/-/commits/master) +[![Documentation Status](https://readthedocs.org/projects/squirrel-battle/badge/?version=latest)](https://squirrel-battle.readthedocs.io/fr/latest/?badge=latest) +[![PyPI](https://img.shields.io/pypi/v/squirrel-battle)](https://pypi.org/project/squirrel-battle/) +[![PYPI downloads](https://img.shields.io/pypi/dm/squirrel-battle)](https://pypi.org/project/squirrel-battle/) +[![AUR version](https://img.shields.io/aur/version/python-squirrel-battle)](https://aur.archlinux.org/packages/python-squirrel-battle/) +[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt) -# Dungeon Battle +# Squirrel Battle -M1 Software engineering project +Attention aux couteaux des écureuils ! -## Création d'un environnement de développement +## Documentation -Il est toujours préférable de travailler dans un environnement Python isolé du reste de son instalation. - -1. **Installation des dépendances de la distribution.** - Vous devez déjà installer Python et le module qui permet de créer des environnements virtuels. - On donne ci-dessous l'exemple pour une distribution basée sur Debian, mais vous pouvez facilement adapter pour ArchLinux ou autre. - - ```bash - $ sudo apt update - $ sudo apt install --no-install-recommends -y python3-setuptools python3-venv python3-dev git - ``` - -2. **Clonage du dépot** là où vous voulez : - - ```bash - $ git clone git@gitlab.crans.org:ynerant/dungeon-battle.git && cd dungeon-battle - ``` - -3. **Création d'un environment de travail Python décorrélé du système.** - On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système. - - ```bash - $ python3 -m venv env - $ source env/bin/activate # entrer dans l'environnement - (env)$ pip3 install -r requirements.txt - (env)$ deactivate # sortir de l'environnement - ``` +La documentation du projet est présente sur [squirrel-battle.readthedocs.io](https://squirrel-battle.readthedocs.io). diff --git a/debian/75-fix-squirrel-emojis.conf b/debian/75-fix-squirrel-emojis.conf new file mode 100644 index 0000000..f47023e --- /dev/null +++ b/debian/75-fix-squirrel-emojis.conf @@ -0,0 +1,118 @@ + + + + + + + + + emoji + Noto Color Emoji + + + + + + + sans + Noto Color Emoji + + + + serif + Noto Color Emoji + + + + sans-serif + Noto Color Emoji + + + + monospace + Noto Color Emoji + + + + + + + + + + Symbola + + + + + + + + + + Android Emoji + Noto Color Emoji + + + + Apple Color Emoji + Noto Color Emoji + + + + EmojiSymbols + Noto Color Emoji + + + + Emoji Two + Noto Color Emoji + + + + EmojiTwo + Noto Color Emoji + + + + Noto Color Emoji + Noto Color Emoji + + + + Segoe UI Emoji + Noto Color Emoji + + + + Segoe UI Symbol + Noto Color Emoji + + + + Symbola + Noto Color Emoji + + + + Twemoji + Noto Color Emoji + + + + Twemoji Mozilla + Noto Color Emoji + + + + TwemojiMozilla + Noto Color Emoji + + + + Twitter Color Emoji + Noto Color Emoji + + + + diff --git a/debian/README.debian b/debian/README.debian new file mode 100644 index 0000000..6961b22 --- /dev/null +++ b/debian/README.debian @@ -0,0 +1,5 @@ +Squirrel Battle + +Watch out for squirrel's knifes! + + -- Yohann D'ANELLO Thu, 19 Nov 2020 03:30:42 +0100 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..2399e41 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,11 @@ +python3-squirrel-battle (3.14.1) beta; urgency=low + + * Some graphical improvements. + + -- Yohann D'ANELLO Thu, 27 Nov 2020 18:25:42 +0100 + + python3-squirrel-battle (3.14) beta; urgency=low + + * Initial release. + + -- Yohann D'ANELLO Thu, 19 Nov 2020 03:30:42 +0100 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..9a03714 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +10 \ No newline at end of file diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..b59997d --- /dev/null +++ b/debian/control @@ -0,0 +1,16 @@ +Source: python3-squirrel-battle +Section: devel +Priority: optional +Maintainer: ynerant +Build-Depends: debhelper (>=10~), dh-python, gettext, python3-all, python3-setuptools +Depends: fonts-noto-color-emoji +Standards-Version: 4.1.4 +Homepage: https://gitlab.crans.org/ynerant/squirrel-battle +X-Python3-Version: >= 3.6 + +Package: python3-squirrel-battle +Architecture: all +Multi-Arch: foreign +Depends: fonts-noto-color-emoji, ${python3:Depends} +Description: Squirrel Battle + Watch out for squirrel's knifes! \ No newline at end of file diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..6d35767 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,29 @@ + +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: ÿnérant, eichhornchen, nicomarg, charlse +Upstream-Contact: ÿnérant, eichhornchen, nicomarg, charlse +Source: https://gitlab.crans.org/ynerant/squirrel-battle + +Files: * +Copyright: 2020 ÿnérant, eichhornchen, nicomarg, charlse +License: GPL-3+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 3 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 3 can be found in the file + `/usr/share/common-licenses/GPL-3'. diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..628e923 --- /dev/null +++ b/debian/install @@ -0,0 +1,2 @@ +debian/75-fix-squirrel-emojis.conf etc/fonts/conf.avail +debian/75-fix-squirrel-emojis.conf etc/fonts/conf.d \ No newline at end of file diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..e29871f --- /dev/null +++ b/debian/rules @@ -0,0 +1,5 @@ +#!/usr/bin/make -f +export DH_VERBOSE = 1 + +%: + dh $@ --with python3 --buildsystem=pybuild diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..4877d19 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,60 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'Squirrel Battle' +copyright = "2020" +author = "Yohann D'ANELLO,\nMathilde DEPRES,\nNicolas MARGULIES,\nCharles PEYRAT" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx_rtd_theme", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'fr' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] \ No newline at end of file diff --git a/docs/deployment.rst b/docs/deployment.rst new file mode 100644 index 0000000..6bde6f0 --- /dev/null +++ b/docs/deployment.rst @@ -0,0 +1,323 @@ +Déploiement du projet +===================== + +.. _PyPI: https://pypi.org/project/squirrel-battle/ +.. _AUR: https://aur.archlinux.org/packages/python-squirrel-battle/ +.. _Debian: https://gitlab.crans.org/ynerant/squirrel-battle/-/jobs/artifacts/master/raw/build/python3-squirrelbattle_3.14.1_all.deb?job=build-deb +.. _installation: install.html + +À chaque nouvelle version du projet, il est compilé et déployé dans PyPI_, dans +l'AUR_ et un paquet Debian_ est créé, voir la page d'installation_. + + +PyPI +---- + +Définition du paquet +~~~~~~~~~~~~~~~~~~~~ + +.. _setup.py: https://gitlab.crans.org/ynerant/squirrel-battle/-/blob/master/setup.py + +La documentation sur le packaging dans PyPI_ est disponible `ici +`_. + +Le fichier `setup.py`_ contient l'ensemble des instructions d'installation du +paquet ainsi que des détails à fournir à PyPI : + +.. code:: python + + #!/usr/bin/env python3 + import os + + from setuptools import find_packages, setup + + with open("README.md", "r") as f: + long_description = f.read() + + # Compile messages + for language in ["de", "es", "fr"]: + args = ["msgfmt", "--check-format", + "-o", f"squirrelbattle/locale/{language}/LC_MESSAGES" + "/squirrelbattle.mo", + f"squirrelbattle/locale/{language}/LC_MESSAGES" + "/squirrelbattle.po"] + print(f"Compiling {language} messages...") + subprocess.Popen(args) + + setup( + name="squirrel-battle", + version="3.14.1", + author="ÿnérant, eichhornchen, nicomarg, charlse", + author_email="squirrel-battle@crans.org", + description="Watch out for squirrel's knives!", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://gitlab.crans.org/ynerant/squirrel-battle", + packages=find_packages(), + license='GPLv3', + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console :: Curses", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Natural Language :: French", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Games/Entertainment", + ], + python_requires='>=3.6', + include_package_data=True, + package_data={"squirrelbattle": ["assets/*", "locale/*/*/*.mo"]}, + entry_points={ + "console_scripts": [ + "squirrel-battle = squirrelbattle.bootstrap:Bootstrap.run_game", + ] + } + ) + +Ce fichier contient le nom du paquet, sa version, l'auteur et son contact, +sa description en une ligne et sa description longue, le lien d'accueil du projet, +sa licence, ses classificateurs et son exécutable. + +Il commence tout d'abord par compiler les fichiers de `traduction `_. + +Le paramètre ``entry_points`` définit un exécutable nommé ``squirrel-battle``, +qui permet de lancer le jeu. + + +Installation locale du paquet +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +L'installation du paquet localement dans son environnement Python (virtuel ou non) +peut se faire en exécutant ``pip install -e .``. + + +Génération des binaires +~~~~~~~~~~~~~~~~~~~~~~~ + +Les paquets ``setuptools`` (``python3-setuptools`` pour APT, ``python-setuptools`` +pour pacman) et ``wheel`` (``python3-wheel`` pour APT, ``python-wheel`` pour pacman) +sont nécessaires. Une fois installés, il faut appeler la commande : + +.. code:: bash + + python3 setup.py sdist bdist_wheel + +Une fois cela fait, le dossier ``dist/`` contiendra les archives à transmettre à PyPI. + + +Publier sur PyPI +~~~~~~~~~~~~~~~~ + +Il faut avant tout avoir un compte sur PyPI. Dans `votre compte PyPI +`_, il faut générer un jeton d'accès API. + +Dans le fichier ``.pypirc`` dans le répertoire principal de l'utilisateur, +il faut ajouter le jeton d'accès : + +.. code:: + + [pypi] + username = __token__ + password = pypi-my-pypi-api-access-token + +Cela permet de s'authentifier directement par ce jeton. + +Ensuite, il faut installer ``twine``, qui permet de publier sur PyPI. + +Il suffit ensuite d'appeler : + +.. code:: bash + + twine upload dist/* + +pour envoyer le paquet sur PyPI. + + +.. note:: + + À des fins de tests, il est possible d'utiliser le dépôt ``_. + Les différences sont au niveau de l'authentification, où il faut l'en-tête + ``[testpypi]`` dans le ``.pypirc``, et il faut envoyer le paquet avec + ``twine upload --repository testpypi dist/``. + + +Publier dans l'AUR +------------------ + +Fonctionnement du packaging +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. _python-squirrel-battle: https://aur.archlinux.org/packages/python-squirrel-battle/ +.. _python-squirrel-battle-git: https://aur.archlinux.org/packages/python-squirrel-battle-git/ + +Deux paquets sont publiés dans l'AUR (Arch User Repository) : + +- python-squirrel-battle_ +- python-squirrel-battle-git_ + +Le packaging dans Arch Linux se fait en commitant un fichier ``PKGBUILD`` dans +le dépôt à l'adresse ``ssh://aur@aur.archlinux.org/packagename.git``, +en remplaçant ``packagename`` par le nom du paquet. + +Le second paquet compile directement le jeu à partir de la branche ``master`` +du dépôt Git. Le fichier ``PKGBUILD`` dispose de cette structure : + +.. code:: + + # Maintainer: Yohann D'ANELLO + + pkgbase=squirrel-battle + pkgname=python-squirrel-battle-git + pkgver=3.14.1 + pkgrel=1 + pkgdesc="Watch out for squirrel's knives!" + arch=('any') + url="https://gitlab.crans.org/ynerant/squirrel-battle" + license=('GPLv3') + depends=('python') + makedepends=('gettext' 'python-setuptools') + depends=('noto-fonts-emoji') + checkdepends=('python-tox') + ssource=("git+https://gitlab.crans.org/ynerant/squirrel-battle.git") + sha256sums=("SKIP") + + pkgver() { + cd pkgbase + git describe --long --tags | sed -r 's/^v//;s/([^-]*-g)/r\1/;s/-/./g' + } + build() { + cd $pkgbase + python setup.py build + } + + check() { + cd $pkgbase + tox -e py3 + tox -e linters + } + + package() { + cd $pkgbase + python setup.py install --skip-build \ + --optimize=1 \ + --root="${pkgdir}" + install -vDm 644 README.md \ + -t "${pkgdir}/usr/share/doc/${pkgname}" + install -vDm 644 LICENSE -t "${pkgdir}/usr/share/licenses/${pkgname}" + } + +Ces instructions permettent de cloner le dépôt, l'installer et exécuter des tests, +en plus de définir les attributs du paquet. + +Le fichier ``PKGBUILD`` du paquet ``python-squirrel-battle``, synchronisé avec +les releases, est plus ou moins similaire : + +.. code:: + + # Maintainer: Yohann D'ANELLO + + pkgbase=squirrel-battle + pkgname=python-squirrel-battle + pkgver=3.14.1 + pkgrel=1 + pkgdesc="Watch out for squirrel's knives!" + arch=('any') + url="https://gitlab.crans.org/ynerant/squirrel-battle" + license=('GPLv3') + depends=('python') + makedepends=('gettext' 'python-setuptools') + depends=('noto-fonts-emoji') + checkdepends=('python-tox') + source=("https://gitlab.crans.org/ynerant/squirrel-battle/-/archive/v3.14.1/$pkgbase-v$pkgver.tar.gz") + sha256sums=("6090534d598c0b3a8f5acdb553c12908ba8107d62d08e17747d1dbb397bddef0") + + build() { + cd $pkgbase-v$pkgver + python setup.py build + } + + check() { + cd $pkgbase-v$pkgver + tox -e py3 + tox -e linters + } + + package() { + cd $pkgbase-v$pkgver + python setup.py install --skip-build \ + --optimize=1 \ + --root="${pkgdir}" + install -vDm 644 README.md \ + -t "${pkgdir}/usr/share/doc/${pkgname}" + install -vDm 644 LICENSE -t "${pkgdir}/usr/share/licenses/${pkgname}" + } + +Il se contente ici de télécharger l'archive de la dernière release, et de travailler +dessus. + + +Mettre à jour +~~~~~~~~~~~~~ + +Pour mettre à jour le dépôt, une fois les dépôts +``ssh://aur@aur.archlinux.org/python-squirrel-battle.git`` et +``ssh://aur@aur.archlinux.org/python-squirrel-battle-git.git`` clonés, +il suffit de mettre à jour le paramètre ``pkgver`` pour la bonne version, +de régénérer le fichier ``.SRCINFO`` en faisant +``makepkg --printsrcinfo > .SRCINFO``, puis de committer/pousser. + + +Construction du paquet Debian +----------------------------- + +Structure du paquet +------------------- + +L'ensemble des instructions pour construire le paquet Debian est situé dans le +dossier ``debian/``. + +Le fichier ``changelog`` est à modifier à chaque nouvelle version, le fichier +``compat`` contient la version minimale de Debian requise (``10`` pour Debian +Buster), le fichier ``copyright`` contient la liste des fichiers distribués sous +quelle licence (ici GPLv3), le fichier ``control`` contient les informations du +paquet, le fichier ``install`` les fichiers de configuration à installer +(ici le fix de l'affichage de l'écurueil), et enfin le fichier ``rules`` l'ensemble +des instructions à exécuter pour installer. + +Le paquet ``fonts-noto-color-emoji`` est en dépendance pour le bon affichage +des émojis. + +Mettre à jour le paquet +----------------------- + +Pour changer la version du paquet, il faut ajouter des lignes dans le fichier +``changelog``. + + +Construire le paquet +-------------------- + +Il faut partir d'une installation de Debian. + +D'abord on installe les paquets nécessaires : + +.. code:: + + apt update + apt --no-install-recommends install build-essential debmake dh-python debhelper gettext python3-all python3-setuptools + +On peut ensuite construire le paquet : + +.. code:: bash + + dpkg-buildpackage + mkdir build && cp ../*.deb build/ + +Le paquet sera installé dans ``build/python3-squirrel-battle_3.14.1_all.deb``. + +Le paquet Debian_ est construit par l'intégration continue Gitlab et ajouté +à chaque release. diff --git a/docs/display/index.rst b/docs/display/index.rst new file mode 100644 index 0000000..376466c --- /dev/null +++ b/docs/display/index.rst @@ -0,0 +1,21 @@ +Gestion de l'affichage +====================== + +.. _curses: https://docs.python.org/3/howto/curses.html + +L'intégralité de l'affichage du jeu est géré grâce à la bibliothèque native de +Python curses_. + + +.. warning:: + Plus de documentation à venir. + + +.. toctree:: + :maxdepth: 3 + :caption: Affichage + + menu + map + stats + logs diff --git a/docs/display/logs.rst b/docs/display/logs.rst new file mode 100644 index 0000000..3ad130d --- /dev/null +++ b/docs/display/logs.rst @@ -0,0 +1,4 @@ +Affichage de l'historique +========================= + +Pas encore documenté. diff --git a/docs/display/map.rst b/docs/display/map.rst new file mode 100644 index 0000000..1daa85a --- /dev/null +++ b/docs/display/map.rst @@ -0,0 +1,4 @@ +Affichage de la carte +===================== + +Pas encore documenté. diff --git a/docs/display/menu.rst b/docs/display/menu.rst new file mode 100644 index 0000000..84be36c --- /dev/null +++ b/docs/display/menu.rst @@ -0,0 +1,4 @@ +Affichage des menus +=================== + +Pas encore documenté. diff --git a/docs/display/stats.rst b/docs/display/stats.rst new file mode 100644 index 0000000..1b5f697 --- /dev/null +++ b/docs/display/stats.rst @@ -0,0 +1,4 @@ +Affichage des statistiques +========================== + +Pas encore documenté. diff --git a/docs/documentation.rst b/docs/documentation.rst new file mode 100644 index 0000000..74965be --- /dev/null +++ b/docs/documentation.rst @@ -0,0 +1,31 @@ +Documentation +============= + +La documentation est gérée grâce à Sphinx. Le thème est le thème officiel de +ReadTheDocs ``sphinx-rtd-theme``. + +Générer localement la documentation +----------------------------------- + +On commence par se rendre au bon endroit et installer les bonnes dépendances : + +.. code:: bash + + cd docs + pip install -r requirements.txt + +La documentation se génère à partir d'appels à ``make``, selon le type de +documentation voulue. + +Par exemple, ``make html`` construit la documentation web, ``make latexpdf`` +construit un livre PDF avec cette documentation. + + +Documentation externe +--------------------- + +À chaque commit, un webhook est envoyé à ``_, qui construit +tout seul la documentation Sphinx, la publiant à l'adresse +``_. + +De plus, les documentations sont sauvegardées à chaque release taguée. diff --git a/docs/entities/index.rst b/docs/entities/index.rst new file mode 100644 index 0000000..1d63bbf --- /dev/null +++ b/docs/entities/index.rst @@ -0,0 +1,79 @@ +Entités +======= + +.. toctree:: + :maxdepth: 3 + :caption: Entités + + player + monsters + items + +Entité +------ + +Une entité est un élément placé sur la carte. Ce peut être le joueur, un monstre +ou bien un objet sur la carte. Chaque entité dispose des attributs suivants : + +* ``name: str`` + + Il s'agit du type de l'entité. + +* ``y: int`` +* ``x: int`` + + Cela représente les coordonnées de l'entité sur la carte. + +* ``map: Map`` + + Il s'agit de la carte sur laquelle est placée l'entité. + +.. _objet: items.html + +Il existe à l'heure actuelle deux types d'entité : une `entité attaquante`_ ou +bien un objet_. + + +Entité attaquante +----------------- + +.. _monstre: monsters.html +.. _joueur: player.html + +Une entité attaquante (``FightingEntity``) est un type d'entités représentant +les personnages présents sur la carte, pouvant alors se battre. Ce peut être +un monstre_ ou bien le joueur_. + +Elles disposent toutes, en plus des paramètres d'entité, des attributs suivants : + +* ``maxhealth: int`` + + Représente la vie maximale de l'entité, qui est aussi la vie de départ. + +* ``health: int`` + + Représente la vie actuelle de l'entité. + +* ``strength: int`` + + Représente la force de l'entité, le nombre de dégâts à faire à chaque coup. + +* ``intelligence: int`` +* ``charisma: int`` +* ``dexterity: int`` +* ``constitution: int`` + + Tous ces paramètres sont des statistiques de l'entité, n'ayant pas de réelle + influence pour le moment. + +* ``level: int`` + + Niveau de l'entité. + +Chaque type d'entité disposera de ses propres attributs de départ. + +On considère une entité comme morte à partir du moment où sa vie descend +en-dessous de 0 point de vie. À ce moment-là, l'entité est retirée de la carte. + +Lorsqu'une entité en frappe une autre, celle-ci inflige autant de dégâts qu'elle +n'a de force, et autant de points de vie sont perdus. diff --git a/docs/entities/items.rst b/docs/entities/items.rst new file mode 100644 index 0000000..521ca91 --- /dev/null +++ b/docs/entities/items.rst @@ -0,0 +1,50 @@ +Objets +====== + +.. _joueur: player.html +.. _pack de textures: ../texture_pack.html + +Un objet est une entité présente sur la carte que le joueur_ peut ramasser. +Il lui suffit pour cela de s'approcher, et une fois sur la case de l'objet, +celui-ci est placé dans l'inventaire. + +Un objet dispose de deux paramètres : + +* ``held: bool`` + + Indique si l'objet est placé dans l'inventaire ou s'il est au sol. + +* ``held_by: Optional[Player]`` + + Si l'objet est dans l'inventaire, renvoie son propriétaire. + + +Deux types d'objets sont pour l'instant présents : + + +Bombe +----- + +.. _entités attaquantes: index.html#entite-attaquante + +Une bombe est un objet que l'on peut ramasser. Une fois ramassée, elle est placée +dans l'inventaire. Le joueur peut ensuite lâcher la bombe, qui fera alors +3 dégâts à toutes les `entités attaquantes`_ situées à moins de une case. + +Elle est représentée dans le `pack de textures`_ ASCII par le caractère ``o`` +et dans le `pack de textures`_ écureuil par l'émoji ``💣``. + +.. note:: + + La gestion de l'inventaire n'ayant pas encore été implémentée, il n'est à + l'heure actuelle pas possible de lancer une bombe. + + +Cœur +---- + +Une cœur est un objet que l'on ne peut pas ramasser. Dès que le joueur s'en +approche, il est régénéré automatiquement de 3 points de vie, et le cœur disparaît. + +Elle est représentée dans le `pack de textures`_ ASCII par le caractère ``❤`` +et dans le `pack de textures`_ écureuil par l'émoji ``💜``. diff --git a/docs/entities/monsters.rst b/docs/entities/monsters.rst new file mode 100644 index 0000000..b6f287c --- /dev/null +++ b/docs/entities/monsters.rst @@ -0,0 +1,55 @@ +Monstres +======== + +.. _`entité attaquante`: index.html#entites-attaquantes +.. _`pack de textures`: ../texture-pack.html + +Chaque monstre est une `entité attaquante`_, et hérite donc de ses attributs. + +À chaque tick de jeu, chaque monstre se déplace d'une case, si possible. +Si le monstre est loin du joueur, ce déplacement est fait aléatoirement. +Sinon, si le monstre est à moins de 5 cases du joueur, alors il se dirige +au plus vite sur le joueur pour le frapper selon l'algorithme de Dijkstra, +et s'il est suffisamment proche frappe le joueur et lui fait autant de dégâts +qu'il n'a de force. + +On dénombre actuellement 4 types de monstres : + +Hérisson +-------- + +Son nom est fixé à `hedghog`. Il a par défaut une force à **3** et **10** points de vie. + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``*``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🦔``. + + +Tigre +----- + +Son nom est fixé à `tiger`. Il a par défaut une force à **2** et **20** points de vie. + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``n``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🐅``. + + +Lapin +----- + +Son nom est fixé à `rabbit`. Il a par défaut une force à **1** et **15** points de vie. + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``Y``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🐇``. + + +Nounours +-------- + +Son nom est fixé à `teddy_bear`. Il n'a pas de force et **50** points de vie. + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``8``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🧸``. diff --git a/docs/entities/player.rst b/docs/entities/player.rst new file mode 100644 index 0000000..d3a644a --- /dev/null +++ b/docs/entities/player.rst @@ -0,0 +1,52 @@ +Joueur +====== + +.. _`entité attaquante`: index.html#entites-attaquantes +.. _`paramètres`: ../settings.html +.. _`pack de textures`: ../texture-pack.html +.. _`objet`: items.html + +Le joueur est une `entité attaquante`_, contrôlée par l'utilisateur humain. + +Il est représenté dans le `pack de textures`_ ASCII par le caractère ``@``, +et dans le `pack de textures`_ écureuil par le fameux émoji écureuil ``🐿``. + +En plus des attributs d'une `entité attaquante`_, le joueur dispose des atrributs +supplémentaires : + +* ``current_xp: int`` + + Correspond à l'expérience accumulée par le joueur depuis le dernier niveau obtenu. + +* ``max_xp: int`` + + Expérience requise au joueur pour changer de niveau. Vaut 10 fois le niveau actuel. + +* ``inventory: List[Item]`` + + Contient l'ensemble des objets détenus par le joueur. + + +Déplacement +----------- + +Selon les paramètres_, il est possible de bouger le joueur dans les 4 directions +en appuyant sur ``z``, ``q``, ``s``, ``d`` ou sur les flèches directionnelles. + +Le joueur se retrouvera bloqué s'il avance contre un mur. Si il avance sur un +objet_, alors il prend l'objet_ et avance sur la case. + +S'il rencontre une autre `entité attaquante`_, alors il frappe l'entité en +infligeant autant de dégâts qu'il n'a de force. À chaque fois qu'une entité est +tuée, le joueur gagne aléatoirement entre 3 et 7 points d'expérience. + + +Expérience +---------- + +À chaque monstre tué, le joueur gagne entre 3 et 7 points d'expérience aléatoirement. +Lorsque le joueur atteint la quantité d'expérience requise pour monter de niveau, +le joueur gagne un niveau, regagne toute sa vie, consomme son expérience et la +nouvelle quantité d'expérience requise est 10 fois le niveau actuel. De plus, +entre 5 et 10 fois le niveau actuel entités apparaissent aléatoirement sur la +carte à la montée de niveau. Enfin, le joueur gagne en force en montant de niveau. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..1cb7d83 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,54 @@ +Bienvenue dans la documentation de Squirrel Battle ! +==================================================== + +.. image:: https://gitlab.crans.org/ynerant/squirrel-battle/badges/master/pipeline.svg + :target: https://gitlab.crans.org/ynerant/squirrel-battle/-/commits/master + :alt: Pipeline status + +.. image:: https://gitlab.crans.org/ynerant/squirrel-battle/badges/master/coverage.svg + :target: https://gitlab.crans.org/ynerant/squirrel-battle/-/commits/master + :alt: Coverage report + +.. image:: https://readthedocs.org/projects/squirrel-battle/badge/?version=latest + :target: https://squirrel-battle.readthedocs.io/fr/latest/?badge=latest + :alt: Documentation Status + +.. image:: https://img.shields.io/pypi/v/squirrel-battle + :target: https://pypi.org/project/squirrel-battle/ + :alt: PyPI + +.. image:: https://img.shields.io/pypi/dm/squirrel-battle + :target: https://pypi.org/project/squirrel-battle/ + :alt: PyPI downloads + +.. image:: https://img.shields.io/aur/version/python-squirrel-battle + :target: https://aur.archlinux.org/packages/python-squirrel-battle/ + :alt: AUR version + +.. image:: https://img.shields.io/badge/License-GPL%20v3-blue.svg + :target: https://www.gnu.org/licenses/gpl-3.0.txt + :alt: License: GPL v3 + + +.. toctree:: + :maxdepth: 3 + :caption: Développer + + install-dev + tests + display/index + translation + deployment + documentation + +.. toctree:: + :maxdepth: 3 + :caption: Jouer + + install + rules + map + entities/index + texture-pack + settings + troubleshooting diff --git a/docs/install-dev.rst b/docs/install-dev.rst new file mode 100644 index 0000000..973c0e0 --- /dev/null +++ b/docs/install-dev.rst @@ -0,0 +1,40 @@ +Installation d'un environnement de développement +================================================ + +Il est toujours préférable de travailler dans un environnement Python isolé du +reste de son instalation. + +1. **Installation des dépendances de la distribution.** + Vous devez déjà installer Python et le module qui permet de créer des + environnements virtuels. + On donne ci-dessous l'exemple pour une distribution basée sur Debian, + mais vous pouvez facilement adapter pour ArchLinux ou autre. + +.. code:: bash + + $ sudo apt update + $ sudo apt install --no-install-recommends -y python3-setuptools python3-venv python3-dev gettext git + +2. **Clonage du dépot** là où vous voulez : + +.. code:: bash + + $ git clone git@gitlab.crans.org:ynerant/squirrel-battle.git && cd squirrel-battle + +3. **Création d'un environment de travail Python décorrélé du système.** + On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système. + +.. code:: bash + + $ python3 -m venv env + $ source env/bin/activate # entrer dans l'environnement + (env) $ pip3 install -r requirements.txt + (env) $ deactivate # sortir de l'environnement + +4. **Compilation des messages de traduction.** + +.. code:: bash + + (env) $ python3 main.py --compilemessages + +Le lancement du jeu se fait en lançant la commande ``python3 main.py``. \ No newline at end of file diff --git a/docs/install.rst b/docs/install.rst new file mode 100644 index 0000000..5cc2351 --- /dev/null +++ b/docs/install.rst @@ -0,0 +1,80 @@ +Installation client +=================== + +Installation +------------ + +Différents paquets sont déployés, dans PyPI pour tout système utilisant Python, +un paquet Debian et un paquet Arch Linux. + +Depuis PIP +~~~~~~~~~~ + +.. _PyPI: https://pypi.org/project/squirrel-battle/ + +Le projet `Squirrel Battle` est déployé dans PyPI_. Il suffit d'installer +Squirrel Battle en exécutant : + +.. code:: bash + + pip install --user squirrel-battle + +Les mises à jour s'obtiennent également via PIP en exécutant : + +.. code:: bash + + pip install --user --upgrade squirrel-battle + +Le jeu peut se lancer ensuite en exécutant la commande ``squirrel-battle``. + +Toutefois, le paquet PyPI n'inclut pas les polices d'émojis. Il est recommandé +d'installer des polices telles que ``noto-fonts-emoji`` afin de prendre en charge +les émojis dans votre terminal. + + +Sur Arch Linux +~~~~~~~~~~~~~~ + +.. _AUR: https://aur.archlinux.org/ +.. _python-squirrel-battle: https://aur.archlinux.org/packages/python-squirrel-battle/ +.. _python-squirrel-battle-git: https://aur.archlinux.org/packages/python-squirrel-battle-git/ +.. _yay: https://aur.archlinux.org/packages/yay/ + +Deux paquets sont publiés dans l'AUR_ (Arch User Repository) : + +- python-squirrel-battle_ +- python-squirrel-battle-git_ + +Le premier paquet est mis à jour à chaque nouvelle version déployée, le second +est utile pour des fins de développement et est en permanence à jour +avec la branche ``master`` du Git. + +Les deux ne sont pas présents dans les dépôts officiels de Arch Linux, mais vous +pouvez les récupérer avec un outil tel que yay_. + +Les paquets incluent la dépendance ``noto-fonts-emoji``, qui permet d'afficher +les émojis dans le terminal. + +Le jeu peut être ensuite lancé via la commande ``squirrel-battle``. + + +Sur Ubuntu/Debian +~~~~~~~~~~~~~~~~~ + +.. _paquet: https://gitlab.crans.org/ynerant/squirrel-battle/-/jobs/artifacts/master/raw/build/python3-squirrelbattle_3.14.1_all.deb?job=build-deb + +Un paquet_ est généré par l'intégration continue de Gitlab à chaque commit. +Ils sont également attachés à chaque nouvelle release. + +Il dépend du paquet ``fonts-noto-color-emoji``, permettant d'afficher les émojis +dans le terminal. Il peut être installé via APT. + +Pour installer ce paquet, il suffit de le télécharger et d'appeler ``dpkg`` : + +.. code:: bash + + dpkg -i python3-squirrelbattle_3.14.1_all.deb + +Ce paquet inclut un patch pour afficher les émojis écureuil correctement. + +Après cela, le jeu peut être lancé grâce à la commande ``squirrel-battle``. diff --git a/docs/map.rst b/docs/map.rst new file mode 100644 index 0000000..e8b90cc --- /dev/null +++ b/docs/map.rst @@ -0,0 +1,46 @@ +Carte +===== + +.. _entités: entity/index.html +.. _pack de textures: texture-pack.html + +Dans Squirrel game, le joueur se déplace dans un donjon, constitué de plusieurs +cartes. Pour le moment, le jeu se déroule sur une unique carte pré-définie, +non générée aléatoirement. + +Une carte est un rectangle composé de tuiles_. + +La carte est chargée depuis sa représentation ASCII dans un fichier texte. + +Au lancement du jeu, une quantité aléatoire d'entités_ sont générées et placées +aléatoirement sur la carte. + +Tuiles +------ + +Une tuile représente une case du jeu, avec ses différentes propriétés physiques. +On compte actuellement 3 types de tuiles : + +Vide +~~~~ + +Le vide est représenté par un espace vide quelque que soit le `pack de textures`_ +utilisé. Cette tuile n'est utilisée que pour délimiter les bords de la carte, +aucune entité ne peut se trouver sur cette tuile. + + +Sol +~~~ + +Le sol représente les emplacements où les entités peuvent se déplacer librement. +Il est représenté par un point ``.`` dans le `pack de textures`_ ASCII et par +deux caractères rectangulaires blancs ``██`` dans le `pack de textures`_ +écureuil. + + +Mur +~~~ + +Les murs délimitent les salles du donjon. Personne ne peut les traverser. +Ils sont représentés par un dièse ``#`` dans le `pack de textures`_ ASCII et +par une brique carrée ``🧱`` dans le `pack de textures`_ écureuil. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..cbf1e36 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +sphinx-rtd-theme diff --git a/docs/rules.rst b/docs/rules.rst new file mode 100644 index 0000000..77cfc6b --- /dev/null +++ b/docs/rules.rst @@ -0,0 +1,24 @@ +Règles du jeu +============= + +.. _carte: map.html +.. _objets: entities/items.html +.. _monstres: entities/monsters.html +.. _entités: entities/index.html + +Dans `Squirrel Game`, le joueur incarne un écureuil coincé dans un donjon, +prêt à tout pour s'en sortir. Sa vision de rongeur lui permet d'observer +l'intégralité de la carte_, et à l'aide d'objets_, il va pouvoir affronter +les monstres_ présents dans le donjon et gagner en expérience et en force. + +Le jeu fonctionne par niveau. À chaque niveau ``n`` du joueur, entre ``3n`` et +``7n`` entités apparaissent aléatoirement sur la carte. + +En tuant des ennemis, ce qu'il parvient à faire en fonçant directement sur eux +ayant mangé trop de noisettes (ou étant armé d'un couteau), l'écureuil va +pouvoir gagner en expérience et au fur et à mesure qu'il monte de niveau, +a force augmentera. + +Arriverez-vous à sauver ce malheureux petit écureuil perdu ? + +Bon courage sachant que le jeu est sans fin ... diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..a8644d4 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,4 @@ +Paramètres +========== + +Pas encore documenté. diff --git a/docs/tests.rst b/docs/tests.rst new file mode 100644 index 0000000..3bdd0d2 --- /dev/null +++ b/docs/tests.rst @@ -0,0 +1,12 @@ +Exécution des tests +=================== + +.. note:: + La documentation va être revue ici. + +Les tests sont gérés par ``pytest`` dans le module ``squirrelbattle.tests``. + +``tox`` est un outil permettant de configurer l'exécution des tests. Ainsi, après +installation de tox dans votre environnement virtuel via ``pip install tox``, +il vous suffit d'exécuter ``tox -e py3`` pour lancer les tests et ``tox -e linters`` +pour vérifier la syntaxe du code. diff --git a/docs/texture-pack.rst b/docs/texture-pack.rst new file mode 100644 index 0000000..377a3cf --- /dev/null +++ b/docs/texture-pack.rst @@ -0,0 +1,65 @@ +Pack de textures +================ + +.. _entité: entity/index.html +.. _tuile: map.html#tuiles +.. _tuiles: map.html#tuiles +.. _carte: map.html +.. _paramètres: settings.html + +.. _Joueur: entities/player.html +.. _Hérisson: entities/monsters.html#herisson +.. _Cœur: entities/items.html#coeur +.. _Bombe: entities/items.html#bombe +.. _Lapin: entities/monsters.html#lapin +.. _Tigre: entities/monsters.html#tigre +.. _Nounours: entities/monsters.html#nounours + +Chaque entité_ et chaque tuile_ de la carte_ est associé à un caractère pour +être affiché dans le terminal. Cependant, afin de pouvoir proposer plusieurs +expériences graphiques (notamment en fonction du support des émojis), différents +packs de textures sont proposés. + +Il est possible de changer de pack dans les paramètres. + +Les packs de textures peuvent influencer la taille que prennent les tuiles_, +en raison du fait que les émojis ne sont pas monospace. + +Les packs de textures sont au nombre de deux : + +Pack ASCII +---------- + +Chaque tuile fait un caractère de large. + +* Tuiles + * Vide : *espace* + * Mur : ``#`` + * Sol : ``.`` +* Entités + * Joueur_ : ``@`` + * Hérisson_ : ``*`` + * Cœur_ : ``❤`` + * Bombe_ : ``o`` + * Lapin_ : ``Y`` + * Tigre_ : ``n`` + * Nounours_ : ``8`` + + +Pack Écureuil +------------- + +Chaque tuile fait 2 caractères de large pour afficher les émojis proprement. + +* Tuiles + * Vide : *espace* + * Mur : ``🧱`` + * Sol : ``██`` +* Entités + * Joueur_ : ``🐿`` + * Hérisson_ : ``🦔`` + * Cœur_ : ``💜`` + * Bombe_ : ``💣`` + * Lapin_ : ``🐇`` + * Tigre_ : ``🐅`` + * Nounours_ : ``🧸`` diff --git a/docs/translation.rst b/docs/translation.rst new file mode 100644 index 0000000..f3d2584 --- /dev/null +++ b/docs/translation.rst @@ -0,0 +1,120 @@ +Traduction +========== + +Le jeu Squirrel Battle est entièrement traduit en anglais, en français et en allement. +La langue se choisit dans les `paramètres `_. + + +Utitisation +----------- + +Les traductions sont gérées grâce au module natif ``gettext``. Le module +``squirrelbattle.translations`` s'occupe d'installer les traductions, et de +donner les chaînes traduites. + +Pour choisir la langue, il faut appeler ``Translator.setlocale(language: str)``, +où ``language`` correspond au code à 2 lettres de la langue. + +Enfin, le module expose une fonction ``gettext(str) -> str`` qui permet de +traduire les chaînes. + +Il est courant et recommandé d'importer cette fonction sous l'alias ``_``, +afin de limiter la verbositer et de permettre de rendre facilement une chaîne +traduisible. + +.. code:: python + + from squirrelbattle.translations import gettext as _, Translator + + Translator.setlocale("fr") + print(_("I am a translatable string")) + print("I am not translatable") + +Si les traductions sont bien faites (voir ci-dessous), cela donnera : + +.. code:: + + Je suis une chaîne traduisible + I am not translatable + +À noter que si la chaîne n'est pas traduite, alors par défaut on renvoie la +chaîne elle-même. + + +Extraction des chaînes à traduire +--------------------------------- + +L'appel à ``gettext`` ne fait pas que traduire les chaînes : il est possible +également d'extraire toutes les chaînes à traduire. + +Il est nécessaire d'installer le paquet Linux ``gettext`` pour cela. + +L'utilitaire ``xgettext`` s'occupe de cette extraction. Il s'utilise de la façon +suivante : + +.. code:: bash + + xgettext --from-code utf-8 -o output_file.po source_1.py ... source_n.py + +Afin de ne pas avoir à sélectionner manuellement chaque fichier, il est possible +d'appeler directement ``python3 main.py --makemessages``. Cela a pour effet +d'exécuter pour chaque langue ```` : + +.. code:: bash + + find squirrelbattle -iname '*.py' | xargs xgettext --from-code utf-8 + --add-comments + --package-name=squirrelbattle + --package-version=3.14.1 + "--copyright-holder=ÿnérant, eichhornchen, nicomarg, charlse" + --msgid-bugs-address=squirrel-battle@crans.org + -o squirrelbattle/locale//LC_MESSAGES/squirrelbattle.po + +Les fichiers de traductions se trouvent alors dans +``squirrelbattle/locale//LC_MESSAGES/squirrelbattle.po``. + + +Traduire les chaînes +-------------------- + +Après extraction des chaînes, les chaînes à traduire se trouvent dans +``squirrelbattle/locale//LC_MESSAGES/squirrelbattle.po``, comme indiqué +ci-dessus. + +Ce fichier peut-être édité avec un utilitaire tel que ``poedit``, sur +l'interface Web sur ``_, +mais surtout manuellement avec un éditeur de texte. + +Dans ce fichier, on obtient pour chaque chaîne à traduire un paragraphe de la +forme : + +.. code:: po + + #: main.py:4 + msgid "I am a translatable string" + msgstr "Je suis une chaîne traduisible" + +Il sufift de remplir les champs ``msgstr``. + + +Compilation des chaînes +----------------------- + +Pour gagner en efficacité, les chaînes sont compilées dans un fichier avec +l'extension ``.mo``. Ce sont ces fichiers qui sont lus par le module de traduction. + +Pour compiler les traductions, c'est l'utilitaire ``msgfmt`` fourni toujours par +le paquet Linux ``gettext`` que nous utilisons. Il s'utilise assez simplement : + +.. code:: bash + + msgfmt po_file.po -o mo_file.mo + +À nouveau, il est possible de compiler automatiquement les messages en exécutant +``python3 main.py --compilemessages``. + +.. warning:: + + On ne partagera pas dans le dépôt Git les fichiers compilé. En développement, + on compilera soi-même les messages, et en production, la construction des + paquets se charge de compiler automatiquement les traductions. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 0000000..6e400f6 --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,47 @@ +Résolution d'erreurs +==================== + +Émojis +------ + +Le jeu s'exécutant en terminal, il est courant d'obtenir des problèmes d'affichage. +Sous Windows, les émojis s'affichent normalement correctement. Il suffit en +général d'installer les bons paquets de police. + +Sous Arch Linux +^^^^^^^^^^^^^^^ + +Il est recommandé d'utiliser le terminal `xfce4-terminal`. Il suffit d'installer +le paquets de polices : + +.. code:: bash + + sudo pacman -Sy noto-fonts-emoji + +Le jeu doit ensuite se lancer normalement sans action supplémentaire. + +Sous Ubuntu/Debian +^^^^^^^^^^^^^^^^^^ + +À nouveau, le terminal `xfce4-terminal` est recommandé. Le paquet +`fonts-noto-color-emoji`. + +Toutefois, un problème reste avec l'écureuil. Sous Ubuntu et Debian, le +caractère écureuil existe déjà, mais ne s'affiche pas proprement. On peut +appliquer un patch qui permet d'afficher les émojis correctement dans son +terminal. Pour cela, il suffit de faire : + +.. code:: bash + + ln -s $PWD/debian/75-fix-squirrel-emojis.conf /etc/fonts/conf.avail/75-fix-squirrel-emojis.conf + ln -s /etc/fonts/conf.avail/75-fix-squirrel-emojis.conf /etc/fonts/conf.d/75-fix-squirrel-emojis.conf + +Après redémarrage du terminal, l'écureuil devrait s'afficher correctement. + +Pour supprimer le patch : + +.. code:: bash + + rm /etc/fonts/conf.d/75-fix-squirrel-emojis.conf + +À noter que ce patch est inclus dans le paquet Debian. diff --git a/dungeonbattle/bootstrap.py b/dungeonbattle/bootstrap.py deleted file mode 100644 index 0bc97be..0000000 --- a/dungeonbattle/bootstrap.py +++ /dev/null @@ -1,15 +0,0 @@ -from dungeonbattle.game import Game -from dungeonbattle.display.display_manager import DisplayManager -from dungeonbattle.term_manager import TermManager - - -class Bootstrap: - - @staticmethod - def run_game(): - with TermManager() as term_manager: # pragma: no cover - game = Game() - game.new_game() - display = DisplayManager(term_manager.screen, game) - game.display_actions = display.handle_display_action - game.run(term_manager.screen) diff --git a/dungeonbattle/display/__init__.py b/dungeonbattle/display/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dungeonbattle/display/display.py b/dungeonbattle/display/display.py deleted file mode 100644 index 6aba26a..0000000 --- a/dungeonbattle/display/display.py +++ /dev/null @@ -1,52 +0,0 @@ -import curses -from typing import Any, Optional, Union - -from dungeonbattle.display.texturepack import TexturePack -from dungeonbattle.tests.screen import FakePad - - -class Display: - x: int - y: int - width: int - height: int - pad: Any - - def __init__(self, screen: Any, pack: Optional[TexturePack] = None): - self.screen = screen - self.pack = pack or TexturePack.get_pack("ascii") - - def newpad(self, height: int, width: int) -> Union[FakePad, Any]: - return curses.newpad(height, width) if self.screen else FakePad() - - def init_pair(self, number: int, foreground: int, background: int) -> None: - return curses.init_pair(number, foreground, background) \ - if self.screen else None - - def color_pair(self, number: int) -> int: - return curses.color_pair(number) if self.screen else 0 - - def resize(self, y: int, x: int, height: int, width: int, - resize_pad: bool = True) -> None: - self.x = x - self.y = y - self.width = width - self.height = height - if hasattr(self, "pad") and resize_pad: - self.pad.resize(self.height - 1, self.width - 1) - - def refresh(self, *args, resize_pad: bool = True) -> None: - if len(args) == 4: - self.resize(*args, resize_pad) - self.display() - - def display(self) -> None: - raise NotImplementedError - - @property - def rows(self) -> int: - return curses.LINES if self.screen else 42 - - @property - def cols(self) -> int: - return curses.COLS if self.screen else 42 diff --git a/dungeonbattle/display/display_manager.py b/dungeonbattle/display/display_manager.py deleted file mode 100644 index b2cb125..0000000 --- a/dungeonbattle/display/display_manager.py +++ /dev/null @@ -1,69 +0,0 @@ -import curses -from dungeonbattle.display.mapdisplay import MapDisplay -from dungeonbattle.display.statsdisplay import StatsDisplay -from dungeonbattle.display.menudisplay import MenuDisplay, MainMenuDisplay -from dungeonbattle.display.texturepack import TexturePack -from typing import Any -from dungeonbattle.game import Game, GameMode -from dungeonbattle.enums import DisplayActions - - -class DisplayManager: - - def __init__(self, screen: Any, g: Game): - self.game = g - self.screen = screen - pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) - self.mapdisplay = MapDisplay(screen, pack) - self.statsdisplay = StatsDisplay(screen, pack) - self.mainmenudisplay = MainMenuDisplay(self.game.main_menu, - screen, pack) - self.settingsmenudisplay = MenuDisplay(screen, pack) - self.displays = [self.statsdisplay, self.mapdisplay, - self.mainmenudisplay, self.settingsmenudisplay] - self.update_game_components() - - def handle_display_action(self, action: DisplayActions) -> None: - if action == DisplayActions.REFRESH: - self.refresh() - elif action == DisplayActions.UPDATE: - self.update_game_components() - - def update_game_components(self) -> None: - for d in self.displays: - d.pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) - self.mapdisplay.update_map(self.game.map) - self.statsdisplay.update_player(self.game.player) - self.settingsmenudisplay.update_menu(self.game.settings_menu) - - def refresh(self) -> None: - if self.game.state == GameMode.PLAY: - # The map pad has already the good size - self.mapdisplay.refresh(0, 0, self.rows * 4 // 5, self.cols, - resize_pad=False) - self.statsdisplay.refresh(self.rows * 4 // 5, 0, - self.rows // 5, self.cols) - if self.game.state == GameMode.MAINMENU: - self.mainmenudisplay.refresh(0, 0, self.rows, self.cols) - if self.game.state == GameMode.SETTINGS: - self.settingsmenudisplay.refresh(0, 0, self.rows, self.cols - 1) - self.resize_window() - - def resize_window(self) -> bool: - """ - If the window got resized, ensure that the screen size got updated. - """ - y, x = self.screen.getmaxyx() if self.screen else (0, 0) - if self.screen and curses.is_term_resized(self.rows, - self.cols): # pragma: nocover - curses.resizeterm(y, x) - return True - return False - - @property - def rows(self) -> int: - return curses.LINES if self.screen else 42 - - @property - def cols(self) -> int: - return curses.COLS if self.screen else 42 diff --git a/dungeonbattle/display/mapdisplay.py b/dungeonbattle/display/mapdisplay.py deleted file mode 100644 index 36cd616..0000000 --- a/dungeonbattle/display/mapdisplay.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python -from dungeonbattle.interfaces import Map -from .display import Display - - -class MapDisplay(Display): - - def __init__(self, *args): - super().__init__(*args) - - def update_map(self, m: Map) -> None: - self.map = m - self.pad = self.newpad(m.height, self.pack.tile_width * m.width + 1) - - def update_pad(self) -> None: - self.init_pair(1, self.pack.tile_fg_color, self.pack.tile_bg_color) - self.init_pair(2, self.pack.entity_fg_color, self.pack.entity_bg_color) - self.pad.addstr(0, 0, self.map.draw_string(self.pack), - self.color_pair(1)) - for e in self.map.entities: - self.pad.addstr(e.y, self.pack.tile_width * e.x, - self.pack[e.name.upper()], self.color_pair(2)) - - def display(self) -> None: - y, x = self.map.currenty, self.pack.tile_width * self.map.currentx - deltay, deltax = (self.height // 2) + 1, (self.width // 2) + 1 - pminrow, pmincol = y - deltay, x - deltax - sminrow, smincol = max(-pminrow, 0), max(-pmincol, 0) - deltay, deltax = self.height - deltay, self.width - deltax - smaxrow = self.map.height - (y + deltay) + self.height - 1 - smaxrow = min(smaxrow, self.height - 1) - smaxcol = self.pack.tile_width * self.map.width - \ - (x + deltax) + self.width - 1 - smaxcol = min(smaxcol, self.width - 1) - pminrow = max(0, min(self.map.height, pminrow)) - pmincol = max(0, min(self.pack.tile_width * self.map.width, pmincol)) - self.pad.clear() - self.update_pad() - self.pad.refresh(pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol) diff --git a/dungeonbattle/display/menudisplay.py b/dungeonbattle/display/menudisplay.py deleted file mode 100644 index 973dd31..0000000 --- a/dungeonbattle/display/menudisplay.py +++ /dev/null @@ -1,84 +0,0 @@ -from typing import List - -from dungeonbattle.menus import Menu, MainMenu -from .display import Display - - -class MenuDisplay(Display): - position: int - - def __init__(self, *args): - super().__init__(*args) - self.menubox = self.newpad(self.rows, self.cols) - - def update_menu(self, menu: Menu) -> None: - self.menu = menu - self.trueheight = len(self.values) - self.truewidth = max([len(a) for a in self.values]) - - # Menu values are printed in pad - self.pad = self.newpad(self.trueheight, self.truewidth + 2) - for i in range(self.trueheight): - self.pad.addstr(i, 0, " " + self.values[i]) - - def update_pad(self) -> None: - for i in range(self.trueheight): - self.pad.addstr(i, 0, " " + self.values[i]) - # set a marker on the selected line - self.pad.addstr(self.menu.position, 0, ">") - - def display(self) -> None: - cornery = 0 if self.height - 2 >= self.menu.position - 1 \ - else self.trueheight - self.height + 2 \ - if self.height - 2 >= self.trueheight - self.menu.position else 0 - - # Menu box - self.menubox.addstr(0, 0, "┏" + "━" * (self.width - 2) + "┓") - for i in range(1, self.height - 1): - self.menubox.addstr(i, 0, "┃" + " " * (self.width - 2) + "┃") - self.menubox.addstr(self.height - 1, 0, - "┗" + "━" * (self.width - 2) + "┛") - - self.menubox.refresh(0, 0, self.y, self.x, - self.height + self.y, - self.width + self.x) - self.update_pad() - self.pad.refresh(cornery, 0, self.y + 1, self.x + 2, - self.height - 2 + self.y, - self.width - 2 + self.x) - - @property - def preferred_width(self) -> int: - return self.truewidth + 6 - - @property - def preferred_height(self) -> int: - return self.trueheight + 2 - - @property - def values(self) -> List[str]: - return [str(a) for a in self.menu.values] - - -class MainMenuDisplay(Display): - def __init__(self, menu: MainMenu, *args): - super().__init__(*args) - self.menu = menu - self.pad = self.newpad(self.rows, self.cols) - - with open("resources/ascii_art.txt", "r") as file: - self.title = file.read().split("\n") - - self.menudisplay = MenuDisplay(self.screen, self.pack) - self.menudisplay.update_menu(self.menu) - - def display(self) -> None: - for i in range(len(self.title)): - self.pad.addstr(4 + i, self.width // 2 - - len(self.title[0]) // 2 - 1, self.title[i]) - self.pad.refresh(0, 0, self.y, self.x, self.height, self.width) - menuwidth = min(self.menudisplay.preferred_width, self.width) - menuy, menux = len(self.title) + 8, self.width // 2 - menuwidth // 2 - 1 - self.menudisplay.refresh( - menuy, menux, min(self.menudisplay.preferred_height, - self.height - menuy), menuwidth) diff --git a/dungeonbattle/display/statsdisplay.py b/dungeonbattle/display/statsdisplay.py deleted file mode 100644 index 70c6f0c..0000000 --- a/dungeonbattle/display/statsdisplay.py +++ /dev/null @@ -1,52 +0,0 @@ -import curses - -from .display import Display - -from dungeonbattle.entities.player import Player - - -class StatsDisplay(Display): - player: Player - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.pad = self.newpad(self.rows, self.cols) - self.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) - - def update_player(self, p: Player) -> None: - self.player = p - - def update_pad(self) -> None: - string = "" - for _ in range(self.width - 1): - string = string + "-" - self.pad.addstr(0, 0, string) - string2 = "Player -- LVL {} EXP {}/{} HP {}/{}"\ - .format(self.player.level, self.player.current_xp, - self.player.max_xp, self.player.health, - self.player.maxhealth) - for _ in range(self.width - len(string2) - 1): - string2 = string2 + " " - self.pad.addstr(1, 0, string2) - string3 = "Stats : STR {} INT {} CHR {} DEX {} CON {}"\ - .format(self.player.strength, - self.player.intelligence, self.player.charisma, - self.player.dexterity, self.player.constitution) - for _ in range(self.width - len(string3) - 1): - string3 = string3 + " " - self.pad.addstr(2, 0, string3) - - inventory_str = "Inventaire : " + "".join( - self.pack[item.name.upper()] for item in self.player.inventory) - self.pad.addstr(3, 0, inventory_str) - - if self.player.dead: - self.pad.addstr(4, 0, "VOUS ÊTES MORT", - curses.A_BOLD | curses.A_BLINK | curses.A_STANDOUT - | self.color_pair(3)) - - def display(self) -> None: - self.pad.clear() - self.update_pad() - self.pad.refresh(0, 0, self.y, self.x, - 4 + self.y, self.width + self.x) diff --git a/dungeonbattle/entities/__init__.py b/dungeonbattle/entities/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dungeonbattle/entities/items.py b/dungeonbattle/entities/items.py deleted file mode 100644 index 4cfd26b..0000000 --- a/dungeonbattle/entities/items.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Optional - -from .player import Player -from ..interfaces import Entity, FightingEntity, Map - - -class Item(Entity): - held: bool - held_by: Optional["Player"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.held = False - - def drop(self, y: int, x: int) -> None: - if self.held: - self.held_by.inventory.remove(self) - self.held = False - self.held_by = None - self.map.add_entity(self) - self.move(y, x) - - def hold(self, player: "Player") -> None: - self.held = True - self.held_by = player - self.map.remove_entity(self) - player.inventory.append(self) - - -class Heart(Item): - name: str = "heart" - healing: int = 5 - - def hold(self, player: "Player") -> None: - """ - When holding a heart, heal the player and don't put item in inventory. - """ - player.health = min(player.maxhealth, player.health + self.healing) - self.map.remove_entity(self) - - -class Bomb(Item): - name: str = "bomb" - damage: int = 5 - exploding: bool - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.exploding = False - - def drop(self, x: int, y: int) -> None: - super().drop(x, y) - self.exploding = True - - def act(self, m: Map) -> None: - if self.exploding: - for e in m.entities: - if abs(e.x - self.x) + abs(e.y - self.y) <= 1 and \ - isinstance(e, FightingEntity): - e.take_damage(self, self.damage) diff --git a/dungeonbattle/entities/monsters.py b/dungeonbattle/entities/monsters.py deleted file mode 100644 index 327521f..0000000 --- a/dungeonbattle/entities/monsters.py +++ /dev/null @@ -1,58 +0,0 @@ -from random import choice - -from .player import Player -from ..interfaces import FightingEntity, Map - - -class Monster(FightingEntity): - def act(self, m: Map) -> None: - """ - By default, a monster will move randomly where it is possible - And if a player is close to the monster, the monster run on the player. - """ - target = None - for entity in m.entities: - if self.distance_squared(entity) <= 25 and \ - isinstance(entity, Player): - target = entity - break - - # A Dijkstra algorithm has ran that targets the player. - # With that way, monsters can simply follow the path. - # If they can't move and they are already close to the player, - # They hit. - if target and (self.y, self.x) in target.paths: - # Move to target player - next_y, next_x = target.paths[(self.y, self.x)] - moved = self.check_move(next_y, next_x, True) - if not moved and self.distance_squared(target) <= 1: - self.hit(target) - else: - for _ in range(100): - if choice([self.move_up, self.move_down, - self.move_left, self.move_right])(): - break - - -class Beaver(Monster): - name = "beaver" - maxhealth = 30 - strength = 2 - - -class Hedgehog(Monster): - name = "hedgehog" - maxhealth = 10 - strength = 3 - - -class Rabbit(Monster): - name = "rabbit" - maxhealth = 15 - strength = 1 - - -class TeddyBear(Monster): - name = "teddy_bear" - maxhealth = 50 - strength = 0 diff --git a/dungeonbattle/entities/player.py b/dungeonbattle/entities/player.py deleted file mode 100644 index c1bde5e..0000000 --- a/dungeonbattle/entities/player.py +++ /dev/null @@ -1,102 +0,0 @@ -from random import randint -from typing import Dict, Tuple - -from ..interfaces import FightingEntity - - -class Player(FightingEntity): - name = "player" - maxhealth: int = 20 - strength: int = 5 - intelligence: int = 1 - charisma: int = 1 - dexterity: int = 1 - constitution: int = 1 - level: int = 1 - current_xp: int = 0 - max_xp: int = 10 - inventory: list - paths: Dict[Tuple[int, int], Tuple[int, int]] - - def __init__(self): - super().__init__() - self.inventory = list() - - def move(self, y: int, x: int) -> None: - """ - When the player moves, move the camera of the map. - """ - super().move(y, x) - self.map.currenty = y - self.map.currentx = x - self.recalculate_paths() - - def level_up(self) -> None: - """ - Add levels to the player as much as it is possible. - """ - while self.current_xp > self.max_xp: - self.level += 1 - self.current_xp -= self.max_xp - self.max_xp = self.level * 10 - self.health = self.maxhealth - # TODO Remove it, that's only fun - self.map.spawn_random_entities(randint(3 * self.level, - 10 * self.level)) - - def add_xp(self, xp: int) -> None: - """ - Add some experience to the player. - If the required amount is reached, level up. - """ - self.current_xp += xp - self.level_up() - - # noinspection PyTypeChecker,PyUnresolvedReferences - def check_move(self, y: int, x: int, move_if_possible: bool = False) \ - -> bool: - """ - If the player tries to move but a fighting entity is there, - the player fights this entity. - It rewards some XP if it is dead. - """ - # Don't move if we are dead - if self.dead: - return False - for entity in self.map.entities: - if entity.y == y and entity.x == x: - if entity.is_fighting_entity(): - self.hit(entity) - if entity.dead: - self.add_xp(randint(3, 7)) - return True - elif entity.is_item(): - entity.hold(self) - return super().check_move(y, x, move_if_possible) - - def recalculate_paths(self, max_distance: int = 8) -> None: - """ - Use Dijkstra algorithm to calculate best paths - for monsters to go to the player. - """ - queue = [(self.y, self.x)] - visited = [] - distances = {(self.y, self.x): 0} - predecessors = {} - while queue: - y, x = queue.pop(0) - visited.append((y, x)) - if distances[(y, x)] >= max_distance: - continue - for diff_y, diff_x in [(1, 0), (-1, 0), (0, 1), (0, -1)]: - new_y, new_x = y + diff_y, x + diff_x - if not 0 <= new_y < self.map.height or \ - not 0 <= new_x < self.map.width or \ - not self.map.tiles[y][x].can_walk() or \ - (new_y, new_x) in visited or \ - (new_y, new_x) in queue: - continue - predecessors[(new_y, new_x)] = (y, x) - distances[(new_y, new_x)] = distances[(y, x)] + 1 - queue.append((new_y, new_x)) - self.paths = predecessors diff --git a/dungeonbattle/game.py b/dungeonbattle/game.py deleted file mode 100644 index f8a79e1..0000000 --- a/dungeonbattle/game.py +++ /dev/null @@ -1,91 +0,0 @@ -from random import randint -from typing import Any, Optional - -from .entities.player import Player -from .enums import GameMode, KeyValues, DisplayActions -from .interfaces import Map -from .settings import Settings -from . import menus -from .mapgeneration import randomwalk -from typing import Callable - - -class Game: - map: Map - player: Player - # display_actions is a display interface set by the bootstrapper - display_actions: Callable[[DisplayActions], None] - - def __init__(self) -> None: - """ - Init the game. - """ - self.state = GameMode.MAINMENU - self.main_menu = menus.MainMenu() - self.settings_menu = menus.SettingsMenu() - self.settings = Settings() - self.settings.load_settings() - self.settings.write_settings() - self.settings_menu.update_values(self.settings) - - def new_game(self) -> None: - """ - Create a new game on the screen. - """ - self.map = randomwalk.Generator().run() - self.player = Player() - self.map.add_entity(self.player) - self.player.move(self.map.start_y, self.map.start_x) - self.map.spawn_random_entities(randint(3, 10)) - - @staticmethod - def load_game(filename: str) -> None: - # TODO loading map from a file - raise NotImplementedError() - - def run(self, screen: Any) -> None: - """ - Main infinite loop. - We wait for a player action, then we do what that should be done - when the given key got pressed. - """ - while True: # pragma no cover - screen.clear() - screen.refresh() - self.display_actions(DisplayActions.REFRESH) - key = screen.getkey() - self.handle_key_pressed( - KeyValues.translate_key(key, self.settings), key) - - def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str = '')\ - -> None: - """ - Indicates what should be done when the given key is pressed, - according to the current game state. - """ - if self.state == GameMode.PLAY: - self.handle_key_pressed_play(key) - elif self.state == GameMode.MAINMENU: - self.main_menu.handle_key_pressed(key, self) - elif self.state == GameMode.SETTINGS: - self.settings_menu.handle_key_pressed(key, raw_key, self) - self.display_actions(DisplayActions.REFRESH) - - def handle_key_pressed_play(self, key: KeyValues) -> None: - """ - In play mode, arrows or zqsd should move the main character. - """ - if key == KeyValues.UP: - if self.player.move_up(): - self.map.tick() - elif key == KeyValues.DOWN: - if self.player.move_down(): - self.map.tick() - elif key == KeyValues.LEFT: - if self.player.move_left(): - self.map.tick() - elif key == KeyValues.RIGHT: - if self.player.move_right(): - self.map.tick() - elif key == KeyValues.SPACE: - self.state = GameMode.MAINMENU diff --git a/dungeonbattle/interfaces.py b/dungeonbattle/interfaces.py deleted file mode 100644 index b057400..0000000 --- a/dungeonbattle/interfaces.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python -from enum import Enum, auto -from math import sqrt -from random import choice, randint -from typing import List - -from dungeonbattle.display.texturepack import TexturePack - - -class Map: - """ - Object that represents a Map with its width, height - and the whole tiles, with their custom properties. - """ - width: int - height: int - start_y: int - start_x: int - tiles: List[List["Tile"]] - entities: List["Entity"] - # coordinates of the point that should be - # on the topleft corner of the screen - currentx: int - currenty: int - - def __init__(self, width: int, height: int, tiles: list, - start_y: int, start_x: int): - self.width = width - self.height = height - self.start_y = start_y - self.start_x = start_x - self.tiles = tiles - self.entities = [] - - def add_entity(self, entity: "Entity") -> None: - """ - Register a new entity in the map. - """ - self.entities.append(entity) - entity.map = self - - def remove_entity(self, entity: "Entity") -> None: - """ - Unregister an entity from the map. - """ - self.entities.remove(entity) - - def is_free(self, y: int, x: int) -> bool: - """ - Indicates that the case at the coordinates (y, x) is empty. - """ - return 0 <= y < self.height and 0 <= x < self.width and \ - self.tiles[y][x].can_walk() and \ - not any(entity.x == x and entity.y == y for entity in self.entities) - - @staticmethod - def load(filename: str) -> "Map": - """ - Read a file that contains the content of a map, and build a Map object. - """ - with open(filename, "r") as f: - file = f.read() - return Map.load_from_string(file) - - @staticmethod - def load_from_string(content: str) -> "Map": - """ - Load a map represented by its characters and build a Map object. - """ - lines = content.split("\n") - first_line = lines[0] - start_y, start_x = map(int, first_line.split(" ")) - lines = [line for line in lines[1:] if line] - height = len(lines) - width = len(lines[0]) - tiles = [[Tile.from_ascii_char(c) - for x, c in enumerate(line)] for y, line in enumerate(lines)] - - return Map(width, height, tiles, start_y, start_x) - - def draw_string(self, pack: TexturePack) -> str: - """ - Draw the current map as a string object that can be rendered - in the window. - """ - return "\n".join("".join(tile.char(pack) for tile in line) - for line in self.tiles) - - def spawn_random_entities(self, count: int) -> None: - """ - Put randomly {count} hedgehogs on the map, where it is available. - """ - for _ in range(count): - y, x = 0, 0 - while True: - y, x = randint(0, self.height - 1), randint(0, self.width - 1) - tile = self.tiles[y][x] - if tile.can_walk(): - break - entity = choice(Entity.get_all_entity_classes())() - entity.move(y, x) - self.add_entity(entity) - - def tick(self) -> None: - """ - Trigger all entity events. - """ - for entity in self.entities: - entity.act(self) - - -class Tile(Enum): - EMPTY = auto() - WALL = auto() - FLOOR = auto() - - @classmethod - def from_ascii_char(cls, ch: str) -> "Tile": - for tile in Tile: - if tile.char(TexturePack.ASCII_PACK) == ch: - return tile - raise ValueError(ch) - - def char(self, pack: TexturePack) -> str: - return getattr(pack, self.name) - - def is_wall(self) -> bool: - return self == Tile.WALL - - def can_walk(self) -> bool: - """ - Check if an entity (player or not) can move in this tile. - """ - return not self.is_wall() and self != Tile.EMPTY - - -class Entity: - y: int - x: int - name: str - map: Map - - def __init__(self): - self.y = 0 - self.x = 0 - - def check_move(self, y: int, x: int, move_if_possible: bool = False)\ - -> bool: - free = self.map.is_free(y, x) - if free and move_if_possible: - self.move(y, x) - return free - - def move(self, y: int, x: int) -> bool: - self.y = y - self.x = x - return True - - def move_up(self, force: bool = False) -> bool: - return self.move(self.y - 1, self.x) if force else \ - self.check_move(self.y - 1, self.x, True) - - def move_down(self, force: bool = False) -> bool: - return self.move(self.y + 1, self.x) if force else \ - self.check_move(self.y + 1, self.x, True) - - def move_left(self, force: bool = False) -> bool: - return self.move(self.y, self.x - 1) if force else \ - self.check_move(self.y, self.x - 1, True) - - def move_right(self, force: bool = False) -> bool: - return self.move(self.y, self.x + 1) if force else \ - self.check_move(self.y, self.x + 1, True) - - def act(self, m: Map) -> None: - """ - Define the action of the entity that is ran each tick. - By default, does nothing. - """ - pass - - def distance_squared(self, other: "Entity") -> int: - """ - Get the square of the distance to another entity. - Useful to check distances since square root takes time. - """ - return (self.y - other.y) ** 2 + (self.x - other.x) ** 2 - - def distance(self, other: "Entity") -> float: - """ - Get the cartesian distance to another entity. - """ - return sqrt(self.distance_squared(other)) - - def is_fighting_entity(self) -> bool: - return isinstance(self, FightingEntity) - - def is_item(self) -> bool: - from dungeonbattle.entities.items import Item - return isinstance(self, Item) - - @staticmethod - def get_all_entity_classes(): - from dungeonbattle.entities.items import Heart, Bomb - from dungeonbattle.entities.monsters import Beaver, Hedgehog, \ - Rabbit, TeddyBear - return [Beaver, Bomb, Heart, Hedgehog, Rabbit, TeddyBear] - - -class FightingEntity(Entity): - maxhealth: int - health: int - strength: int - dead: bool - intelligence: int - charisma: int - dexterity: int - constitution: int - level: int - - def __init__(self): - super().__init__() - self.health = self.maxhealth - self.dead = False - - def hit(self, opponent: "FightingEntity") -> None: - opponent.take_damage(self, self.strength) - - def take_damage(self, attacker: "Entity", amount: int) -> None: - self.health -= amount - if self.health <= 0: - self.die() - - def die(self) -> None: - self.dead = True - self.map.remove_entity(self) diff --git a/dungeonbattle/mapgeneration/__init__.py b/dungeonbattle/mapgeneration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dungeonbattle/tests/__init__.py b/dungeonbattle/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dungeonbattle/tests/game_test.py b/dungeonbattle/tests/game_test.py deleted file mode 100644 index 80720ee..0000000 --- a/dungeonbattle/tests/game_test.py +++ /dev/null @@ -1,216 +0,0 @@ -import os -import unittest - -from dungeonbattle.bootstrap import Bootstrap -from dungeonbattle.display.display import Display -from dungeonbattle.display.display_manager import DisplayManager -from dungeonbattle.entities.player import Player -from dungeonbattle.game import Game, KeyValues, GameMode -from dungeonbattle.menus import MainMenuValues -from dungeonbattle.settings import Settings - - -class TestGame(unittest.TestCase): - def setUp(self) -> None: - """ - Setup game. - """ - self.game = Game() - self.game.new_game() - display = DisplayManager(None, self.game) - self.game.display_actions = display.handle_display_action - - def test_load_game(self) -> None: - self.assertRaises(NotImplementedError, Game.load_game, "game.save") - self.assertRaises(NotImplementedError, Display(None).display) - - def test_bootstrap_fail(self) -> None: - """ - Ensure that the test can't play the game, - because there is no associated shell. - Yeah, that's only for coverage. - """ - self.assertRaises(Exception, Bootstrap.run_game) - self.assertEqual(os.getenv("TERM", "unknown"), "unknown") - - def test_key_translation(self) -> None: - """ - Test key bindings. - """ - self.game.settings = Settings() - - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_UP_PRIMARY, self.game.settings), - KeyValues.UP) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_UP_SECONDARY, self.game.settings), - KeyValues.UP) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_DOWN_PRIMARY, self.game.settings), - KeyValues.DOWN) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_DOWN_SECONDARY, self.game.settings), - KeyValues.DOWN) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_LEFT_PRIMARY, self.game.settings), - KeyValues.LEFT) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_LEFT_SECONDARY, self.game.settings), - KeyValues.LEFT) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_RIGHT_PRIMARY, self.game.settings), - KeyValues.RIGHT) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_RIGHT_SECONDARY, self.game.settings), - KeyValues.RIGHT) - self.assertEqual(KeyValues.translate_key( - self.game.settings.KEY_ENTER, self.game.settings), - KeyValues.ENTER) - self.assertEqual(KeyValues.translate_key(' ', self.game.settings), - KeyValues.SPACE) - self.assertEqual(KeyValues.translate_key('plop', self.game.settings), - None) - - def test_key_press(self) -> None: - """ - Press a key and see what is done. - """ - self.assertEqual(self.game.state, GameMode.MAINMENU) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.START) - self.game.handle_key_pressed(KeyValues.UP) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.START) - self.game.handle_key_pressed(KeyValues.DOWN) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.SETTINGS) - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.state, GameMode.SETTINGS) - - self.game.handle_key_pressed(KeyValues.SPACE) - self.assertEqual(self.game.state, GameMode.MAINMENU) - - self.game.handle_key_pressed(KeyValues.DOWN) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.EXIT) - self.assertRaises(SystemExit, self.game.handle_key_pressed, - KeyValues.ENTER) - - self.game.handle_key_pressed(KeyValues.UP) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.SETTINGS) - self.game.handle_key_pressed(KeyValues.UP) - self.assertEqual(self.game.main_menu.validate(), - MainMenuValues.START) - - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.state, GameMode.PLAY) - - # Kill entities - for entity in self.game.map.entities.copy(): - if not isinstance(entity, Player): - self.game.map.remove_entity(entity) - - y, x = self.game.player.y, self.game.player.x - self.game.handle_key_pressed(KeyValues.DOWN) - new_y, new_x = self.game.player.y, self.game.player.x - self.assertEqual(new_y, y + 1) - self.assertEqual(new_x, x) - - y, x = new_y, new_x - self.game.handle_key_pressed(KeyValues.RIGHT) - new_y, new_x = self.game.player.y, self.game.player.x - self.assertEqual(new_y, y) - self.assertEqual(new_x, x + 1) - - y, x = self.game.player.y, self.game.player.x - self.game.handle_key_pressed(KeyValues.UP) - new_y, new_x = self.game.player.y, self.game.player.x - self.assertEqual(new_y, y - 1) - self.assertEqual(new_x, x) - - y, x = self.game.player.y, self.game.player.x - self.game.handle_key_pressed(KeyValues.LEFT) - new_y, new_x = self.game.player.y, self.game.player.x - self.assertEqual(new_y, y) - self.assertEqual(new_x, x - 1) - - self.game.handle_key_pressed(KeyValues.SPACE) - self.assertEqual(self.game.state, GameMode.MAINMENU) - - def test_settings_menu(self) -> None: - """ - Ensure that the settings menu is working properly. - """ - self.game.settings = Settings() - - # Open settings menu - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.state, GameMode.SETTINGS) - - # Define the "move up" key to 'w' - self.assertFalse(self.game.settings_menu.waiting_for_key) - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertTrue(self.game.settings_menu.waiting_for_key) - self.game.handle_key_pressed(None, 'w') - self.assertFalse(self.game.settings_menu.waiting_for_key) - self.assertEqual(self.game.settings.KEY_UP_PRIMARY, 'w') - - # Navigate to "move left" - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.UP) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - - # Define the "move up" key to 'a' - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertTrue(self.game.settings_menu.waiting_for_key) - # Can't used a mapped key - self.game.handle_key_pressed(None, 's') - self.assertTrue(self.game.settings_menu.waiting_for_key) - self.game.handle_key_pressed(None, 'a') - self.assertFalse(self.game.settings_menu.waiting_for_key) - self.assertEqual(self.game.settings.KEY_LEFT_PRIMARY, 'a') - - # Navigate to "texture pack" - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - - # Change texture pack - self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.settings.TEXTURE_PACK, "squirrel") - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") - - # Navigate to "back" button - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - self.game.handle_key_pressed(KeyValues.DOWN) - - self.game.handle_key_pressed(KeyValues.ENTER) - self.assertEqual(self.game.state, GameMode.MAINMENU) - - def test_dead_screen(self) -> None: - """ - Kill player and render dead screen. - """ - self.game.state = GameMode.PLAY - # Kill player - self.game.player.take_damage(self.game.player, - self.game.player.health + 2) - y, x = self.game.player.y, self.game.player.x - for key in [KeyValues.UP, KeyValues.DOWN, - KeyValues.LEFT, KeyValues.RIGHT]: - self.game.handle_key_pressed(key) - new_y, new_x = self.game.player.y, self.game.player.x - self.assertEqual(new_y, y) - self.assertEqual(new_x, x) diff --git a/dungeonbattle/tests/menus_test.py b/dungeonbattle/tests/menus_test.py deleted file mode 100644 index 6ad9df7..0000000 --- a/dungeonbattle/tests/menus_test.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -from dungeonbattle.menus import ArbitraryMenu, MainMenu, MainMenuValues - - -class TestMenus(unittest.TestCase): - def test_scroll_menu(self) -> None: - """ - Test to scroll the menu. - """ - arbitrary_menu = ArbitraryMenu([]) - self.assertEqual(arbitrary_menu.position, 0) - - main_menu = MainMenu() - self.assertEqual(main_menu.position, 0) - self.assertEqual(main_menu.validate(), MainMenuValues.START) - main_menu.go_up() - self.assertEqual(main_menu.validate(), MainMenuValues.START) - main_menu.go_down() - self.assertEqual(main_menu.validate(), MainMenuValues.SETTINGS) - main_menu.go_down() - self.assertEqual(main_menu.validate(), MainMenuValues.EXIT) - main_menu.go_down() - self.assertEqual(main_menu.validate(), MainMenuValues.EXIT) diff --git a/main.py b/main.py index e918f0d..fbbbb35 100755 --- a/main.py +++ b/main.py @@ -1,5 +1,25 @@ -#!/usr/bin/env python -from dungeonbattle.bootstrap import Bootstrap +#!/usr/bin/env python3 + +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later +import argparse +import sys + +from squirrelbattle.bootstrap import Bootstrap +from squirrelbattle.translations import Translator if __name__ == "__main__": - Bootstrap.run_game() + parser = argparse.ArgumentParser() + + parser.add_argument("--makemessages", "-mm", action="store_true", + help="Extract translatable strings") + parser.add_argument("--compilemessages", "-cm", action="store_true", + help="Compile translatable strings") + + args = parser.parse_args(sys.argv[1:]) + if args.makemessages: + Translator.makemessages() + elif args.compilemessages: + Translator.compilemessages() + else: + Bootstrap.run_game() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7f39d83 --- /dev/null +++ b/setup.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import subprocess + +from setuptools import find_packages, setup + +with open("README.md", "r") as f: + long_description = f.read() + +# Compile messages +for language in ["de", "es", "fr"]: + args = ["msgfmt", "--check-format", + "-o", f"squirrelbattle/locale/{language}/LC_MESSAGES" + "/squirrelbattle.mo", + f"squirrelbattle/locale/{language}/LC_MESSAGES" + "/squirrelbattle.po"] + print(f"Compiling {language} messages...") + subprocess.Popen(args) + +setup( + name="squirrel-battle", + version="3.14.1", + author="ÿnérant, eichhornchen, nicomarg, charlse", + author_email="squirrel-battle@crans.org", + description="Watch out for squirrel's knives!", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://gitlab.crans.org/ynerant/squirrel-battle", + packages=find_packages(), + license='GPLv3', + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console :: Curses", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Natural Language :: French", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Games/Entertainment", + ], + python_requires='>=3.6', + include_package_data=True, + package_data={"squirrelbattle": ["assets/*", "locale/*/*/*.mo"]}, + entry_points={ + "console_scripts": [ + "squirrel-battle = squirrelbattle.bootstrap:Bootstrap.run_game", + ] + } +) diff --git a/squirrelbattle/__init__.py b/squirrelbattle/__init__.py new file mode 100644 index 0000000..1cc6688 --- /dev/null +++ b/squirrelbattle/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/resources/ascii_art.txt b/squirrelbattle/assets/ascii_art.txt similarity index 100% rename from resources/ascii_art.txt rename to squirrelbattle/assets/ascii_art.txt diff --git a/resources/example_map.txt b/squirrelbattle/assets/example_map.txt similarity index 100% rename from resources/example_map.txt rename to squirrelbattle/assets/example_map.txt diff --git a/resources/example_map_2.txt b/squirrelbattle/assets/example_map_2.txt similarity index 100% rename from resources/example_map_2.txt rename to squirrelbattle/assets/example_map_2.txt diff --git a/squirrelbattle/bootstrap.py b/squirrelbattle/bootstrap.py new file mode 100644 index 0000000..f041aef --- /dev/null +++ b/squirrelbattle/bootstrap.py @@ -0,0 +1,24 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from squirrelbattle.game import Game +from squirrelbattle.display.display_manager import DisplayManager +from squirrelbattle.term_manager import TermManager + + +class Bootstrap: + """ + The bootstrap object is used to bootstrap the game so that it starts + properly. + (It was initially created to avoid circular imports between the Game and + Display classes) + """ + + @staticmethod + def run_game(): + with TermManager() as term_manager: # pragma: no cover + game = Game() + game.new_game() + display = DisplayManager(term_manager.screen, game) + game.display_actions = display.handle_display_action + game.run(term_manager.screen) diff --git a/squirrelbattle/display/__init__.py b/squirrelbattle/display/__init__.py new file mode 100644 index 0000000..1cc6688 --- /dev/null +++ b/squirrelbattle/display/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/squirrelbattle/display/display.py b/squirrelbattle/display/display.py new file mode 100644 index 0000000..9cc1456 --- /dev/null +++ b/squirrelbattle/display/display.py @@ -0,0 +1,160 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import curses +from typing import Any, Optional, Union + +from squirrelbattle.display.texturepack import TexturePack +from squirrelbattle.tests.screen import FakePad + + +class Display: + x: int + y: int + width: int + height: int + pad: Any + + def __init__(self, screen: Any, pack: Optional[TexturePack] = None): + self.screen = screen + self.pack = pack or TexturePack.get_pack("ascii") + + def newpad(self, height: int, width: int) -> Union[FakePad, Any]: + return curses.newpad(height, width) if self.screen else FakePad() + + def truncate(self, msg: str, height: int, width: int) -> str: + height = max(0, height) + width = max(0, width) + lines = msg.split("\n") + lines = lines[:height] + lines = [line[:width] for line in lines] + return "\n".join(lines) + + def addstr(self, pad: Any, y: int, x: int, msg: str, *options) -> None: + """ + Display a message onto the pad. + If the message is too large, it is truncated vertically and horizontally + """ + height, width = pad.getmaxyx() + msg = self.truncate(msg, height - y, width - x - 1) + if msg.replace("\n", "") and x >= 0 and y >= 0: + return pad.addstr(y, x, msg, *options) + + def init_pair(self, number: int, foreground: int, background: int) -> None: + return curses.init_pair(number, foreground, background) \ + if self.screen else None + + def color_pair(self, number: int) -> int: + return curses.color_pair(number) if self.screen else 0 + + def resize(self, y: int, x: int, height: int, width: int, + resize_pad: bool = True) -> None: + self.x = x + self.y = y + self.width = width + self.height = height + if hasattr(self, "pad") and resize_pad and \ + self.height >= 0 and self.width >= 0: + self.pad.resize(self.height + 1, self.width + 1) + + def refresh(self, *args, resize_pad: bool = True) -> None: + if len(args) == 4: + self.resize(*args, resize_pad) + self.display() + + def refresh_pad(self, pad: Any, top_y: int, top_x: int, + window_y: int, window_x: int, + last_y: int, last_x: int) -> None: + """ + Refresh a pad on a part of the window. + The refresh starts at coordinates (top_y, top_x) from the pad, + and is drawn from (window_y, window_x) to (last_y, last_x). + If coordinates are invalid (negative indexes/length..., then nothing + is drawn and no error is raised. + """ + top_y, top_x = max(0, top_y), max(0, top_x) + window_y, window_x = max(0, window_y), max(0, window_x) + screen_max_y, screen_max_x = self.screen.getmaxyx() if self.screen \ + else (42, 42) + last_y, last_x = min(screen_max_y - 1, last_y), \ + min(screen_max_x - 1, last_x) + + if last_y >= window_y and last_x >= window_x: + # Refresh the pad only if coordinates are valid + pad.refresh(top_y, top_x, window_y, window_x, last_y, last_x) + + def display(self) -> None: + raise NotImplementedError + + @property + def rows(self) -> int: + return curses.LINES if self.screen else 42 + + @property + def cols(self) -> int: + return curses.COLS if self.screen else 42 + + +class VerticalSplit(Display): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pad = self.newpad(self.rows, 1) + + @property + def width(self) -> int: + return 1 + + @width.setter + def width(self, val: Any) -> None: + pass + + def display(self) -> None: + for i in range(self.height): + self.addstr(self.pad, i, 0, "┃") + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.y + self.height - 1, self.x) + + +class HorizontalSplit(Display): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pad = self.newpad(1, self.cols) + + @property + def height(self) -> int: + return 1 + + @height.setter + def height(self, val: Any) -> None: + pass + + def display(self) -> None: + for i in range(self.width): + self.addstr(self.pad, 0, i, "━") + self.refresh_pad(self.pad, 0, 0, self.y, self.x, self.y, + self.x + self.width - 1) + + +class Box(Display): + + def __init__(self, *args, fg_border_color: Optional[int] = None, **kwargs): + super().__init__(*args, **kwargs) + self.pad = self.newpad(self.rows, self.cols) + self.fg_border_color = fg_border_color or curses.COLOR_WHITE + + pair_number = 4 + self.fg_border_color + self.init_pair(pair_number, self.fg_border_color, curses.COLOR_BLACK) + self.pair = self.color_pair(pair_number) + + def display(self) -> None: + self.addstr(self.pad, 0, 0, "┏" + "━" * (self.width - 2) + "┓", + self.pair) + for i in range(1, self.height - 1): + self.addstr(self.pad, i, 0, "┃", self.pair) + self.addstr(self.pad, i, self.width - 1, "┃", self.pair) + self.addstr(self.pad, self.height - 1, 0, + "┗" + "━" * (self.width - 2) + "┛", self.pair) + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.y + self.height - 1, self.x + self.width - 1) diff --git a/squirrelbattle/display/display_manager.py b/squirrelbattle/display/display_manager.py new file mode 100644 index 0000000..0e9cf04 --- /dev/null +++ b/squirrelbattle/display/display_manager.py @@ -0,0 +1,107 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import curses +from squirrelbattle.display.display import VerticalSplit, HorizontalSplit +from squirrelbattle.display.mapdisplay import MapDisplay +from squirrelbattle.display.messagedisplay import MessageDisplay +from squirrelbattle.display.statsdisplay import StatsDisplay +from squirrelbattle.display.menudisplay import MainMenuDisplay, \ + InventoryDisplay, SettingsMenuDisplay +from squirrelbattle.display.logsdisplay import LogsDisplay +from squirrelbattle.display.texturepack import TexturePack +from typing import Any +from squirrelbattle.game import Game, GameMode +from squirrelbattle.enums import DisplayActions + + +class DisplayManager: + + def __init__(self, screen: Any, g: Game): + self.game = g + self.screen = screen + pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) + self.mapdisplay = MapDisplay(screen, pack) + self.statsdisplay = StatsDisplay(screen, pack) + self.logsdisplay = LogsDisplay(screen, pack) + self.inventorydisplay = InventoryDisplay(screen, pack) + self.mainmenudisplay = MainMenuDisplay(self.game.main_menu, + screen, pack) + self.settingsmenudisplay = SettingsMenuDisplay(screen, pack) + self.messagedisplay = MessageDisplay(screen=screen, pack=None) + self.hbar = HorizontalSplit(screen, pack) + self.vbar = VerticalSplit(screen, pack) + self.displays = [self.statsdisplay, self.mapdisplay, + self.mainmenudisplay, self.settingsmenudisplay, + self.logsdisplay, self.messagedisplay] + self.update_game_components() + + def handle_display_action(self, action: DisplayActions) -> None: + if action == DisplayActions.REFRESH: + self.refresh() + elif action == DisplayActions.UPDATE: + self.update_game_components() + + def update_game_components(self) -> None: + for d in self.displays: + d.pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) + self.mapdisplay.update_map(self.game.map) + self.statsdisplay.update_player(self.game.player) + self.inventorydisplay.update_menu(self.game.inventory_menu) + self.settingsmenudisplay.update_menu(self.game.settings_menu) + self.logsdisplay.update_logs(self.game.logs) + self.messagedisplay.update_message(self.game.message) + + def refresh(self) -> None: + if self.game.state == GameMode.PLAY \ + or self.game.state == GameMode.INVENTORY: + # The map pad has already the good size + self.mapdisplay.refresh(0, 0, self.rows * 4 // 5, + self.mapdisplay.pack.tile_width + * (self.cols * 4 // 5 + // self.mapdisplay.pack.tile_width), + resize_pad=False) + self.statsdisplay.refresh(0, self.cols * 4 // 5 + 1, + self.rows, self.cols // 5 - 1) + self.logsdisplay.refresh(self.rows * 4 // 5 + 1, 0, + self.rows // 5 - 1, self.cols * 4 // 5) + self.hbar.refresh(self.rows * 4 // 5, 0, 1, self.cols * 4 // 5) + self.vbar.refresh(0, self.cols * 4 // 5, self.rows, 1) + if self.game.state == GameMode.INVENTORY: + self.inventorydisplay.refresh(self.rows // 10, + self.cols // 2, + 8 * self.rows // 10, + 2 * self.cols // 5) + elif self.game.state == GameMode.MAINMENU: + self.mainmenudisplay.refresh(0, 0, self.rows, self.cols) + elif self.game.state == GameMode.SETTINGS: + self.settingsmenudisplay.refresh(0, 0, self.rows, self.cols) + + if self.game.message: + height, width = 0, 0 + for line in self.game.message.split("\n"): + height += 1 + width = max(width, len(line)) + y, x = (self.rows - height) // 2, (self.cols - width) // 2 + self.messagedisplay.refresh(y, x, height, width) + + self.resize_window() + + def resize_window(self) -> bool: + """ + If the window got resized, ensure that the screen size got updated. + """ + y, x = self.screen.getmaxyx() if self.screen else (0, 0) + if self.screen and curses.is_term_resized(self.rows, + self.cols): # pragma: nocover + curses.resizeterm(y, x) + return True + return False + + @property + def rows(self) -> int: + return curses.LINES if self.screen else 42 + + @property + def cols(self) -> int: + return curses.COLS if self.screen else 42 diff --git a/squirrelbattle/display/logsdisplay.py b/squirrelbattle/display/logsdisplay.py new file mode 100644 index 0000000..b768a0e --- /dev/null +++ b/squirrelbattle/display/logsdisplay.py @@ -0,0 +1,25 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from squirrelbattle.display.display import Display +from squirrelbattle.interfaces import Logs + + +class LogsDisplay(Display): + + def __init__(self, *args) -> None: + super().__init__(*args) + self.pad = self.newpad(self.rows, self.cols) + + def update_logs(self, logs: Logs) -> None: + self.logs = logs + + def display(self) -> None: + messages = self.logs.messages[-self.height:] + messages = messages[::-1] + self.pad.erase() + for i in range(min(self.height, len(messages))): + self.addstr(self.pad, self.height - i - 1, self.x, + messages[i][:self.width]) + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.y + self.height - 1, self.x + self.width - 1) diff --git a/squirrelbattle/display/mapdisplay.py b/squirrelbattle/display/mapdisplay.py new file mode 100644 index 0000000..d403f7f --- /dev/null +++ b/squirrelbattle/display/mapdisplay.py @@ -0,0 +1,71 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from squirrelbattle.interfaces import Map +from .display import Display + + +class MapDisplay(Display): + + def __init__(self, *args): + super().__init__(*args) + + def update_map(self, m: Map) -> None: + self.map = m + self.pad = self.newpad(m.height, self.pack.tile_width * m.width + 1) + + def update_pad(self) -> None: + self.init_pair(1, self.pack.tile_fg_color, self.pack.tile_bg_color) + self.init_pair(2, self.pack.entity_fg_color, self.pack.entity_bg_color) + self.addstr(self.pad, 0, 0, self.map.draw_string(self.pack), + self.color_pair(1)) + for e in self.map.entities: + self.addstr(self.pad, e.y, self.pack.tile_width * e.x, + self.pack[e.name.upper()], self.color_pair(2)) + + # Display Path map for deubg purposes + # from squirrelbattle.entities.player import Player + # players = [ p for p in self.map.entities if isinstance(p,Player) ] + # player = players[0] if len(players) > 0 else None + # if player: + # for x in range(self.map.width): + # for y in range(self.map.height): + # if (y,x) in player.paths: + # deltay, deltax = (y - player.paths[(y, x)][0], + # x - player.paths[(y, x)][1]) + # if (deltay, deltax) == (-1, 0): + # character = '↓' + # elif (deltay, deltax) == (1, 0): + # character = '↑' + # elif (deltay, deltax) == (0, -1): + # character = '→' + # else: + # character = '←' + # self.addstr(self.pad, y, self.pack.tile_width * x, + # character, self.color_pair(1)) + + def display(self) -> None: + y, x = self.map.currenty, self.pack.tile_width * self.map.currentx + deltay, deltax = (self.height // 2) + 1, (self.width // 2) + 1 + pminrow, pmincol = y - deltay, x - deltax + sminrow, smincol = max(-pminrow, 0), max(-pmincol, 0) + deltay, deltax = self.height - deltay, self.width - deltax + smaxrow = self.map.height - (y + deltay) + self.height - 1 + smaxrow = min(smaxrow, self.height - 1) + smaxcol = self.pack.tile_width * self.map.width - \ + (x + deltax) + self.width - 1 + + # Wrap perfectly the map according to the width of the tiles + pmincol = self.pack.tile_width * (pmincol // self.pack.tile_width) + smincol = self.pack.tile_width * (smincol // self.pack.tile_width) + smaxcol = self.pack.tile_width \ + * (smaxcol // self.pack.tile_width + 1) - 1 + + smaxcol = min(smaxcol, self.width - 1) + pminrow = max(0, min(self.map.height, pminrow)) + pmincol = max(0, min(self.pack.tile_width * self.map.width, pmincol)) + + self.pad.erase() + self.update_pad() + self.refresh_pad(self.pad, pminrow, pmincol, sminrow, smincol, smaxrow, + smaxcol) diff --git a/squirrelbattle/display/menudisplay.py b/squirrelbattle/display/menudisplay.py new file mode 100644 index 0000000..d040d81 --- /dev/null +++ b/squirrelbattle/display/menudisplay.py @@ -0,0 +1,120 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later +import curses +from typing import List + +from squirrelbattle.menus import Menu, MainMenu +from .display import Display, Box +from ..resources import ResourceManager +from ..translations import gettext as _ + + +class MenuDisplay(Display): + position: int + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.menubox = Box(*args, **kwargs) + + def update_menu(self, menu: Menu) -> None: + self.menu = menu + + # Menu values are printed in pad + self.pad = self.newpad(self.trueheight, self.truewidth + 2) + + def update_pad(self) -> None: + for i in range(self.trueheight): + self.addstr(self.pad, i, 0, " " + self.values[i]) + # set a marker on the selected line + self.addstr(self.pad, self.menu.position, 0, ">") + + def display(self) -> None: + cornery = 0 if self.height - 2 >= self.menu.position - 1 \ + else self.trueheight - self.height + 2 \ + if self.height - 2 >= self.trueheight - self.menu.position else 0 + + # Menu box + self.menubox.refresh(self.y, self.x, self.height, self.width) + self.pad.erase() + self.update_pad() + self.refresh_pad(self.pad, cornery, 0, self.y + 1, self.x + 2, + self.height - 2 + self.y, + self.width - 2 + self.x) + + @property + def truewidth(self) -> int: + return max([len(str(a)) for a in self.values]) + + @property + def trueheight(self) -> int: + return len(self.values) + + @property + def preferred_width(self) -> int: + return self.truewidth + 6 + + @property + def preferred_height(self) -> int: + return self.trueheight + 2 + + @property + def values(self) -> List[str]: + return [str(a) for a in self.menu.values] + + +class SettingsMenuDisplay(MenuDisplay): + @property + def values(self) -> List[str]: + return [_(a[1][1]) + (" : " + + ("?" if self.menu.waiting_for_key + and a == self.menu.validate() else a[1][0] + .replace("\n", "\\n")) + if a[1][0] else "") for a in self.menu.values] + + +class MainMenuDisplay(Display): + def __init__(self, menu: MainMenu, *args): + super().__init__(*args) + self.menu = menu + + with open(ResourceManager.get_asset_path("ascii_art.txt"), "r") as file: + self.title = file.read().split("\n") + + self.pad = self.newpad(max(self.rows, len(self.title) + 30), + max(len(self.title[0]) + 5, self.cols)) + + self.menudisplay = MenuDisplay(self.screen, self.pack) + self.menudisplay.update_menu(self.menu) + + def display(self) -> None: + for i in range(len(self.title)): + self.addstr(self.pad, 4 + i, max(self.width // 2 + - len(self.title[0]) // 2 - 1, 0), self.title[i]) + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.height + self.y - 1, + self.width + self.x - 1) + menuwidth = min(self.menudisplay.preferred_width, self.width) + menuy, menux = len(self.title) + 8, self.width // 2 - menuwidth // 2 - 1 + self.menudisplay.refresh( + menuy, menux, min(self.menudisplay.preferred_height, + self.height - menuy), menuwidth) + + +class InventoryDisplay(MenuDisplay): + def update_pad(self) -> None: + message = _("== INVENTORY ==") + self.addstr(self.pad, 0, (self.width - len(message)) // 2, message, + curses.A_BOLD | curses.A_ITALIC) + for i, item in enumerate(self.menu.values): + rep = self.pack[item.name.upper()] + selection = f"[{rep}]" if i == self.menu.position else f" {rep} " + self.addstr(self.pad, 2 + i, 0, selection + + " " + item.translated_name.capitalize()) + + @property + def truewidth(self) -> int: + return max(1, self.height if hasattr(self, "height") else 10) + + @property + def trueheight(self) -> int: + return 2 + super().trueheight diff --git a/squirrelbattle/display/messagedisplay.py b/squirrelbattle/display/messagedisplay.py new file mode 100644 index 0000000..bcc2539 --- /dev/null +++ b/squirrelbattle/display/messagedisplay.py @@ -0,0 +1,31 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later +import curses + +from squirrelbattle.display.display import Box, Display + + +class MessageDisplay(Display): + """ + Display a message in a popup. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.box = Box(fg_border_color=curses.COLOR_RED, *args, **kwargs) + self.message = "" + self.pad = self.newpad(1, 1) + + def update_message(self, msg: str) -> None: + self.message = msg + + def display(self) -> None: + self.box.refresh(self.y - 1, self.x - 2, + self.height + 2, self.width + 4) + self.box.display() + self.pad.erase() + self.addstr(self.pad, 0, 0, self.message, curses.A_BOLD) + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.height + self.y - 1, + self.width + self.x - 1) diff --git a/squirrelbattle/display/statsdisplay.py b/squirrelbattle/display/statsdisplay.py new file mode 100644 index 0000000..ac1a89c --- /dev/null +++ b/squirrelbattle/display/statsdisplay.py @@ -0,0 +1,58 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import curses + +from ..entities.player import Player +from ..translations import gettext as _ +from .display import Display + + +class StatsDisplay(Display): + player: Player + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pad = self.newpad(self.rows, self.cols) + self.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) + + def update_player(self, p: Player) -> None: + self.player = p + + def update_pad(self) -> None: + string2 = "Player -- LVL {}\nEXP {}/{}\nHP {}/{}"\ + .format(self.player.level, self.player.current_xp, + self.player.max_xp, self.player.health, + self.player.maxhealth) + self.addstr(self.pad, 0, 0, string2) + string3 = "STR {}\nINT {}\nCHR {}\nDEX {}\nCON {}"\ + .format(self.player.strength, + self.player.intelligence, self.player.charisma, + self.player.dexterity, self.player.constitution) + self.addstr(self.pad, 3, 0, string3) + + inventory_str = _("Inventory:") + " " + # Stack items by type instead of displaying each item + item_types = [item.name for item in self.player.inventory] + item_types.sort(key=item_types.count, reverse=True) + printed_items = [] + for item in item_types: + if item in printed_items: + continue + count = item_types.count(item) + inventory_str += self.pack[item.upper()] + if count > 1: + inventory_str += f"x{count} " + printed_items.append(item) + self.addstr(self.pad, 8, 0, inventory_str) + + if self.player.dead: + self.addstr(self.pad, 10, 0, _("YOU ARE DEAD"), + curses.A_BOLD | curses.A_BLINK | curses.A_STANDOUT + | self.color_pair(3)) + + def display(self) -> None: + self.pad.erase() + self.update_pad() + self.refresh_pad(self.pad, 0, 0, self.y, self.x, + self.y + self.height - 1, self.width + self.x - 1) diff --git a/dungeonbattle/display/texturepack.py b/squirrelbattle/display/texturepack.py similarity index 87% rename from dungeonbattle/display/texturepack.py rename to squirrelbattle/display/texturepack.py index 0ae8f56..7fc4a9a 100644 --- a/dungeonbattle/display/texturepack.py +++ b/squirrelbattle/display/texturepack.py @@ -1,3 +1,6 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import curses from typing import Any @@ -51,8 +54,9 @@ TexturePack.ASCII_PACK = TexturePack( HEART='❤', BOMB='o', RABBIT='Y', - BEAVER='_', + TIGER='n', TEDDY_BEAR='8', + BODY_SNATCH_POTION='S', ) TexturePack.SQUIRREL_PACK = TexturePack( @@ -65,11 +69,12 @@ TexturePack.SQUIRREL_PACK = TexturePack( EMPTY=' ', WALL='🧱', FLOOR='██', - PLAYER='🐿 ️', + PLAYER='🐿️ ️', HEDGEHOG='🦔', HEART='💜', BOMB='💣', RABBIT='🐇', - BEAVER='🦫', + TIGER='🐅', TEDDY_BEAR='🧸', + BODY_SNATCH_POTION='🔀', ) diff --git a/squirrelbattle/entities/__init__.py b/squirrelbattle/entities/__init__.py new file mode 100644 index 0000000..1cc6688 --- /dev/null +++ b/squirrelbattle/entities/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/squirrelbattle/entities/items.py b/squirrelbattle/entities/items.py new file mode 100644 index 0000000..e90ec32 --- /dev/null +++ b/squirrelbattle/entities/items.py @@ -0,0 +1,178 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import choice, randint +from typing import Optional + +from .player import Player +from ..interfaces import Entity, FightingEntity, Map +from ..translations import gettext as _ + + +class Item(Entity): + """ + A class for items + """ + held: bool + held_by: Optional[Player] + + def __init__(self, held: bool = False, held_by: Optional[Player] = None, + *args, **kwargs): + super().__init__(*args, **kwargs) + self.held = held + self.held_by = held_by + + def drop(self) -> None: + """ + The item is dropped from the inventory onto the floor + """ + if self.held: + self.held_by.inventory.remove(self) + self.map.add_entity(self) + self.move(self.held_by.y, self.held_by.x) + self.held = False + self.held_by = None + + def use(self) -> None: + """ + Indicates what should be done when the item is used. + """ + + def equip(self) -> None: + """ + Indicates what should be done when the item is equipped. + """ + + def hold(self, player: "Player") -> None: + """ + The item is taken from the floor and put into the inventory + """ + self.held = True + self.held_by = player + self.map.remove_entity(self) + player.inventory.append(self) + + def save_state(self) -> dict: + """ + Saves the state of the entity into a dictionary + """ + d = super().save_state() + d["held"] = self.held + return d + + +class Heart(Item): + """ + A heart item to return health to the player + """ + healing: int + + def __init__(self, name: str = "heart", healing: int = 5, *args, **kwargs): + super().__init__(name=name, *args, **kwargs) + self.healing = healing + + def hold(self, player: "Player") -> None: + """ + When holding a heart, heal the player and don't put item in inventory. + """ + player.health = min(player.maxhealth, player.health + self.healing) + self.map.remove_entity(self) + + def save_state(self) -> dict: + """ + Saves the state of the header into a dictionary + """ + d = super().save_state() + d["healing"] = self.healing + return d + + +class Bomb(Item): + """ + A bomb item intended to deal damage to enemies at long range + """ + damage: int = 5 + exploding: bool + owner: Optional["Player"] + tick: int + + def __init__(self, name: str = "bomb", damage: int = 5, + exploding: bool = False, *args, **kwargs): + super().__init__(name=name, *args, **kwargs) + self.damage = damage + self.exploding = exploding + self.tick = 4 + self.owner = None + + def use(self) -> None: + """ + When the bomb is used, throw it and explodes it. + """ + if self.held: + self.owner = self.held_by + super().drop() + self.exploding = True + + def act(self, m: Map) -> None: + """ + Special exploding action of the bomb + """ + if self.exploding: + if self.tick > 0: + # The bomb will explode in moves + self.tick -= 1 + else: + # The bomb is exploding. + # Each entity that is close to the bomb takes damages. + # The player earn XP if the entity was killed. + log_message = _("Bomb is exploding.") + for e in m.entities.copy(): + if abs(e.x - self.x) + abs(e.y - self.y) <= 3 and \ + isinstance(e, FightingEntity): + log_message += " " + e.take_damage(self, self.damage) + if e.dead: + self.owner.add_xp(randint(3, 7)) + m.logs.add_message(log_message) + m.entities.remove(self) + + def save_state(self) -> dict: + """ + Saves the state of the bomb into a dictionary + """ + d = super().save_state() + d["exploding"] = self.exploding + d["damage"] = self.damage + return d + + +class BodySnatchPotion(Item): + """ + The body-snatch potion allows to exchange all characteristics with a random + other entity. + """ + + def __init__(self, name: str = "body_snatch_potion", *args, **kwargs): + super().__init__(name=name, *args, **kwargs) + + def use(self) -> None: + """ + Find a valid random entity, then exchange characteristics. + """ + valid_entities = self.held_by.map.find_entities(FightingEntity) + valid_entities.remove(self.held_by) + entity = choice(valid_entities) + entity_state = entity.save_state() + player_state = self.held_by.save_state() + self.held_by.__dict__.update(entity_state) + entity.__dict__.update(player_state) + self.held_by.map.currenty, self.held_by.map.currentx = self.held_by.y,\ + self.held_by.x + + self.held_by.map.logs.add_message( + _("{player} exchanged its body with {entity}.").format( + player=self.held_by.translated_name.capitalize(), + entity=entity.translated_name)) + + self.held_by.recalculate_paths() + + self.held_by.inventory.remove(self) diff --git a/squirrelbattle/entities/monsters.py b/squirrelbattle/entities/monsters.py new file mode 100644 index 0000000..34cd4bf --- /dev/null +++ b/squirrelbattle/entities/monsters.py @@ -0,0 +1,102 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import shuffle + +from .player import Player +from ..interfaces import FightingEntity, Map + + +class Monster(FightingEntity): + """ + The class for all monsters in the dungeon. + A monster must override this class, and the parameters are given + in the __init__ function. + An example of the specification of a monster that has a strength of 4 + and 20 max HP: + + class MyMonster(Monster): + def __init__(self, strength: int = 4, maxhealth: int = 20, + *args, **kwargs) -> None: + super().__init__(name="my_monster", strength=strength, + maxhealth=maxhealth, *args, **kwargs) + + With that way, attributes can be overwritten when the entity got created. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def act(self, m: Map) -> None: + """ + By default, a monster will move randomly where it is possible + And if a player is close to the monster, the monster run on the player. + """ + target = None + for entity in m.entities: + if self.distance_squared(entity) <= 25 and \ + isinstance(entity, Player): + target = entity + break + + # A Dijkstra algorithm has ran that targets the player. + # With that way, monsters can simply follow the path. + # If they can't move and they are already close to the player, + # They hit. + if target and (self.y, self.x) in target.paths: + # Move to target player by choosing the best avaliable path + for next_y, next_x in target.paths[(self.y, self.x)]: + moved = self.check_move(next_y, next_x, True) + if moved: + break + if self.distance_squared(target) <= 1: + self.map.logs.add_message(self.hit(target)) + break + else: + # Move in a random direction + # If the direction is not available, try another one + moves = [self.move_up, self.move_down, + self.move_left, self.move_right] + shuffle(moves) + for move in moves: + if move(): + break + + +class Tiger(Monster): + """ + A tiger monster + """ + def __init__(self, name: str = "tiger", strength: int = 2, + maxhealth: int = 20, *args, **kwargs) -> None: + super().__init__(name=name, strength=strength, + maxhealth=maxhealth, *args, **kwargs) + + +class Hedgehog(Monster): + """ + A really mean hedgehog monster + """ + def __init__(self, name: str = "hedgehog", strength: int = 3, + maxhealth: int = 10, *args, **kwargs) -> None: + super().__init__(name=name, strength=strength, + maxhealth=maxhealth, *args, **kwargs) + + +class Rabbit(Monster): + """ + A rabbit monster + """ + def __init__(self, name: str = "rabbit", strength: int = 1, + maxhealth: int = 15, *args, **kwargs) -> None: + super().__init__(name=name, strength=strength, + maxhealth=maxhealth, *args, **kwargs) + + +class TeddyBear(Monster): + """ + A cute teddybear monster + """ + def __init__(self, name: str = "teddy_bear", strength: int = 0, + maxhealth: int = 50, *args, **kwargs) -> None: + super().__init__(name=name, strength=strength, + maxhealth=maxhealth, *args, **kwargs) diff --git a/squirrelbattle/entities/player.py b/squirrelbattle/entities/player.py new file mode 100644 index 0000000..45e2bdf --- /dev/null +++ b/squirrelbattle/entities/player.py @@ -0,0 +1,153 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from functools import reduce +from queue import PriorityQueue +from random import randint +from typing import Dict, Tuple + +from ..interfaces import FightingEntity + + +class Player(FightingEntity): + """ + The class of the player + """ + current_xp: int = 0 + max_xp: int = 10 + inventory: list + paths: Dict[Tuple[int, int], Tuple[int, int]] + + def __init__(self, name: str = "player", maxhealth: int = 20, + strength: int = 5, intelligence: int = 1, charisma: int = 1, + dexterity: int = 1, constitution: int = 1, level: int = 1, + current_xp: int = 0, max_xp: int = 10, inventory: list = None, + *args, **kwargs) \ + -> None: + super().__init__(name=name, maxhealth=maxhealth, strength=strength, + intelligence=intelligence, charisma=charisma, + dexterity=dexterity, constitution=constitution, + level=level, *args, **kwargs) + self.current_xp = current_xp + self.max_xp = max_xp + self.inventory = inventory if inventory else list() + for i in range(len(self.inventory)): + if isinstance(self.inventory[i], dict): + entity_classes = self.get_all_entity_classes_in_a_dict() + item_class = entity_classes[self.inventory[i]["type"]] + self.inventory[i] = item_class(**self.inventory[i]) + self.paths = dict() + + def move(self, y: int, x: int) -> None: + """ + Moves the view of the map (the point on which the camera is centered) + according to the moves of the player. + """ + super().move(y, x) + self.map.currenty = y + self.map.currentx = x + self.recalculate_paths() + + def level_up(self) -> None: + """ + Add levels to the player as much as it is possible. + """ + while self.current_xp > self.max_xp: + self.level += 1 + self.current_xp -= self.max_xp + self.max_xp = self.level * 10 + self.health = self.maxhealth + self.strength = self.strength + 1 + # TODO Remove it, that's only fun + self.map.spawn_random_entities(randint(3 * self.level, + 10 * self.level)) + + def add_xp(self, xp: int) -> None: + """ + Add some experience to the player. + If the required amount is reached, level up. + """ + self.current_xp += xp + self.level_up() + + # noinspection PyTypeChecker,PyUnresolvedReferences + def check_move(self, y: int, x: int, move_if_possible: bool = False) \ + -> bool: + """ + If the player tries to move but a fighting entity is there, + the player fights this entity. + If the entity dies, the player is rewarded with some XP + """ + # Don't move if we are dead + if self.dead: + return False + for entity in self.map.entities: + if entity.y == y and entity.x == x: + if entity.is_fighting_entity(): + self.map.logs.add_message(self.hit(entity)) + if entity.dead: + self.add_xp(randint(3, 7)) + return True + elif entity.is_item(): + entity.hold(self) + return super().check_move(y, x, move_if_possible) + + def recalculate_paths(self, max_distance: int = 8) -> None: + """ + Use Dijkstra algorithm to calculate best paths for monsters to go to + the player. Actually, the paths are computed for each tile adjacent to + the player then for each step the monsters use the best path avaliable. + """ + distances = [] + predecessors = [] + # four Dijkstras, one for each adjacent tile + for dir_y, dir_x in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + queue = PriorityQueue() + new_y, new_x = self.y + dir_y, self.x + dir_x + if not 0 <= new_y < self.map.height or \ + not 0 <= new_x < self.map.width or \ + not self.map.tiles[new_y][new_x].can_walk(): + continue + queue.put(((1, 0), (new_y, new_x))) + visited = [(self.y, self.x)] + distances.append({(self.y, self.x): (0, 0), (new_y, new_x): (1, 0)}) + predecessors.append({(new_y, new_x): (self.y, self.x)}) + while not queue.empty(): + dist, (y, x) = queue.get() + if dist[0] >= max_distance or (y, x) in visited: + continue + visited.append((y, x)) + for diff_y, diff_x in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + new_y, new_x = y + diff_y, x + diff_x + if not 0 <= new_y < self.map.height or \ + not 0 <= new_x < self.map.width or \ + not self.map.tiles[new_y][new_x].can_walk(): + continue + new_distance = (dist[0] + 1, + dist[1] + (not self.map.is_free(y, x))) + if not (new_y, new_x) in distances[-1] or \ + distances[-1][(new_y, new_x)] > new_distance: + predecessors[-1][(new_y, new_x)] = (y, x) + distances[-1][(new_y, new_x)] = new_distance + queue.put((new_distance, (new_y, new_x))) + # For each tile that is reached by at least one Dijkstra, sort the + # different paths by distance to the player. For the technical bits : + # The reduce function is a fold starting on the first element of the + # iterable, and we associate the points to their distance, sort + # along the distance, then only keep the points. + self.paths = {} + for y, x in reduce(set.union, + [set(p.keys()) for p in predecessors], set()): + self.paths[(y, x)] = [p for d, p in sorted( + [(distances[i][(y, x)], predecessors[i][(y, x)]) + for i in range(len(distances)) if (y, x) in predecessors[i]])] + + def save_state(self) -> dict: + """ + Saves the state of the entity into a dictionary + """ + d = super().save_state() + d["current_xp"] = self.current_xp + d["max_xp"] = self.max_xp + d["inventory"] = [item.save_state() for item in self.inventory] + return d diff --git a/dungeonbattle/enums.py b/squirrelbattle/enums.py similarity index 58% rename from dungeonbattle/enums.py rename to squirrelbattle/enums.py index 2a6b993..84eb498 100644 --- a/dungeonbattle/enums.py +++ b/squirrelbattle/enums.py @@ -1,15 +1,27 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + from enum import Enum, auto from typing import Optional -from dungeonbattle.settings import Settings +from squirrelbattle.settings import Settings + +# This file contains a few useful enumeration classes used elsewhere in the code class DisplayActions(Enum): + """ + Display actions options for the callable displayaction Game uses + It just calls the same action on the display object displayaction refers to. + """ REFRESH = auto() UPDATE = auto() class GameMode(Enum): + """ + Game mode options + """ MAINMENU = auto() PLAY = auto() SETTINGS = auto() @@ -17,11 +29,18 @@ class GameMode(Enum): class KeyValues(Enum): + """ + Key values options used in the game + """ UP = auto() DOWN = auto() LEFT = auto() RIGHT = auto() ENTER = auto() + INVENTORY = auto() + USE = auto() + EQUIP = auto() + DROP = auto() SPACE = auto() @staticmethod @@ -43,6 +62,14 @@ class KeyValues(Enum): return KeyValues.UP elif key == settings.KEY_ENTER: return KeyValues.ENTER + elif key == settings.KEY_INVENTORY: + return KeyValues.INVENTORY + elif key == settings.KEY_USE: + return KeyValues.USE + elif key == settings.KEY_EQUIP: + return KeyValues.EQUIP + elif key == settings.KEY_DROP: + return KeyValues.DROP elif key == ' ': return KeyValues.SPACE return None diff --git a/squirrelbattle/game.py b/squirrelbattle/game.py new file mode 100644 index 0000000..6d9e9e7 --- /dev/null +++ b/squirrelbattle/game.py @@ -0,0 +1,216 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from json import JSONDecodeError +from random import randint +from typing import Any, Optional +import json +import os +import sys + +from .entities.player import Player +from .enums import GameMode, KeyValues, DisplayActions +from .interfaces import Map, Logs +from .resources import ResourceManager +from .settings import Settings +from . import menus +from .translations import gettext as _, Translator +from typing import Callable + + +class Game: + """ + The game object controls all actions in the game. + """ + map: Map + player: Player + # display_actions is a display interface set by the bootstrapper + display_actions: Callable[[DisplayActions], None] + + def __init__(self) -> None: + """ + Init the game. + """ + self.state = GameMode.MAINMENU + self.settings = Settings() + self.settings.load_settings() + self.settings.write_settings() + Translator.setlocale(self.settings.LOCALE) + self.main_menu = menus.MainMenu() + self.settings_menu = menus.SettingsMenu() + self.settings_menu.update_values(self.settings) + self.inventory_menu = menus.InventoryMenu() + self.logs = Logs() + self.message = None + + def new_game(self) -> None: + """ + Create a new game on the screen. + """ + # TODO generate a new map procedurally + self.map = Map.load(ResourceManager.get_asset_path("example_map.txt")) + self.map.logs = self.logs + self.logs.clear() + self.player = Player() + self.map.add_entity(self.player) + self.player.move(self.map.start_y, self.map.start_x) + self.map.spawn_random_entities(randint(3, 10)) + self.inventory_menu.update_player(self.player) + + def run(self, screen: Any) -> None: + """ + Main infinite loop. + We wait for the player's action, then we do what that should be done + when the given key gets pressed. + """ + while True: # pragma no cover + screen.erase() + screen.refresh() + self.display_actions(DisplayActions.REFRESH) + key = screen.getkey() + self.handle_key_pressed( + KeyValues.translate_key(key, self.settings), key) + + def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str = '')\ + -> None: + """ + Indicates what should be done when the given key is pressed, + according to the current game state. + """ + if self.message: + self.message = None + self.display_actions(DisplayActions.REFRESH) + return + + if self.state == GameMode.PLAY: + self.handle_key_pressed_play(key) + elif self.state == GameMode.INVENTORY: + self.handle_key_pressed_inventory(key) + elif self.state == GameMode.MAINMENU: + self.handle_key_pressed_main_menu(key) + elif self.state == GameMode.SETTINGS: + self.settings_menu.handle_key_pressed(key, raw_key, self) + self.display_actions(DisplayActions.REFRESH) + + def handle_key_pressed_play(self, key: KeyValues) -> None: + """ + In play mode, arrows or zqsd move the main character. + """ + if key == KeyValues.UP: + if self.player.move_up(): + self.map.tick() + elif key == KeyValues.DOWN: + if self.player.move_down(): + self.map.tick() + elif key == KeyValues.LEFT: + if self.player.move_left(): + self.map.tick() + elif key == KeyValues.RIGHT: + if self.player.move_right(): + self.map.tick() + elif key == KeyValues.INVENTORY: + self.state = GameMode.INVENTORY + elif key == KeyValues.SPACE: + self.state = GameMode.MAINMENU + + def handle_key_pressed_inventory(self, key: KeyValues) -> None: + """ + In the inventory menu, we can interact with items or close the menu. + """ + if key == KeyValues.SPACE or key == KeyValues.INVENTORY: + self.state = GameMode.PLAY + elif key == KeyValues.UP: + self.inventory_menu.go_up() + elif key == KeyValues.DOWN: + self.inventory_menu.go_down() + if self.inventory_menu.values and not self.player.dead: + if key == KeyValues.USE: + self.inventory_menu.validate().use() + elif key == KeyValues.EQUIP: + self.inventory_menu.validate().equip() + elif key == KeyValues.DROP: + self.inventory_menu.validate().drop() + + # Ensure that the cursor has a good position + self.inventory_menu.position = min(self.inventory_menu.position, + len(self.inventory_menu.values) + - 1) + + def handle_key_pressed_main_menu(self, key: KeyValues) -> None: + """ + In the main menu, we can navigate through options. + """ + if key == KeyValues.DOWN: + self.main_menu.go_down() + if key == KeyValues.UP: + self.main_menu.go_up() + if key == KeyValues.ENTER: + option = self.main_menu.validate() + if option == menus.MainMenuValues.START: + self.new_game() + self.display_actions(DisplayActions.UPDATE) + self.state = GameMode.PLAY + if option == menus.MainMenuValues.RESUME: + self.state = GameMode.PLAY + elif option == menus.MainMenuValues.SAVE: + self.save_game() + elif option == menus.MainMenuValues.LOAD: + self.load_game() + elif option == menus.MainMenuValues.SETTINGS: + self.state = GameMode.SETTINGS + elif option == menus.MainMenuValues.EXIT: + sys.exit(0) + + def save_state(self) -> dict: + """ + Saves the game to a dictionary + """ + return self.map.save_state() + + def load_state(self, d: dict) -> None: + """ + Loads the game from a dictionary + """ + try: + self.map.load_state(d) + except KeyError: + self.message = _("Some keys are missing in your save file.\n" + "Your save seems to be corrupt. It got deleted.") + os.unlink(ResourceManager.get_config_path("save.json")) + self.display_actions(DisplayActions.UPDATE) + return + + players = self.map.find_entities(Player) + if not players: + self.message = _("No player was found on this map!\n" + "Maybe you died?") + self.player.health = 0 + self.display_actions(DisplayActions.UPDATE) + return + + self.player = players[0] + self.display_actions(DisplayActions.UPDATE) + + def load_game(self) -> None: + """ + Loads the game from a file + """ + file_path = ResourceManager.get_config_path("save.json") + if os.path.isfile(file_path): + with open(file_path, "r") as f: + try: + state = json.loads(f.read()) + self.load_state(state) + except JSONDecodeError: + self.message = _("The JSON file is not correct.\n" + "Your save seems corrupted. " + "It got deleted.") + os.unlink(file_path) + self.display_actions(DisplayActions.UPDATE) + + def save_game(self) -> None: + """ + Saves the game to a file + """ + with open(ResourceManager.get_config_path("save.json"), "w") as f: + f.write(json.dumps(self.save_state())) diff --git a/squirrelbattle/interfaces.py b/squirrelbattle/interfaces.py new file mode 100644 index 0000000..3567ea0 --- /dev/null +++ b/squirrelbattle/interfaces.py @@ -0,0 +1,438 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from enum import Enum, auto +from math import sqrt +from random import choice, randint +from typing import List, Optional + +from .display.texturepack import TexturePack +from .translations import gettext as _ + + +class Logs: + """ + The logs object stores the messages to display. It is encapsulating a list + of such messages, to allow multiple pointers to keep track of it even if + the list was to be reassigned. + """ + + def __init__(self) -> None: + self.messages = [] + + def add_message(self, msg: str) -> None: + self.messages.append(msg) + + def add_messages(self, msg: List[str]) -> None: + self.messages += msg + + def clear(self) -> None: + self.messages = [] + + +class Map: + """ + Object that represents a Map with its width, height + and tiles, that have their custom properties. + """ + width: int + height: int + start_y: int + start_x: int + tiles: List[List["Tile"]] + entities: List["Entity"] + logs: Logs + # coordinates of the point that should be + # on the topleft corner of the screen + currentx: int + currenty: int + + def __init__(self, width: int, height: int, tiles: list, + start_y: int, start_x: int): + self.width = width + self.height = height + self.start_y = start_y + self.start_x = start_x + self.tiles = tiles + self.entities = [] + self.logs = Logs() + + def add_entity(self, entity: "Entity") -> None: + """ + Register a new entity in the map. + """ + self.entities.append(entity) + entity.map = self + + def remove_entity(self, entity: "Entity") -> None: + """ + Unregister an entity from the map. + """ + self.entities.remove(entity) + + def find_entities(self, entity_class: type) -> list: + return [entity for entity in self.entities + if isinstance(entity, entity_class)] + + def is_free(self, y: int, x: int) -> bool: + """ + Indicates that the case at the coordinates (y, x) is empty. + """ + return 0 <= y < self.height and 0 <= x < self.width and \ + self.tiles[y][x].can_walk() and \ + not any(entity.x == x and entity.y == y for entity in self.entities) + + @staticmethod + def load(filename: str) -> "Map": + """ + Read a file that contains the content of a map, and build a Map object. + """ + with open(filename, "r") as f: + file = f.read() + return Map.load_from_string(file) + + @staticmethod + def load_from_string(content: str) -> "Map": + """ + Load a map represented by its characters and build a Map object. + """ + lines = content.split("\n") + first_line = lines[0] + start_y, start_x = map(int, first_line.split(" ")) + lines = [line for line in lines[1:] if line] + height = len(lines) + width = len(lines[0]) + tiles = [[Tile.from_ascii_char(c) + for x, c in enumerate(line)] for y, line in enumerate(lines)] + + return Map(width, height, tiles, start_y, start_x) + + @staticmethod + def load_dungeon_from_string(content: str) -> List[List["Tile"]]: + """ + Transforms a string into the list of corresponding tiles + """ + lines = content.split("\n") + tiles = [[Tile.from_ascii_char(c) + for x, c in enumerate(line)] for y, line in enumerate(lines)] + return tiles + + def draw_string(self, pack: TexturePack) -> str: + """ + Draw the current map as a string object that can be rendered + in the window. + """ + return "\n".join("".join(tile.char(pack) for tile in line) + for line in self.tiles) + + def spawn_random_entities(self, count: int) -> None: + """ + Put randomly {count} hedgehogs on the map, where it is available. + """ + for ignored in range(count): + y, x = 0, 0 + while True: + y, x = randint(0, self.height - 1), randint(0, self.width - 1) + tile = self.tiles[y][x] + if tile.can_walk(): + break + entity = choice(Entity.get_all_entity_classes())() + entity.move(y, x) + self.add_entity(entity) + + def tick(self) -> None: + """ + Trigger all entity events. + """ + for entity in self.entities: + entity.act(self) + + def save_state(self) -> dict: + """ + Saves the map's attributes to a dictionary + """ + d = dict() + d["width"] = self.width + d["height"] = self.height + d["start_y"] = self.start_y + d["start_x"] = self.start_x + d["currentx"] = self.currentx + d["currenty"] = self.currenty + d["entities"] = [] + for enti in self.entities: + d["entities"].append(enti.save_state()) + d["map"] = self.draw_string(TexturePack.ASCII_PACK) + return d + + def load_state(self, d: dict) -> None: + """ + Loads the map's attributes from a dictionary + """ + self.width = d["width"] + self.height = d["height"] + self.start_y = d["start_y"] + self.start_x = d["start_x"] + self.currentx = d["currentx"] + self.currenty = d["currenty"] + self.tiles = self.load_dungeon_from_string(d["map"]) + self.entities = [] + dictclasses = Entity.get_all_entity_classes_in_a_dict() + for entisave in d["entities"]: + self.add_entity(dictclasses[entisave["type"]](**entisave)) + + +class Tile(Enum): + """ + The internal representation of the tiles of the map + """ + EMPTY = auto() + WALL = auto() + FLOOR = auto() + + @staticmethod + def from_ascii_char(ch: str) -> "Tile": + """ + Maps an ascii character to its equivalent in the texture pack + """ + for tile in Tile: + if tile.char(TexturePack.ASCII_PACK) == ch: + return tile + raise ValueError(ch) + + def char(self, pack: TexturePack) -> str: + """ + Translates a Tile to the corresponding character according + to the texture pack + """ + return getattr(pack, self.name) + + def is_wall(self) -> bool: + """ + Is this Tile a wall? + """ + return self == Tile.WALL + + def can_walk(self) -> bool: + """ + Check if an entity (player or not) can move in this tile. + """ + return not self.is_wall() and self != Tile.EMPTY + + +class Entity: + """ + An Entity object represents any entity present on the map + """ + y: int + x: int + name: str + map: Map + + # noinspection PyShadowingBuiltins + def __init__(self, y: int = 0, x: int = 0, name: Optional[str] = None, + map: Optional[Map] = None, *ignored, **ignored2): + self.y = y + self.x = x + self.name = name + self.map = map + + def check_move(self, y: int, x: int, move_if_possible: bool = False)\ + -> bool: + """ + Checks if moving to (y,x) is authorized + """ + free = self.map.is_free(y, x) + if free and move_if_possible: + self.move(y, x) + return free + + def move(self, y: int, x: int) -> bool: + """ + Moves an entity to (y,x) coordinates + """ + self.y = y + self.x = x + return True + + def move_up(self, force: bool = False) -> bool: + """ + Moves the entity up one tile, if possible + """ + return self.move(self.y - 1, self.x) if force else \ + self.check_move(self.y - 1, self.x, True) + + def move_down(self, force: bool = False) -> bool: + """ + Moves the entity down one tile, if possible + """ + return self.move(self.y + 1, self.x) if force else \ + self.check_move(self.y + 1, self.x, True) + + def move_left(self, force: bool = False) -> bool: + """ + Moves the entity left one tile, if possible + """ + return self.move(self.y, self.x - 1) if force else \ + self.check_move(self.y, self.x - 1, True) + + def move_right(self, force: bool = False) -> bool: + """ + Moves the entity right one tile, if possible + """ + return self.move(self.y, self.x + 1) if force else \ + self.check_move(self.y, self.x + 1, True) + + def act(self, m: Map) -> None: + """ + Define the action of the entity that is ran each tick. + By default, does nothing. + """ + pass + + def distance_squared(self, other: "Entity") -> int: + """ + Get the square of the distance to another entity. + Useful to check distances since square root takes time. + """ + return (self.y - other.y) ** 2 + (self.x - other.x) ** 2 + + def distance(self, other: "Entity") -> float: + """ + Get the cartesian distance to another entity. + """ + return sqrt(self.distance_squared(other)) + + def is_fighting_entity(self) -> bool: + """ + Is this entity a fighting entity? + """ + return isinstance(self, FightingEntity) + + def is_item(self) -> bool: + """ + Is this entity an item? + """ + from squirrelbattle.entities.items import Item + return isinstance(self, Item) + + @property + def translated_name(self) -> str: + return _(self.name.replace("_", " ")) + + @staticmethod + def get_all_entity_classes(): + """ + Returns all entities subclasses + """ + from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart + from squirrelbattle.entities.monsters import Tiger, Hedgehog, \ + Rabbit, TeddyBear + return [BodySnatchPotion, Bomb, Heart, Hedgehog, + Rabbit, TeddyBear, Tiger] + + @staticmethod + def get_all_entity_classes_in_a_dict() -> dict: + """ + Returns all entities subclasses in a dictionary + """ + from squirrelbattle.entities.player import Player + from squirrelbattle.entities.monsters import Tiger, Hedgehog, Rabbit, \ + TeddyBear + from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart + return { + "Tiger": Tiger, + "Bomb": Bomb, + "Heart": Heart, + "BodySnatchPotion": BodySnatchPotion, + "Hedgehog": Hedgehog, + "Rabbit": Rabbit, + "TeddyBear": TeddyBear, + "Player": Player, + } + + def save_state(self) -> dict: + """ + Saves the coordinates of the entity + """ + d = dict() + d["x"] = self.x + d["y"] = self.y + d["type"] = self.__class__.__name__ + return d + + +class FightingEntity(Entity): + """ + A FightingEntity is an entity that can fight, and thus has a health, + level and stats + """ + maxhealth: int + health: int + strength: int + intelligence: int + charisma: int + dexterity: int + constitution: int + level: int + + def __init__(self, maxhealth: int = 0, health: Optional[int] = None, + strength: int = 0, intelligence: int = 0, charisma: int = 0, + dexterity: int = 0, constitution: int = 0, level: int = 0, + *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.maxhealth = maxhealth + self.health = maxhealth if health is None else health + self.strength = strength + self.intelligence = intelligence + self.charisma = charisma + self.dexterity = dexterity + self.constitution = constitution + self.level = level + + @property + def dead(self) -> bool: + return self.health <= 0 + + def hit(self, opponent: "FightingEntity") -> str: + """ + Deals damage to the opponent, based on the stats + """ + return _("{name} hits {opponent}.")\ + .format(name=_(self.translated_name.capitalize()), + opponent=_(opponent.translated_name)) + " " + \ + opponent.take_damage(self, self.strength) + + def take_damage(self, attacker: "Entity", amount: int) -> str: + """ + Take damage from the attacker, based on the stats + """ + self.health -= amount + if self.health <= 0: + self.die() + return _("{name} takes {amount} damage.")\ + .format(name=self.translated_name.capitalize(), amount=str(amount))\ + + (" " + _("{name} dies.") + .format(name=self.translated_name.capitalize()) + if self.health <= 0 else "") + + def die(self) -> None: + """ + If a fighting entity has no more health, it dies and is removed + """ + self.map.remove_entity(self) + + def keys(self) -> list: + """ + Returns a fighting entities specific attributes + """ + return ["name", "maxhealth", "health", "level", "strength", + "intelligence", "charisma", "dexterity", "constitution"] + + def save_state(self) -> dict: + """ + Saves the state of the entity into a dictionary + """ + d = super().save_state() + for name in self.keys(): + d[name] = getattr(self, name) + return d diff --git a/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po new file mode 100644 index 0000000..38d16a6 --- /dev/null +++ b/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po @@ -0,0 +1,201 @@ +# German translation of Squirrel Battle +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# This file is distributed under the same license as the squirrelbattle package. +# +msgid "" +msgstr "" +"Project-Id-Version: squirrelbattle 3.14.1\n" +"Report-Msgid-Bugs-To: squirrel-battle@crans.org\n" +"POT-Creation-Date: 2020-12-05 14:46+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: squirrelbattle/display/menudisplay.py:105 +msgid "== INVENTORY ==" +msgstr "== BESTAND ==" + +#: squirrelbattle/display/statsdisplay.py:34 +msgid "Inventory:" +msgstr "Bestand:" + +#: squirrelbattle/display/statsdisplay.py:50 +msgid "YOU ARE DEAD" +msgstr "SIE WURDEN GESTORBEN" + +#. The bomb is exploding. +#. Each entity that is close to the bomb takes damages. +#. The player earn XP if the entity was killed. +#: squirrelbattle/entities/items.py:128 +msgid "Bomb is exploding." +msgstr "Die Bombe explodiert." + +#: squirrelbattle/entities/items.py:172 +#, python-brace-format +msgid "{player} exchanged its body with {entity}." +msgstr "{player} täuscht seinem Körper mit {entity} aus." + +#: squirrelbattle/game.py:177 +msgid "" +"Some keys are missing in your save file.\n" +"Your save seems to be corrupt. It got deleted." +msgstr "" +"In Ihrer Speicherdatei fehlen einige Schlüssel.\n" +"Ihre Speicherung scheint korrupt zu sein. Es wird gelöscht." + +#: squirrelbattle/game.py:185 +msgid "" +"No player was found on this map!\n" +"Maybe you died?" +msgstr "" +"Auf dieser Karte wurde kein Spieler gefunden!\n" +"Vielleicht sind Sie gestorben?" + +#: squirrelbattle/game.py:205 +msgid "" +"The JSON file is not correct.\n" +"Your save seems corrupted. It got deleted." +msgstr "" +"Die JSON-Datei ist nicht korrekt.\n" +"Ihre Speicherung scheint korrumpiert. Sie wurde gelöscht." + +#: squirrelbattle/interfaces.py:400 +#, python-brace-format +msgid "{name} hits {opponent}." +msgstr "{name} schlägt {opponent}." + +#: squirrelbattle/interfaces.py:412 +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} nimmt {amount} Schadenspunkte." + +#: squirrelbattle/interfaces.py:414 +#, python-brace-format +msgid "{name} dies." +msgstr "{name} stirbt." + +#: squirrelbattle/menus.py:72 +msgid "Back" +msgstr "Zurück" + +#: squirrelbattle/tests/game_test.py:300 squirrelbattle/tests/game_test.py:303 +#: squirrelbattle/tests/game_test.py:306 +#: squirrelbattle/tests/translations_test.py:16 +msgid "New game" +msgstr "Neu Spiel" + +#: squirrelbattle/tests/translations_test.py:17 +msgid "Resume" +msgstr "Weitergehen" + +#: squirrelbattle/tests/translations_test.py:18 +msgid "Load" +msgstr "Laden" + +#: squirrelbattle/tests/translations_test.py:19 +msgid "Save" +msgstr "Speichern" + +#: squirrelbattle/tests/translations_test.py:20 +msgid "Settings" +msgstr "Einstellungen" + +#: squirrelbattle/tests/translations_test.py:21 +msgid "Exit" +msgstr "Verlassen" + +#: squirrelbattle/tests/translations_test.py:27 +msgid "Main key to move up" +msgstr "Haupttaste zum Obengehen" + +#: squirrelbattle/tests/translations_test.py:29 +msgid "Secondary key to move up" +msgstr "Sekundärtaste zum Obengehen" + +#: squirrelbattle/tests/translations_test.py:31 +msgid "Main key to move down" +msgstr "Haupttaste zum Untergehen" + +#: squirrelbattle/tests/translations_test.py:33 +msgid "Secondary key to move down" +msgstr "Sekundärtaste zum Untergehen" + +#: squirrelbattle/tests/translations_test.py:35 +msgid "Main key to move left" +msgstr "Haupttaste zum Linksgehen" + +#: squirrelbattle/tests/translations_test.py:37 +msgid "Secondary key to move left" +msgstr "Sekundärtaste zum Linksgehen" + +#: squirrelbattle/tests/translations_test.py:39 +msgid "Main key to move right" +msgstr "Haupttaste zum Rechtsgehen" + +#: squirrelbattle/tests/translations_test.py:41 +msgid "Secondary key to move right" +msgstr "Sekundärtaste zum Rechtsgehen" + +#: squirrelbattle/tests/translations_test.py:43 +msgid "Key to validate a menu" +msgstr "Menütaste" + +#: squirrelbattle/tests/translations_test.py:45 +msgid "Key used to open the inventory" +msgstr "Bestandtaste" + +#: squirrelbattle/tests/translations_test.py:47 +msgid "Key used to use an item in the inventory" +msgstr "Taste um eines Objekts im Bestand zu verwenden" + +#: squirrelbattle/tests/translations_test.py:49 +msgid "Key used to equip an item in the inventory" +msgstr "Taste um eines Objekts im Bestand auszurüsten" + +#: squirrelbattle/tests/translations_test.py:51 +msgid "Key used to drop an item in the inventory" +msgstr "Taste um eines Objekts im Bestand zu werfen" + +#: squirrelbattle/tests/translations_test.py:53 +msgid "Texture pack" +msgstr "Textur-Packung" + +#: squirrelbattle/tests/translations_test.py:54 +msgid "Language" +msgstr "Sprache" + +#: squirrelbattle/tests/translations_test.py:57 +msgid "player" +msgstr "Spieler" + +#: squirrelbattle/tests/translations_test.py:59 +msgid "tiger" +msgstr "Tiger" + +#: squirrelbattle/tests/translations_test.py:60 +msgid "hedgehog" +msgstr "Igel" + +#: squirrelbattle/tests/translations_test.py:61 +msgid "rabbit" +msgstr "Kanninchen" + +#: squirrelbattle/tests/translations_test.py:62 +msgid "teddy bear" +msgstr "Teddybär" + +#: squirrelbattle/tests/translations_test.py:64 +msgid "body snatch potion" +msgstr "Leichenfleddererzaubertrank" + +#: squirrelbattle/tests/translations_test.py:65 +msgid "bomb" +msgstr "Bombe" + +#: squirrelbattle/tests/translations_test.py:66 +msgid "heart" +msgstr "Herz" diff --git a/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po new file mode 100644 index 0000000..acbfb5a --- /dev/null +++ b/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po @@ -0,0 +1,206 @@ +# Spanish translation of Squirrel Battle +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# This file is distributed under the same license as the squirrelbattle package. +# Translation by ifugaao +# +msgid "" +msgstr "" +"Project-Id-Version: squirrelbattle 3.14.1\n" +"Report-Msgid-Bugs-To: squirrel-battle@crans.org\n" +"POT-Creation-Date: 2020-12-05 14:46+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: ifugao\n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Suggested in Weblate: == INVENTORIO == +#: squirrelbattle/display/menudisplay.py:105 +msgid "== INVENTORY ==" +msgstr "== INVENTORIO ==" + +# Suggested in Weblate: Inventorio : +#: squirrelbattle/display/statsdisplay.py:34 +msgid "Inventory:" +msgstr "Inventorio :" + +# Suggested in Weblate: ERES MUERTO +#: squirrelbattle/display/statsdisplay.py:50 +msgid "YOU ARE DEAD" +msgstr "ERES MUERTO" + +#. The bomb is exploding. +#. Each entity that is close to the bomb takes damages. +#. The player earn XP if the entity was killed. +#: squirrelbattle/entities/items.py:128 +msgid "Bomb is exploding." +msgstr "La bomba está explotando." + +#: squirrelbattle/entities/items.py:172 +#, python-brace-format +msgid "{player} exchanged its body with {entity}." +msgstr "{player} intercambió su cuerpo con {entity}." + +#: squirrelbattle/game.py:177 +msgid "" +"Some keys are missing in your save file.\n" +"Your save seems to be corrupt. It got deleted." +msgstr "" +"Algunas claves faltan en su archivo de guarda.\n" +"Su guarda parece a ser corruptido. Fue eliminado." + +#: squirrelbattle/game.py:185 +msgid "" +"No player was found on this map!\n" +"Maybe you died?" +msgstr "" +"No jugador encontrado sobre la carta !\n" +"¿ Quizas murió ?" + +#: squirrelbattle/game.py:205 +msgid "" +"The JSON file is not correct.\n" +"Your save seems corrupted. It got deleted." +msgstr "" +"El JSON archivo no es correcto.\n" +"Su guarda parece corrupta. Fue eliminada." + +#: squirrelbattle/interfaces.py:400 +#, python-brace-format +msgid "{name} hits {opponent}." +msgstr "{name} golpea a {opponent}." + +#: squirrelbattle/interfaces.py:412 +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} recibe {amount} daño." + +#: squirrelbattle/interfaces.py:414 +#, python-brace-format +msgid "{name} dies." +msgstr "{name} se muere." + +#: squirrelbattle/menus.py:72 +msgid "Back" +msgstr "Volver" + +#: squirrelbattle/tests/game_test.py:300, +#: squirrelbattle/tests/game_test.py:303, +#: squirrelbattle/tests/game_test.py:306, +#: squirrelbattle/tests/translations_test.py:16 +msgid "New game" +msgstr "Nuevo partido" + +#: squirrelbattle/tests/translations_test.py:17 +msgid "Resume" +msgstr "Resumir" + +#: squirrelbattle/tests/translations_test.py:18 +msgid "Load" +msgstr "Cargar" + +#: squirrelbattle/tests/translations_test.py:19 +msgid "Save" +msgstr "Guardar" + +#: squirrelbattle/tests/translations_test.py:20 +msgid "Settings" +msgstr "Parametros" + +#: squirrelbattle/tests/translations_test.py:21 +msgid "Exit" +msgstr "Salir" + +#: squirrelbattle/tests/translations_test.py:27 +msgid "Main key to move up" +msgstr "Primera tecla para subir" + +#: squirrelbattle/tests/translations_test.py:29 +msgid "Secondary key to move up" +msgstr "Segunda tecla para subir" + +#: squirrelbattle/tests/translations_test.py:31 +msgid "Main key to move down" +msgstr "Primera tecla para bajar" + +#: squirrelbattle/tests/translations_test.py:33 +msgid "Secondary key to move down" +msgstr "Segunda tecla para bajar" + +#: squirrelbattle/tests/translations_test.py:35 +msgid "Main key to move left" +msgstr "Primera tecla para moverse a la izquierda" + +#: squirrelbattle/tests/translations_test.py:37 +msgid "Secondary key to move left" +msgstr "Segunda tecla para moverse a la izquierda" + +#: squirrelbattle/tests/translations_test.py:39 +msgid "Main key to move right" +msgstr "Primera tecla para moverse a la derecha" + +#: squirrelbattle/tests/translations_test.py:41 +msgid "Secondary key to move right" +msgstr "Segunda tecla para moverse a la derecha" + +#: squirrelbattle/tests/translations_test.py:43 +msgid "Key to validate a menu" +msgstr "Tecla para validar un menú" + +#: squirrelbattle/tests/translations_test.py:45 +msgid "Key used to open the inventory" +msgstr "Tecla para abrir el inventorio" + +#: squirrelbattle/tests/translations_test.py:47 +msgid "Key used to use an item in the inventory" +msgstr "Tecla para utilizar un objeto del inventorio" + +#: squirrelbattle/tests/translations_test.py:49 +msgid "Key used to equip an item in the inventory" +msgstr "Tecla para equipar un objeto del inventorio" + +#: squirrelbattle/tests/translations_test.py:51 +msgid "Key used to drop an item in the inventory" +msgstr "Tecla para dejar un objeto del inventorio" + +#: squirrelbattle/tests/translations_test.py:53 +msgid "Texture pack" +msgstr "Paquete de texturas" + +#: squirrelbattle/tests/translations_test.py:54 +msgid "Language" +msgstr "Languaje" + +#: squirrelbattle/tests/translations_test.py:57 +msgid "player" +msgstr "jugador" + +#: squirrelbattle/tests/translations_test.py:59 +msgid "tiger" +msgstr "tigre" + +#: squirrelbattle/tests/translations_test.py:60 +msgid "hedgehog" +msgstr "erizo" + +#: squirrelbattle/tests/translations_test.py:61 +msgid "rabbit" +msgstr "conejo" + +#: squirrelbattle/tests/translations_test.py:62 +msgid "teddy bear" +msgstr "osito de peluche" + +#: squirrelbattle/tests/translations_test.py:64 +msgid "body snatch potion" +msgstr "poción de intercambio" + +#: squirrelbattle/tests/translations_test.py:65 +msgid "bomb" +msgstr "bomba" + +#: squirrelbattle/tests/translations_test.py:66 +msgid "heart" +msgstr "corazón" diff --git a/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po new file mode 100644 index 0000000..85bd728 --- /dev/null +++ b/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po @@ -0,0 +1,202 @@ +# French translation of Squirrel Battle +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# This file is distributed under the same license as the squirrelbattle package. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: squirrelbattle 3.14.1\n" +"Report-Msgid-Bugs-To: squirrel-battle@crans.org\n" +"POT-Creation-Date: 2020-12-05 14:46+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: squirrelbattle/display/menudisplay.py:105 +msgid "== INVENTORY ==" +msgstr "== INVENTAIRE ==" + +#: squirrelbattle/display/statsdisplay.py:34 +msgid "Inventory:" +msgstr "Inventaire :" + +#: squirrelbattle/display/statsdisplay.py:50 +msgid "YOU ARE DEAD" +msgstr "VOUS ÊTES MORT" + +#. The bomb is exploding. +#. Each entity that is close to the bomb takes damages. +#. The player earn XP if the entity was killed. +#: squirrelbattle/entities/items.py:128 +msgid "Bomb is exploding." +msgstr "La bombe explose." + +#: squirrelbattle/entities/items.py:172 +#, python-brace-format +msgid "{player} exchanged its body with {entity}." +msgstr "{player} a échangé son corps avec {entity}." + +#: squirrelbattle/game.py:177 +msgid "" +"Some keys are missing in your save file.\n" +"Your save seems to be corrupt. It got deleted." +msgstr "" +"Certaines clés de votre ficher de sauvegarde sont manquantes.\n" +"Votre sauvegarde semble corrompue. Elle a été supprimée." + +#: squirrelbattle/game.py:185 +msgid "" +"No player was found on this map!\n" +"Maybe you died?" +msgstr "" +"Aucun joueur n'a été trouvé sur la carte !\n" +"Peut-être êtes-vous mort ?" + +#: squirrelbattle/game.py:205 +msgid "" +"The JSON file is not correct.\n" +"Your save seems corrupted. It got deleted." +msgstr "" +"Le fichier JSON de sauvegarde est incorrect.\n" +"Votre sauvegarde semble corrompue. Elle a été supprimée." + +#: squirrelbattle/interfaces.py:400 +#, python-brace-format +msgid "{name} hits {opponent}." +msgstr "{name} frappe {opponent}." + +#: squirrelbattle/interfaces.py:412 +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} prend {amount} points de dégât." + +#: squirrelbattle/interfaces.py:414 +#, python-brace-format +msgid "{name} dies." +msgstr "{name} meurt." + +#: squirrelbattle/menus.py:72 +msgid "Back" +msgstr "Retour" + +#: squirrelbattle/tests/game_test.py:300 squirrelbattle/tests/game_test.py:303 +#: squirrelbattle/tests/game_test.py:306 +#: squirrelbattle/tests/translations_test.py:16 +msgid "New game" +msgstr "Nouvelle partie" + +#: squirrelbattle/tests/translations_test.py:17 +msgid "Resume" +msgstr "Continuer" + +#: squirrelbattle/tests/translations_test.py:18 +msgid "Load" +msgstr "Charger" + +#: squirrelbattle/tests/translations_test.py:19 +msgid "Save" +msgstr "Sauvegarder" + +#: squirrelbattle/tests/translations_test.py:20 +msgid "Settings" +msgstr "Paramètres" + +#: squirrelbattle/tests/translations_test.py:21 +msgid "Exit" +msgstr "Quitter" + +#: squirrelbattle/tests/translations_test.py:27 +msgid "Main key to move up" +msgstr "Touche principale pour aller vers le haut" + +#: squirrelbattle/tests/translations_test.py:29 +msgid "Secondary key to move up" +msgstr "Touche secondaire pour aller vers le haut" + +#: squirrelbattle/tests/translations_test.py:31 +msgid "Main key to move down" +msgstr "Touche principale pour aller vers le bas" + +#: squirrelbattle/tests/translations_test.py:33 +msgid "Secondary key to move down" +msgstr "Touche secondaire pour aller vers le bas" + +#: squirrelbattle/tests/translations_test.py:35 +msgid "Main key to move left" +msgstr "Touche principale pour aller vers la gauche" + +#: squirrelbattle/tests/translations_test.py:37 +msgid "Secondary key to move left" +msgstr "Touche secondaire pour aller vers la gauche" + +#: squirrelbattle/tests/translations_test.py:39 +msgid "Main key to move right" +msgstr "Touche principale pour aller vers la droite" + +#: squirrelbattle/tests/translations_test.py:41 +msgid "Secondary key to move right" +msgstr "Touche secondaire pour aller vers la droite" + +#: squirrelbattle/tests/translations_test.py:43 +msgid "Key to validate a menu" +msgstr "Touche pour valider un menu" + +#: squirrelbattle/tests/translations_test.py:45 +msgid "Key used to open the inventory" +msgstr "Touche utilisée pour ouvrir l'inventaire" + +#: squirrelbattle/tests/translations_test.py:47 +msgid "Key used to use an item in the inventory" +msgstr "Touche pour utiliser un objet de l'inventaire" + +#: squirrelbattle/tests/translations_test.py:49 +msgid "Key used to equip an item in the inventory" +msgstr "Touche pour équiper un objet de l'inventaire" + +#: squirrelbattle/tests/translations_test.py:51 +msgid "Key used to drop an item in the inventory" +msgstr "Touche pour jeter un objet de l'inventaire" + +#: squirrelbattle/tests/translations_test.py:53 +msgid "Texture pack" +msgstr "Pack de textures" + +#: squirrelbattle/tests/translations_test.py:54 +msgid "Language" +msgstr "Langue" + +#: squirrelbattle/tests/translations_test.py:57 +msgid "player" +msgstr "joueur" + +#: squirrelbattle/tests/translations_test.py:59 +msgid "tiger" +msgstr "tigre" + +#: squirrelbattle/tests/translations_test.py:60 +msgid "hedgehog" +msgstr "hérisson" + +#: squirrelbattle/tests/translations_test.py:61 +msgid "rabbit" +msgstr "lapin" + +#: squirrelbattle/tests/translations_test.py:62 +msgid "teddy bear" +msgstr "nounours" + +#: squirrelbattle/tests/translations_test.py:64 +msgid "body snatch potion" +msgstr "potion d'arrachage de corps" + +#: squirrelbattle/tests/translations_test.py:65 +msgid "bomb" +msgstr "bombe" + +#: squirrelbattle/tests/translations_test.py:66 +msgid "heart" +msgstr "cœur" diff --git a/dungeonbattle/__init__.py b/squirrelbattle/mapgeneration/__init__.py similarity index 100% rename from dungeonbattle/__init__.py rename to squirrelbattle/mapgeneration/__init__.py diff --git a/dungeonbattle/mapgeneration/randomwalk.py b/squirrelbattle/mapgeneration/randomwalk.py similarity index 100% rename from dungeonbattle/mapgeneration/randomwalk.py rename to squirrelbattle/mapgeneration/randomwalk.py diff --git a/dungeonbattle/menus.py b/squirrelbattle/menus.py similarity index 56% rename from dungeonbattle/menus.py rename to squirrelbattle/menus.py index 0d7a542..3a536f3 100644 --- a/dungeonbattle/menus.py +++ b/squirrelbattle/menus.py @@ -1,87 +1,80 @@ -import sys +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + from enum import Enum from typing import Any, Optional from .display.texturepack import TexturePack +from .entities.player import Player from .enums import GameMode, KeyValues, DisplayActions from .settings import Settings +from .translations import gettext as _, Translator class Menu: + """ + A Menu object is the logical representation of a menu in the game + """ values: list def __init__(self): self.position = 0 def go_up(self) -> None: + """ + Moves the pointer of the menu on the previous value + """ self.position = max(0, self.position - 1) def go_down(self) -> None: + """ + Moves the pointer of the menu on the next value + """ self.position = min(len(self.values) - 1, self.position + 1) def validate(self) -> Any: + """ + Selects the value that is pointed by the menu pointer + """ return self.values[self.position] class MainMenuValues(Enum): - START = 'Nouvelle partie' - RESUME = 'Continuer' - SETTINGS = 'Paramètres' - EXIT = 'Quitter' + """ + Values of the main menu + """ + START = "New game" + RESUME = "Resume" + SAVE = "Save" + LOAD = "Load" + SETTINGS = "Settings" + EXIT = "Exit" def __str__(self): - return self.value + return _(self.value) class MainMenu(Menu): + """ + A special instance of a menu : the main menu + """ values = [e for e in MainMenuValues] - def handle_key_pressed(self, key: KeyValues, game: Any) -> None: - """ - In the main menu, we can navigate through options. - """ - if key == KeyValues.DOWN: - self.go_down() - if key == KeyValues.UP: - self.go_up() - if key == KeyValues.ENTER: - option = self.validate() - if option == MainMenuValues.START: - game.new_game() - game.state = GameMode.PLAY - game.display_actions(DisplayActions.UPDATE) - elif option == MainMenuValues.RESUME: - game.state = GameMode.PLAY - elif option == MainMenuValues.SETTINGS: - game.state = GameMode.SETTINGS - elif option == MainMenuValues.EXIT: - sys.exit(0) - class SettingsMenu(Menu): + """ + A special instance of a menu : the settings menu + """ waiting_for_key: bool = False def update_values(self, settings: Settings) -> None: - self.values = [] - for i, key in enumerate(settings.settings_keys): - s = settings.get_comment(key) - s += " : " - if self.waiting_for_key and i == self.position: - s += "?" - else: - s += getattr(settings, key).replace("\n", "\\n") - s += 8 * " " # Write over old text - self.values.append(s) - self.values.append("") - self.values.append("Changer le pack de textures n'aura effet") - self.values.append("qu'après avoir relancé le jeu.") - self.values.append("") - self.values.append("Retour (espace)") + self.values = list(settings.__dict__.items()) + self.values.append(("RETURN", ["", _("Back")])) def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str, game: Any) -> None: """ - Update settings + In the setting menu, we van select a setting and change it """ if not self.waiting_for_key: # Navigate normally through the menu. @@ -95,20 +88,27 @@ class SettingsMenu(Menu): self.go_down() if key == KeyValues.UP: self.go_up() - if key == KeyValues.ENTER and self.position < len(self.values) - 3: + if key == KeyValues.ENTER and self.position < len(self.values) - 1: # Change a setting - option = list(game.settings.settings_keys)[self.position] + option = self.values[self.position][0] if option == "TEXTURE_PACK": game.settings.TEXTURE_PACK = \ TexturePack.get_next_pack_name( game.settings.TEXTURE_PACK) game.settings.write_settings() self.update_values(game.settings) + elif option == "LOCALE": + game.settings.LOCALE = 'fr' if game.settings.LOCALE == 'en'\ + else 'de' if game.settings.LOCALE == 'fr' else 'es' \ + if game.settings.LOCALE == 'de' else 'en' + Translator.setlocale(game.settings.LOCALE) + game.settings.write_settings() + self.update_values(game.settings) else: self.waiting_for_key = True self.update_values(game.settings) else: - option = list(game.settings.settings_keys)[self.position] + option = self.values[self.position][0] # Don't use an already mapped key if any(getattr(game.settings, opt) == raw_key for opt in game.settings.settings_keys if opt != option): @@ -119,7 +119,12 @@ class SettingsMenu(Menu): self.update_values(game.settings) -class ArbitraryMenu(Menu): - def __init__(self, values: list): - super().__init__() - self.values = values +class InventoryMenu(Menu): + player: Player + + def update_player(self, player: Player) -> None: + self.player = player + + @property + def values(self) -> list: + return self.player.inventory diff --git a/squirrelbattle/resources.py b/squirrelbattle/resources.py new file mode 100644 index 0000000..b3421db --- /dev/null +++ b/squirrelbattle/resources.py @@ -0,0 +1,24 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from pathlib import Path + + +class ResourceManager: + """ + The ResourceManager loads resources at their right place, + and stores files in config directory. + """ + BASE_DIR = Path(__file__).resolve().parent / 'assets' + # FIXME This might not work on not-UNIX based systems. + CONFIG_DIR = Path.home() / '.config' / 'squirrel-battle' + + @classmethod + def get_asset_path(cls, filename: str) -> str: + return str(cls.BASE_DIR / filename) + + @classmethod + def get_config_path(cls, filename: str) -> str: + cls.CONFIG_DIR.mkdir(parents=True) if not cls.CONFIG_DIR.is_dir() \ + else None + return str(cls.CONFIG_DIR / filename) diff --git a/dungeonbattle/settings.py b/squirrelbattle/settings.py similarity index 55% rename from dungeonbattle/settings.py rename to squirrelbattle/settings.py index 258d88f..4004645 100644 --- a/dungeonbattle/settings.py +++ b/squirrelbattle/settings.py @@ -1,7 +1,14 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import json +import locale import os from typing import Any, Generator +from .resources import ResourceManager +from .translations import gettext as _ + class Settings: """ @@ -11,25 +18,21 @@ class Settings: We can define the setting by simply use settings.TEXTURE_PACK = 'new_key' """ def __init__(self): - self.KEY_UP_PRIMARY = \ - ['z', 'Touche principale pour aller vers le haut'] - self.KEY_UP_SECONDARY = \ - ['KEY_UP', 'Touche secondaire pour aller vers le haut'] - self.KEY_DOWN_PRIMARY = \ - ['s', 'Touche principale pour aller vers le bas'] - self.KEY_DOWN_SECONDARY = \ - ['KEY_DOWN', 'Touche secondaire pour aller vers le bas'] - self.KEY_LEFT_PRIMARY = \ - ['q', 'Touche principale pour aller vers la gauche'] - self.KEY_LEFT_SECONDARY = \ - ['KEY_LEFT', 'Touche secondaire pour aller vers la gauche'] - self.KEY_RIGHT_PRIMARY = \ - ['d', 'Touche principale pour aller vers la droite'] - self.KEY_RIGHT_SECONDARY = \ - ['KEY_RIGHT', 'Touche secondaire pour aller vers la droite'] - self.KEY_ENTER = \ - ['\n', 'Touche pour valider un menu'] - self.TEXTURE_PACK = ['ascii', 'Pack de textures utilisé'] + self.KEY_UP_PRIMARY = ['z', 'Main key to move up'] + self.KEY_UP_SECONDARY = ['KEY_UP', 'Secondary key to move up'] + self.KEY_DOWN_PRIMARY = ['s', 'Main key to move down'] + self.KEY_DOWN_SECONDARY = ['KEY_DOWN', 'Secondary key to move down'] + self.KEY_LEFT_PRIMARY = ['q', 'Main key to move left'] + self.KEY_LEFT_SECONDARY = ['KEY_LEFT', 'Secondary key to move left'] + self.KEY_RIGHT_PRIMARY = ['d', 'Main key to move right'] + self.KEY_RIGHT_SECONDARY = ['KEY_RIGHT', 'Secondary key to move right'] + self.KEY_ENTER = ['\n', 'Key to validate a menu'] + self.KEY_INVENTORY = ['i', 'Key used to open the inventory'] + self.KEY_USE = ['u', 'Key used to use an item in the inventory'] + self.KEY_EQUIP = ['e', 'Key used to equip an item in the inventory'] + self.KEY_DROP = ['r', 'Key used to drop an item in the inventory'] + self.TEXTURE_PACK = ['ascii', 'Texture pack'] + self.LOCALE = [locale.getlocale()[0][:2], 'Language'] def __getattribute__(self, item: str) -> Any: superattribute = super().__getattribute__(item) @@ -48,10 +51,10 @@ class Settings: Retrieve the comment of a setting. """ if item in self.settings_keys: - return object.__getattribute__(self, item)[1] + return _(object.__getattribute__(self, item)[1]) for key in self.settings_keys: if getattr(self, key) == item: - return object.__getattribute__(self, key)[1] + return _(object.__getattribute__(self, key)[1]) @property def settings_keys(self) -> Generator[str, Any, None]: @@ -81,13 +84,14 @@ class Settings: """ Loads the settings from a file """ - if os.path.isfile("settings.json"): - with open("settings.json", "r") as f: + file_path = ResourceManager.get_config_path("settings.json") + if os.path.isfile(file_path): + with open(file_path, "r") as f: self.loads_from_string(f.read()) def write_settings(self) -> None: """ Dumps the settings into a file """ - with open("settings.json", "w") as f: + with open(ResourceManager.get_config_path("settings.json"), "w") as f: f.write(self.dumps_to_string()) diff --git a/dungeonbattle/term_manager.py b/squirrelbattle/term_manager.py similarity index 77% rename from dungeonbattle/term_manager.py rename to squirrelbattle/term_manager.py index a425272..6284173 100644 --- a/dungeonbattle/term_manager.py +++ b/squirrelbattle/term_manager.py @@ -1,8 +1,15 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import curses from types import TracebackType class TermManager: # pragma: no cover + """ + The TermManager object initializes the terminal, returns a screen object and + de-initializes the terminal after use + """ def __init__(self): self.screen = curses.initscr() # convert escapes sequences to curses abstraction diff --git a/squirrelbattle/tests/__init__.py b/squirrelbattle/tests/__init__.py new file mode 100644 index 0000000..1cc6688 --- /dev/null +++ b/squirrelbattle/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/dungeonbattle/tests/entities_test.py b/squirrelbattle/tests/entities_test.py similarity index 62% rename from dungeonbattle/tests/entities_test.py rename to squirrelbattle/tests/entities_test.py index d2c8171..2c72abd 100644 --- a/dungeonbattle/tests/entities_test.py +++ b/squirrelbattle/tests/entities_test.py @@ -1,9 +1,13 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import unittest -from dungeonbattle.entities.items import Bomb, Heart, Item -from dungeonbattle.entities.monsters import Hedgehog -from dungeonbattle.entities.player import Player -from dungeonbattle.interfaces import Entity, Map +from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart, Item +from squirrelbattle.entities.monsters import Tiger, Hedgehog, Rabbit, TeddyBear +from squirrelbattle.entities.player import Player +from squirrelbattle.interfaces import Entity, Map +from squirrelbattle.resources import ResourceManager class TestEntities(unittest.TestCase): @@ -11,7 +15,7 @@ class TestEntities(unittest.TestCase): """ Load example map that can be used in tests. """ - self.map = Map.load("resources/example_map.txt") + self.map = Map.load(ResourceManager.get_asset_path("example_map.txt")) self.player = Player() self.map.add_entity(self.player) self.player.move(self.map.start_y, self.map.start_x) @@ -35,21 +39,20 @@ class TestEntities(unittest.TestCase): """ Test some random stuff with fighting entities. """ - entity = Hedgehog() + entity = Tiger() self.map.add_entity(entity) - self.assertEqual(entity.maxhealth, 10) + self.assertEqual(entity.maxhealth, 20) self.assertEqual(entity.maxhealth, entity.health) - self.assertEqual(entity.strength, 3) - self.assertIsNone(entity.hit(entity)) - self.assertFalse(entity.dead) - self.assertIsNone(entity.hit(entity)) - self.assertFalse(entity.dead) - self.assertIsNone(entity.hit(entity)) - self.assertFalse(entity.dead) - self.assertIsNone(entity.hit(entity)) + self.assertEqual(entity.strength, 2) + for _ in range(9): + self.assertEqual(entity.hit(entity), + "Tiger hits tiger. Tiger takes 2 damage.") + self.assertFalse(entity.dead) + self.assertEqual(entity.hit(entity), "Tiger hits tiger. " + + "Tiger takes 2 damage. Tiger dies.") self.assertTrue(entity.dead) - entity = Hedgehog() + entity = Rabbit() self.map.add_entity(entity) entity.move(15, 44) # Move randomly @@ -61,13 +64,20 @@ class TestEntities(unittest.TestCase): self.map.tick() self.assertTrue(entity.y == 2 and entity.x == 6) - # Hedgehog should fight + # Rabbit should fight old_health = self.player.health self.map.tick() self.assertTrue(entity.y == 2 and entity.x == 6) self.assertEqual(old_health - entity.strength, self.player.health) + self.assertEqual(self.map.logs.messages[-1], + f"{entity.name.capitalize()} hits {self.player.name}. \ +{self.player.name.capitalize()} takes {entity.strength} damage.") - # Fight the hedgehog + # Fight the rabbit + old_health = entity.health + self.player.move_down() + self.assertEqual(entity.health, old_health - self.player.strength) + self.assertFalse(entity.dead) old_health = entity.health self.player.move_down() self.assertEqual(entity.health, old_health - self.player.strength) @@ -87,12 +97,13 @@ class TestEntities(unittest.TestCase): self.assertFalse(item.held) item.hold(self.player) self.assertTrue(item.held) - item.drop(2, 6) - self.assertEqual(item.y, 2) + item.drop() + self.assertEqual(item.y, 1) self.assertEqual(item.x, 6) # Pick up item - self.player.move_down() + self.player.move_left() + self.player.move_right() self.assertTrue(item.held) self.assertEqual(item.held_by, self.player) self.assertIn(item, self.player.inventory) @@ -104,17 +115,29 @@ class TestEntities(unittest.TestCase): """ item = Bomb() hedgehog = Hedgehog() + teddy_bear = TeddyBear() self.map.add_entity(item) self.map.add_entity(hedgehog) + self.map.add_entity(teddy_bear) hedgehog.health = 2 + teddy_bear.health = 2 hedgehog.move(41, 42) + teddy_bear.move(42, 41) item.act(self.map) self.assertFalse(hedgehog.dead) - item.drop(42, 42) + self.assertFalse(teddy_bear.dead) + self.player.move(42, 42) + item.hold(self.player) + item.use() self.assertEqual(item.y, 42) self.assertEqual(item.x, 42) - item.act(self.map) + # Wait for the explosion + for ignored in range(5): + item.act(self.map) self.assertTrue(hedgehog.dead) + self.assertTrue(teddy_bear.dead) + bomb_state = item.save_state() + self.assertEqual(bomb_state["damage"], item.damage) def test_hearts(self) -> None: """ @@ -128,6 +151,26 @@ class TestEntities(unittest.TestCase): self.assertNotIn(item, self.map.entities) self.assertEqual(self.player.health, self.player.maxhealth - item.healing) + heart_state = item.save_state() + self.assertEqual(heart_state["healing"], item.healing) + + def test_body_snatch_potion(self) -> None: + """ + Test some random stuff with body snatch potions. + """ + item = BodySnatchPotion() + self.map.add_entity(item) + item.hold(self.player) + + tiger = Tiger(y=42, x=42) + self.map.add_entity(tiger) + + # The player becomes a tiger, and the tiger becomes a squirrel + item.use() + self.assertEqual(self.player.name, "tiger") + self.assertEqual(tiger.name, "player") + self.assertEqual(self.player.y, 42) + self.assertEqual(self.player.x, 42) def test_players(self) -> None: """ @@ -144,7 +187,7 @@ class TestEntities(unittest.TestCase): self.assertFalse(player.move_up()) self.assertTrue(player.move_left()) self.assertFalse(player.move_left()) - for i in range(8): + for _ in range(8): self.assertTrue(player.move_down()) self.assertFalse(player.move_down()) self.assertTrue(player.move_right()) @@ -158,3 +201,6 @@ class TestEntities(unittest.TestCase): self.assertEqual(player.current_xp, 10) self.assertEqual(player.max_xp, 40) self.assertEqual(player.level, 4) + + player_state = player.save_state() + self.assertEqual(player_state["current_xp"], 10) diff --git a/squirrelbattle/tests/game_test.py b/squirrelbattle/tests/game_test.py new file mode 100644 index 0000000..3a32c95 --- /dev/null +++ b/squirrelbattle/tests/game_test.py @@ -0,0 +1,419 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import unittest + +from ..bootstrap import Bootstrap +from ..display.display import Display +from ..display.display_manager import DisplayManager +from ..entities.items import Bomb +from ..entities.player import Player +from ..enums import DisplayActions +from ..game import Game, KeyValues, GameMode +from ..menus import MainMenuValues +from ..resources import ResourceManager +from ..settings import Settings +from ..translations import gettext as _, Translator + + +class TestGame(unittest.TestCase): + def setUp(self) -> None: + """ + Setup game. + """ + self.game = Game() + self.game.new_game() + self.game.logs.add_message("Hello World !") + display = DisplayManager(None, self.game) + self.game.display_actions = display.handle_display_action + + def test_load_game(self) -> None: + """ + Save a game and reload it. + """ + bomb = Bomb() + self.game.map.add_entity(bomb) + bomb.hold(self.game.player) + old_state = self.game.save_state() + + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), MainMenuValues.SAVE) + self.game.handle_key_pressed(KeyValues.ENTER) # Save game + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), MainMenuValues.LOAD) + self.game.handle_key_pressed(KeyValues.ENTER) # Load game + + new_state = self.game.save_state() + self.assertEqual(old_state, new_state) + + # Ensure that the bomb is loaded + self.assertTrue(self.game.player.inventory) + + # Error on loading save + with open(ResourceManager.get_config_path("save.json"), "w") as f: + f.write("I am not a JSON file") + self.assertIsNone(self.game.message) + self.game.load_game() + self.assertIsNotNone(self.game.message) + self.game.message = None + + with open(ResourceManager.get_config_path("save.json"), "w") as f: + f.write("{}") + self.assertIsNone(self.game.message) + self.game.load_game() + self.assertIsNotNone(self.game.message) + self.game.message = None + + # Load game with a dead player + self.game.map.remove_entity(self.game.player) + self.game.save_game() + self.game.load_game() + self.assertIsNotNone(self.game.message) + + def test_bootstrap_fail(self) -> None: + """ + Ensure that the test can't play the game, + because there is no associated shell. + Yeah, that's only for coverage. + """ + self.assertRaises(Exception, Bootstrap.run_game) + self.assertEqual(os.getenv("TERM", "unknown"), "unknown") + + def test_key_translation(self) -> None: + """ + Test key bindings. + """ + self.game.settings = Settings() + + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_UP_PRIMARY, self.game.settings), + KeyValues.UP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_UP_SECONDARY, self.game.settings), + KeyValues.UP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_DOWN_PRIMARY, self.game.settings), + KeyValues.DOWN) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_DOWN_SECONDARY, self.game.settings), + KeyValues.DOWN) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_LEFT_PRIMARY, self.game.settings), + KeyValues.LEFT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_LEFT_SECONDARY, self.game.settings), + KeyValues.LEFT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_RIGHT_PRIMARY, self.game.settings), + KeyValues.RIGHT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_RIGHT_SECONDARY, self.game.settings), + KeyValues.RIGHT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_ENTER, self.game.settings), + KeyValues.ENTER) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_INVENTORY, self.game.settings), + KeyValues.INVENTORY) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_USE, self.game.settings), + KeyValues.USE) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_EQUIP, self.game.settings), + KeyValues.EQUIP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_DROP, self.game.settings), + KeyValues.DROP) + self.assertEqual(KeyValues.translate_key(' ', self.game.settings), + KeyValues.SPACE) + self.assertEqual(KeyValues.translate_key('plop', self.game.settings), + None) + + def test_key_press(self) -> None: + """ + Press a key and see what is done. + """ + self.assertEqual(self.game.state, GameMode.MAINMENU) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.START) + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.START) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.RESUME) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.SAVE) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.LOAD) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.SETTINGS) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.SETTINGS) + + self.game.handle_key_pressed(KeyValues.SPACE) + self.assertEqual(self.game.state, GameMode.MAINMENU) + + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.EXIT) + self.assertRaises(SystemExit, self.game.handle_key_pressed, + KeyValues.ENTER) + + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.SETTINGS) + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.LOAD) + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.SAVE) + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.RESUME) + self.game.handle_key_pressed(KeyValues.UP) + self.assertEqual(self.game.main_menu.validate(), + MainMenuValues.START) + + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.PLAY) + + # Kill entities + for entity in self.game.map.entities.copy(): + if not isinstance(entity, Player): + self.game.map.remove_entity(entity) + + y, x = self.game.player.y, self.game.player.x + self.game.handle_key_pressed(KeyValues.DOWN) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y + 1) + self.assertEqual(new_x, x) + + y, x = new_y, new_x + self.game.handle_key_pressed(KeyValues.RIGHT) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y) + self.assertEqual(new_x, x + 1) + + y, x = self.game.player.y, self.game.player.x + self.game.handle_key_pressed(KeyValues.UP) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y - 1) + self.assertEqual(new_x, x) + + y, x = self.game.player.y, self.game.player.x + self.game.handle_key_pressed(KeyValues.LEFT) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y) + self.assertEqual(new_x, x - 1) + + self.game.handle_key_pressed(KeyValues.SPACE) + self.assertEqual(self.game.state, GameMode.MAINMENU) + + def test_new_game(self) -> None: + """ + Ensure that the start button starts a new game. + """ + old_map = self.game.map + old_player = self.game.player + self.game.handle_key_pressed(KeyValues.ENTER) # Start new game + new_map = self.game.map + new_player = self.game.player + # Ensure that + self.assertNotEqual(old_map, new_map) + self.assertNotEqual(old_player, new_player) + + self.game.handle_key_pressed(KeyValues.SPACE) + old_map = new_map + old_player = new_player + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.ENTER) # Resume game + new_map = self.game.map + new_player = self.game.player + self.assertEqual(old_map, new_map) + self.assertEqual(old_player, new_player) + + def test_settings_menu(self) -> None: + """ + Ensure that the settings menu is working properly. + """ + self.game.settings = Settings() + + # Open settings menu + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.SETTINGS) + + # Define the "move up" key to 'w' + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertTrue(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(None, 'w') + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.assertEqual(self.game.settings.KEY_UP_PRIMARY, 'w') + + # Navigate to "move left" + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.UP) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + + # Define the "move up" key to 'a' + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertTrue(self.game.settings_menu.waiting_for_key) + # Can't used a mapped key + self.game.handle_key_pressed(None, 's') + self.assertTrue(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(None, 'a') + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.assertEqual(self.game.settings.KEY_LEFT_PRIMARY, 'a') + + # Navigate to "texture pack" + for ignored in range(9): + self.game.handle_key_pressed(KeyValues.DOWN) + + # Change texture pack + self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.TEXTURE_PACK, "squirrel") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") + + # Change language + Translator.compilemessages() + Translator.refresh_translations() + self.game.settings.LOCALE = "en" + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.LOCALE, "fr") + self.assertEqual(_("New game"), "Nouvelle partie") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.LOCALE, "de") + self.assertEqual(_("New game"), "Neu Spiel") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.LOCALE, "es") + self.assertEqual(_("New game"), "Nuevo partido") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.LOCALE, "en") + self.assertEqual(_("New game"), "New game") + + # Navigate to "back" button + self.game.handle_key_pressed(KeyValues.DOWN) + + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.MAINMENU) + + def test_logs(self) -> None: + """ + Tests the use of logs + """ + self.assertEqual(self.game.logs.messages, ["Hello World !"]) + self.game.logs.add_messages(["Hello", "World"]) + self.assertEqual(self.game.logs.messages, ["Hello World !", + "Hello", "World"]) + self.game.logs.clear() + self.assertEqual(self.game.logs.messages, []) + + def test_dead_screen(self) -> None: + """ + Kill player and render dead screen. + """ + self.game.state = GameMode.PLAY + # Kill player + self.game.player.take_damage(self.game.player, + self.game.player.health + 2) + y, x = self.game.player.y, self.game.player.x + for key in [KeyValues.UP, KeyValues.DOWN, + KeyValues.LEFT, KeyValues.RIGHT]: + self.game.handle_key_pressed(key) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y) + self.assertEqual(new_x, x) + + def test_not_implemented(self) -> None: + """ + Check that some functions are not implemented, only for coverage. + """ + self.assertRaises(NotImplementedError, Display.display, None) + + def test_messages(self) -> None: + """ + Display error messages. + """ + self.game.message = "I am an error" + self.game.display_actions(DisplayActions.UPDATE) + self.game.display_actions(DisplayActions.REFRESH) + self.game.handle_key_pressed(None, "random key") + self.assertIsNone(self.game.message) + + def test_inventory_menu(self) -> None: + """ + Open the inventory menu and interact with items. + """ + self.game.state = GameMode.PLAY + # Open and close the inventory + self.game.handle_key_pressed(KeyValues.INVENTORY) + self.assertEqual(self.game.state, GameMode.INVENTORY) + self.game.handle_key_pressed(KeyValues.SPACE) + self.assertEqual(self.game.state, GameMode.PLAY) + + # Add five bombs in the inventory + for ignored in range(5): + bomb = Bomb() + bomb.map = self.game.map + bomb.map.add_entity(bomb) + bomb.hold(self.game.player) + + self.game.handle_key_pressed(KeyValues.INVENTORY) + self.assertEqual(self.game.state, GameMode.INVENTORY) + + # Navigate in the menu + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.inventory_menu.position, 3) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.UP) + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.inventory_menu.position, 4) + + # Equip key does nothing + self.game.handle_key_pressed(KeyValues.EQUIP) + + # Drop an item + bomb = self.game.player.inventory[-1] + self.assertEqual(self.game.inventory_menu.validate(), bomb) + self.assertTrue(bomb.held) + self.assertEqual(bomb.held_by, self.game.player) + self.game.handle_key_pressed(KeyValues.DROP) + self.assertFalse(bomb.held) + self.assertIsNone(bomb.held_by) + self.assertIsNone(bomb.owner) + self.assertFalse(bomb.exploding) + self.assertEqual(bomb.y, self.game.player.y) + self.assertEqual(bomb.x, self.game.player.x) + + # Use the bomb + bomb = self.game.player.inventory[-1] + self.assertEqual(self.game.inventory_menu.validate(), bomb) + self.assertTrue(bomb.held) + self.assertEqual(bomb.held_by, self.game.player) + self.game.handle_key_pressed(KeyValues.USE) + self.assertFalse(bomb.held) + self.assertIsNone(bomb.held_by) + self.assertEqual(bomb.owner, self.game.player) + self.assertTrue(bomb.exploding) + self.assertEqual(bomb.y, self.game.player.y) + self.assertEqual(bomb.x, self.game.player.x) diff --git a/dungeonbattle/tests/interfaces_test.py b/squirrelbattle/tests/interfaces_test.py similarity index 74% rename from dungeonbattle/tests/interfaces_test.py rename to squirrelbattle/tests/interfaces_test.py index b487eac..c9f7253 100644 --- a/dungeonbattle/tests/interfaces_test.py +++ b/squirrelbattle/tests/interfaces_test.py @@ -1,7 +1,11 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import unittest -from dungeonbattle.display.texturepack import TexturePack -from dungeonbattle.interfaces import Map, Tile +from squirrelbattle.display.texturepack import TexturePack +from squirrelbattle.interfaces import Map, Tile +from squirrelbattle.resources import ResourceManager class TestInterfaces(unittest.TestCase): @@ -18,7 +22,7 @@ class TestInterfaces(unittest.TestCase): """ Try to load a map from a file. """ - m = Map.load("resources/example_map.txt") + m = Map.load(ResourceManager.get_asset_path("example_map.txt")) self.assertEqual(m.width, 52) self.assertEqual(m.height, 17) diff --git a/dungeonbattle/tests/screen.py b/squirrelbattle/tests/screen.py similarity index 66% rename from dungeonbattle/tests/screen.py rename to squirrelbattle/tests/screen.py index 6eb2cd0..9a8afe6 100644 --- a/dungeonbattle/tests/screen.py +++ b/squirrelbattle/tests/screen.py @@ -1,3 +1,9 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Tuple + + class FakePad: """ In order to run tests, we simulate a fake curses pad that accepts functions @@ -10,8 +16,11 @@ class FakePad: smincol: int, smaxrow: int, smaxcol: int) -> None: pass - def clear(self) -> None: + def erase(self) -> None: pass def resize(self, height: int, width: int) -> None: pass + + def getmaxyx(self) -> Tuple[int, int]: + return 42, 42 diff --git a/dungeonbattle/tests/settings_test.py b/squirrelbattle/tests/settings_test.py similarity index 78% rename from dungeonbattle/tests/settings_test.py rename to squirrelbattle/tests/settings_test.py index 9a56048..06225b2 100644 --- a/dungeonbattle/tests/settings_test.py +++ b/squirrelbattle/tests/settings_test.py @@ -1,9 +1,16 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + import unittest -from dungeonbattle.settings import Settings +from squirrelbattle.settings import Settings +from squirrelbattle.translations import Translator class TestSettings(unittest.TestCase): + def setUp(self) -> None: + Translator.setlocale("en") + def test_settings(self) -> None: """ Ensure that settings are well loaded. @@ -21,7 +28,7 @@ class TestSettings(unittest.TestCase): self.assertEqual(settings.get_comment(settings.TEXTURE_PACK), settings.get_comment('TEXTURE_PACK')) self.assertEqual(settings.get_comment(settings.TEXTURE_PACK), - 'Pack de textures utilisé') + 'Texture pack') settings.TEXTURE_PACK = 'squirrel' self.assertEqual(settings.TEXTURE_PACK, 'squirrel') diff --git a/squirrelbattle/tests/translations_test.py b/squirrelbattle/tests/translations_test.py new file mode 100644 index 0000000..0cb39c5 --- /dev/null +++ b/squirrelbattle/tests/translations_test.py @@ -0,0 +1,66 @@ +import unittest + +from squirrelbattle.translations import gettext as _, Translator + + +class TestTranslations(unittest.TestCase): + def setUp(self) -> None: + Translator.compilemessages() + Translator.refresh_translations() + Translator.setlocale("fr") + + def test_main_menu_translation(self) -> None: + """ + Ensure that the main menu is translated. + """ + self.assertEqual(_("New game"), "Nouvelle partie") + self.assertEqual(_("Resume"), "Continuer") + self.assertEqual(_("Load"), "Charger") + self.assertEqual(_("Save"), "Sauvegarder") + self.assertEqual(_("Settings"), "Paramètres") + self.assertEqual(_("Exit"), "Quitter") + + def test_settings_menu_translation(self) -> None: + """ + Ensure that the settings menu is translated. + """ + self.assertEqual(_("Main key to move up"), + "Touche principale pour aller vers le haut") + self.assertEqual(_("Secondary key to move up"), + "Touche secondaire pour aller vers le haut") + self.assertEqual(_("Main key to move down"), + "Touche principale pour aller vers le bas") + self.assertEqual(_("Secondary key to move down"), + "Touche secondaire pour aller vers le bas") + self.assertEqual(_("Main key to move left"), + "Touche principale pour aller vers la gauche") + self.assertEqual(_("Secondary key to move left"), + "Touche secondaire pour aller vers la gauche") + self.assertEqual(_("Main key to move right"), + "Touche principale pour aller vers la droite") + self.assertEqual(_("Secondary key to move right"), + "Touche secondaire pour aller vers la droite") + self.assertEqual(_("Key to validate a menu"), + "Touche pour valider un menu") + self.assertEqual(_("Key used to open the inventory"), + "Touche utilisée pour ouvrir l'inventaire") + self.assertEqual(_("Key used to use an item in the inventory"), + "Touche pour utiliser un objet de l'inventaire") + self.assertEqual(_("Key used to equip an item in the inventory"), + "Touche pour équiper un objet de l'inventaire") + self.assertEqual(_("Key used to drop an item in the inventory"), + "Touche pour jeter un objet de l'inventaire") + self.assertEqual(_("Texture pack"), "Pack de textures") + self.assertEqual(_("Language"), "Langue") + + def test_entities_translation(self) -> None: + self.assertEqual(_("player"), "joueur") + + self.assertEqual(_("tiger"), "tigre") + self.assertEqual(_("hedgehog"), "hérisson") + self.assertEqual(_("rabbit"), "lapin") + self.assertEqual(_("teddy bear"), "nounours") + + self.assertEqual(_("body snatch potion"), "potion d'arrachage de corps") + self.assertEqual(_("bomb"), "bombe") + self.assertEqual(_("heart"), "cœur") diff --git a/squirrelbattle/translations.py b/squirrelbattle/translations.py new file mode 100644 index 0000000..08d40d1 --- /dev/null +++ b/squirrelbattle/translations.py @@ -0,0 +1,107 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import gettext as gt +import os +import re +import subprocess +from pathlib import Path +from typing import Any, List + + +class Translator: + """ + This module uses gettext to translate strings. + Translator.setlocale defines the language of the strings, + then gettext() translates the message. + """ + SUPPORTED_LOCALES: List[str] = ["de", "en", "es", "fr"] + locale: str = "en" + translators: dict = {} + + @classmethod + def refresh_translations(cls) -> None: + """ + Load compiled translations. + """ + for language in cls.SUPPORTED_LOCALES: + rep = Path(__file__).parent / "locale" / language / "LC_MESSAGES" + rep.mkdir(parents=True) if not rep.is_dir() else None + if os.path.isfile(rep / "squirrelbattle.mo"): + cls.translators[language] = gt.translation( + "squirrelbattle", + localedir=Path(__file__).parent / "locale", + languages=[language], + ) + + @classmethod + def setlocale(cls, lang: str) -> None: + """ + Define the language used to translate the game. + The language must be supported, otherwise nothing is done. + """ + lang = lang[:2] + if lang in cls.SUPPORTED_LOCALES: + cls.locale = lang + + @classmethod + def get_translator(cls) -> Any: + return cls.translators.get(cls.locale, gt.NullTranslations()) + + @classmethod + def makemessages(cls) -> None: # pragma: no cover + """ + Analyse all strings in the project and extract them. + """ + for language in cls.SUPPORTED_LOCALES: + if language == "en": + # Don't translate the main language + continue + file_name = Path(__file__).parent / "locale" / language \ + / "LC_MESSAGES" / "squirrelbattle.po" + args = ["find", "squirrelbattle", "-iname", "*.py"] + find = subprocess.Popen(args, cwd=Path(__file__).parent.parent, + stdout=subprocess.PIPE) + args = ["xargs", "xgettext", "--from-code", "utf-8", + "--add-comments", + "--package-name=squirrelbattle", + "--package-version=3.14.1", + "--copyright-holder=ÿnérant, eichhornchen, " + "nicomarg, charlse, ifugao", + "--msgid-bugs-address=squirrel-battle@crans.org", + "--sort-by-file", + "-o", file_name] + if file_name.is_file(): + args.append("--join-existing") + with open(file_name, "r") as f: + content = f.read() + with open(file_name, "w") as f: + f.write(re.sub("#:.*\n", "", content)) + print(f"Make {language} messages...") + subprocess.Popen(args, stdin=find.stdout).wait() + + @classmethod + def compilemessages(cls) -> None: + """ + Compile translation messages from source files. + """ + for language in cls.SUPPORTED_LOCALES: + if language == "en": + continue + args = ["msgfmt", "--check-format", + "-o", Path(__file__).parent / "locale" / language + / "LC_MESSAGES" / "squirrelbattle.mo", + Path(__file__).parent / "locale" / language + / "LC_MESSAGES" / "squirrelbattle.po"] + print(f"Compiling {language} messages...") + subprocess.Popen(args).wait() + + +def gettext(message: str) -> str: + """ + Translate a message. + """ + return Translator.get_translator().gettext(message) + + +Translator.refresh_translations() diff --git a/tox.ini b/tox.ini index 1e43e33..1d2f15a 100644 --- a/tox.ini +++ b/tox.ini @@ -6,13 +6,13 @@ envlist = skipsdist = True [testenv] -sitepackages = True +sitepackages = False deps = -r{toxinidir}/requirements.txt pytest pytest-cov commands = - pytest --cov=dungeonbattle/ --cov-report=term-missing dungeonbattle/ + pytest --cov=squirrelbattle/ --cov-report=term-missing squirrelbattle/ [testenv:linters] deps = @@ -23,7 +23,7 @@ deps = pep8-naming pyflakes commands = - flake8 main.py dungeonbattle + flake8 main.py squirrelbattle [flake8] ignore = W503 ANN002 ANN003 ANN101 ANN102 ANN204 ANN205