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