diff --git a/.gitignore b/.gitignore index 8499d7c..e477e04 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ env/ venv/ +local/ .coverage .pytest_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ff5c142..602bc97 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,21 +3,16 @@ stages: - 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 -py38: +py310: stage: test - image: python:3.8-alpine + image: python:rc-alpine before_script: - apk add --no-cache gettext - pip install tox script: tox -e py3 + only: + - master py39: @@ -28,6 +23,38 @@ py39: - pip install tox script: tox -e py3 + +py38: + stage: test + image: python:3.8-alpine + before_script: + - apk add --no-cache gettext + - pip install tox + script: tox -e py3 + + +py37: + stage: test + image: python:3.7-alpine + before_script: + - apk add --no-cache gettext + - pip install tox + script: tox -e py3 + only: + - master + + +py36: + stage: test + image: python:3.6-alpine + before_script: + - apk add --no-cache gettext + - pip install tox + script: tox -e py3 + only: + - master + + linters: stage: quality-assurance image: python:3-alpine @@ -36,11 +63,15 @@ linters: 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 + - > + 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/ diff --git a/README.md b/README.md index 6ad3063..d6898db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![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) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/squirrel-battle)](https://pypi.org/project/squirrel-battle/) [![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/) diff --git a/docs/deployment.rst b/docs/deployment.rst index 6bde6f0..b96246b 100644 --- a/docs/deployment.rst +++ b/docs/deployment.rst @@ -275,7 +275,7 @@ Construction du paquet Debian ----------------------------- Structure du paquet -------------------- +~~~~~~~~~~~~~~~~~~~ L'ensemble des instructions pour construire le paquet Debian est situé dans le dossier ``debian/``. @@ -292,14 +292,14 @@ 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. diff --git a/docs/display/logs.rst b/docs/display/logs.rst index 3ad130d..9e925a3 100644 --- a/docs/display/logs.rst +++ b/docs/display/logs.rst @@ -1,4 +1,4 @@ Affichage de l'historique ========================= -Pas encore documenté. +L'historique des actions est affiché en bas de l'écran. À chaque action d'une entité, comme frapper quelqu'un, ou lorsque le joueur parle à une entité, cela s'affiche dans l'historique. diff --git a/docs/display/map.rst b/docs/display/map.rst index 1daa85a..749b32c 100644 --- a/docs/display/map.rst +++ b/docs/display/map.rst @@ -1,4 +1,6 @@ Affichage de la carte ===================== -Pas encore documenté. +La carte s'affiche dans la partie en haut à gauche de l'écran, sur la plus grande partie de l'écran. On affiche les tuiles une par une, selon le texture pack sélectionné. La map est actualisée à chaque action d'une entité. + +L'afffichage de la carte suit les déplacements du joueur. diff --git a/docs/display/menu.rst b/docs/display/menu.rst index 84be36c..30c4e98 100644 --- a/docs/display/menu.rst +++ b/docs/display/menu.rst @@ -1,4 +1,14 @@ Affichage des menus =================== -Pas encore documenté. +Les menus sont affichés dans une boîte. On peut naviguer dedans avec les flèches haut et bas, +et valider avec la touche entrée. + +Il y a plusieurs menus dans le jeu : + +* Le main menu, qui s'affiche au lancement du jeu. +* Le menu des paramètres : si on sélectionne un choix de touche et qu'on appuie sur entrée, on peut ensuite appuyer sur une touche pour remplacer la touche utilisée. +* Le menu des crédits : ce menu fonctionne avec la souris. En cliquant on affiche une image. +* Le menu d'inventaire : dans l'inventaire, on peut utiliser les touches pour utiliser un item ou l'équiper... +* Le menu de vente : on peut utiliser les touches gauche et droite pour switcher entre l'inventaire du joueur et celui du marchand. +* Menu des warnings : Pas vraiment un menu, mais affiche juste un message dans une petite boite pour prévenir le joueur que quelquechose ne va pas. diff --git a/docs/display/stats.rst b/docs/display/stats.rst index 1b5f697..e993c7f 100644 --- a/docs/display/stats.rst +++ b/docs/display/stats.rst @@ -1,4 +1,26 @@ Affichage des statistiques ========================== -Pas encore documenté. +.. _Hazel: ../index.html + +Les statistiques du joueur sont affichées en haut à droite de l'écran +et séparées du reste de l'affichage par une barre verticale. + +Les informations affichées sont : + +* **LVL** - le niveau du joueur +* **EXP** - la quantité d'expérience que le joueur a gagné et combien il lui en faut avant le prochain niveau. +* **HP** - la quantité de vie que le joueur a actuellement et combien il peut en avoir au maximum. +* **STR** - la force du joueur. +* **INT** - l'intelligence du joueur. +* **CHR** - le charisme du joueur. +* **DEX** - la dextérité du joueur. +* **CON** - la constitution du joueur. +* **CRI** - le pourcentage de chance de coup critique. +* **Inventory** - le contenu de l'inventaire du joueur. +* **Equipped main** - l'objet équipé dans la main principale. +* **Equipped secondary** - l'objet équipé dans la main secondaire. +* **Equipped armor** - le plastron porté par le joueur. +* **Equipped helmet** - le casque porté par le joueur. +* **Hazel** - le nombre d'Hazel_ que le joueur possède. +* **Vous êtes mort** - Éventuellement, si le joueur est mort. \ No newline at end of file diff --git a/docs/entities/friendly.rst b/docs/entities/friendly.rst new file mode 100644 index 0000000..9fca40f --- /dev/null +++ b/docs/entities/friendly.rst @@ -0,0 +1,70 @@ +Entités pacifiques +================== + +.. _`entité attaquante`: index.html#entites-attaquantes +.. _`pack de textures`: ../texture-pack.html + +Chaque entité pacifique est en particulier une `entité attaquante`_, +et hérite donc de ses attributs, et peut alors être attaquée. +Ils sont cependant non-hostiles. + +Il est possible d'interagir avec ces entités. En s'approchant d'elles, en +appuyant sur la touche ``T`` suivie de la direction où regarder, un échange +débute. + +Si l'on s'adresse à un marchand, on devrait voir à l'écran l'inventaire du joueur +et l'inventaire du marchand. Les flèches haut et bas permettent de sélectionner +un objet, les touches droite et gauche de passer d'un inventaire à l'autre, et la +touche entrée valide l'action. + +On dénombre actuellement 3 types d'entités pacifiques : + +Tournesol +--------- + +Son nom est fixé à `sunflower`. Il a par défaut une **15** points de vie. + +Interagir avec un tournesol n'a pas de réel intérêt, si ce n'est déclencher +le « pouvoir des fleurs !! » ou bien savoir que « le soleil est chaud +aujourd'hui ». + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``I``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🌻``. + + +Marchand +-------- + +Son nom est fixé à `merchant`. Il a par défaut **5** points de vie. + +En interagissant avec un marchand, il est possible de lui acheter et de lui +vendre différents objets contre des Hazels, la monnaie du jeu. +Les prix sont fixés : + +* Bombe : 4 Hazels +* Coeur : 3 Hazels +* Potion d'arrachage de corps : 14 Hazels +* Épée : 20 Hazels +* Bouclier : 16 Hazels +* Casque : 18 Hazels +* Plastron : 30 Hazels + +Le marchand commence avec 75 Hazels en sa possession, contre 42 pour le joueur. + +Dans le `pack de textures`_ ASCII, il est représenté par le caractère ``M``. + +Dans le `pack de textures`_ écureuil, il est représenté par l'émoji ``🦜``. + +Trompette +--------- + +Son nom est fixé à 'trumpet'. Une trompette est un familier, c'est à dire que +c'est une entité attaquante qui suit globalement le joueurs et attaque les monstres +qui se rapprochent trop du joueur. + +Elle a 20 point de vie et une attaque de 3. + +Dans le `pack de textures`_ ASCII, elle est représentée par le caractère ``/``. + +Dans le `pack de textures`_ écureuil, elle est représentée par l'émoji ``🎺``. \ No newline at end of file diff --git a/docs/entities/index.rst b/docs/entities/index.rst index 1d63bbf..d23f79c 100644 --- a/docs/entities/index.rst +++ b/docs/entities/index.rst @@ -7,6 +7,7 @@ Entités player monsters + friendly items Entité @@ -38,11 +39,12 @@ Entité attaquante ----------------- .. _monstre: monsters.html +.. _entité pacifique: friendly.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_. +un monstre_, une `entité pacifique`_ ou bien le joueur_. Elles disposent toutes, en plus des paramètres d'entité, des attributs suivants : @@ -77,3 +79,14 @@ en-dessous de 0 point de vie. À ce moment-là, l'entité est retirée de la car 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. + + +Entité pacifique +---------------- + +Une entité pacifique (``FriendlyEntity``) est un cas particulier d'entité +attaquante. Contrairement aux montres, elles ne peuvent pas attaquer le joueur. + +On peut parler à une entité pacifique en appuyant sur la touche ``T`` puis en +appuyant sur la direction dans laquelle on veut parler à l'entité. + diff --git a/docs/entities/items.rst b/docs/entities/items.rst index 521ca91..e4f1c0a 100644 --- a/docs/entities/items.rst +++ b/docs/entities/items.rst @@ -19,7 +19,7 @@ Un objet dispose de deux paramètres : Si l'objet est dans l'inventaire, renvoie son propriétaire. -Deux types d'objets sont pour l'instant présents : +Il y a plusieurs types d'objets : Bombe @@ -28,23 +28,94 @@ 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. +dans l'inventaire. Le joueur peut ensuite utiliser la bombe, via l'inventaire +ou après l'avoir équipée, qui fera alors 3 dégâts à toutes les +`entités attaquantes`_ situées à moins de trois cases au bout de 4 ticks de jeu. 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 ``💣``. +et dans le `pack de textures`_ écureuil par l'émoji ``💣``. Lors de l'explosion, +la bombe est remplacée par un symbole ``%`` ou l'émoji ``💥`` selon le pack de +textures utilisé. -.. 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. +La bombe coûte 4 Hazels auprès des marchands. 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. +Un cœur est un objet que l'on ne peut pas ramasser. Dès que le joueur s'en +approche ou qu'il l'achète auprès d'un marchand, 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 ``❤`` +Il est représenté dans le `pack de textures`_ ASCII par le caractère ``❤`` et dans le `pack de textures`_ écureuil par l'émoji ``💜``. + +Le cœur coûte 3 Hazels auprès des marchands. + + +Potion d'arrachage de corps +--------------------------- + +Cette potion permet, une fois utilisée, d'échanger toutes ses caractéristiques +avec une autre entité aléatoire sur la carte. Cela inclut la force, la position, +l'icône, ... + +Elle est représentée par les caractères ``I`` et ``🔀`` + +Cette potion coûte 14 Hazels auprès des marchands. + + +Épée +---- + +L'épée est un objet que l'on peut trouver uniquement par achat auprès d'un +marchand pour le coût de 20 Hazels. Une fois équipée, l'épée ajoute 3 de force +à son porteur. + +Elle est représentée par les caractères ``†`` et ``🗡️``. + + +Bouclier +-------- + +Le bouclier est un objet que l'on peut trouver uniquement par achat auprès d'un +marchand pour le coût de 16 Hazels. Il s'équippe dans la main secondaire. +Une fois équipé, le bouclier ajoute 1 de +constitution à son porteur, lui permettant de parer mieux les coups. + +Il est représenté par les caractères ``D`` et ``🛡️``. + +Casque +------ + +Le casque est un objet que l'on peut trouver uniquement par achat auprès d'un +marchand pour le coût de 18 Hazels. Il s'équippe sur la tête. +Une fois équipé, le casque ajoute 2 de +constitution à son porteur, lui permettant de prendre moins de dêgats. + +Il est représenté par les caractères ``0`` et ``⛑️``. + +Plastron +-------- + +Le plastron est un objet que l'on peut trouver uniquement par achat auprès d'un +marchand pour le coût de 30 Hazels. Il s'équippe sur le corps. +Une fois équipé, le casque ajoute 4 de +constitution à son porteur, lui permettant de prendre moins de dêgats. + +Il est représenté par les caractères ``(`` et ``🦺``. + +Anneau +------ + +L'anneau est un objet que l'on peut trouver uniquement par achat auprès d'un +marchand. Il s'équippe sur la main secondaire. +Une fois équipé, l'anneau ajoute un bonus à une ou plusieurs statistiques du +joueur, améliorant sa capacité à se débarasser des monstres. + +Il y a plusieurs types d'anneaux : + +* **Anneau de coup critique**, qui augmente la chance de coup critique de 20%. Il coute 15 Hazels. +* **Anneau de gain d'expérience amélioré**, qui multiplie le gain d'expérience du joueur par 2. Il coûte 25 Hazels. + +Un anneau est représenté par les caractères ``o`` et ``💍``. \ No newline at end of file diff --git a/docs/entities/monsters.rst b/docs/entities/monsters.rst index b6f287c..d648069 100644 --- a/docs/entities/monsters.rst +++ b/docs/entities/monsters.rst @@ -13,7 +13,7 @@ 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 : +On dénombre actuellement 5 types de monstres : Hérisson -------- @@ -40,6 +40,8 @@ Lapin Son nom est fixé à `rabbit`. Il a par défaut une force à **1** et **15** points de vie. +Il a une chance de coup critique de 30%. + 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 ``🐇``. @@ -53,3 +55,14 @@ 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 ``🧸``. + + +Pygargue +--------- +Son nom est fixé à `eagle`. Il a par défaut une force à **1000** et **5000** points de vie. + +Il s'agit d'un boss difficilement tuable, qui apparait plus rarement que les autres monstres. + +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 ``🦅``. diff --git a/docs/index.rst b/docs/index.rst index 1cb7d83..71d7343 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,6 +17,10 @@ Bienvenue dans la documentation de Squirrel Battle ! :target: https://pypi.org/project/squirrel-battle/ :alt: PyPI +.. image:: https://img.shields.io/pypi/pyversions/squirrel-battle + :target: https://pypi.org/project/squirrel-battle/ + :alt: Supported Python versions + .. image:: https://img.shields.io/pypi/dm/squirrel-battle :target: https://pypi.org/project/squirrel-battle/ :alt: PyPI downloads diff --git a/docs/requirements.txt b/docs/requirements.txt index cbf1e36..540deda 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -sphinx -sphinx-rtd-theme +sphinx>=3.3 +sphinx-rtd-theme>=0.5 diff --git a/docs/settings.rst b/docs/settings.rst index a8644d4..60fa5c1 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -1,4 +1,38 @@ Paramètres ========== -Pas encore documenté. +.. _pack de textures: texture-pack.html + +Il est possible de changer les touches utilisées dans le jeu dans le menu des paramètres. + +On peut aussi changer le `pack de textures`_ utilisé. + +Touches +------- + +Les touches utilisées de base sont : + +* **Aller vers le haut** : z +* **Aller vers le haut (secondaire)** : ↑ +* **Aller vers le bas** : s +* **Aller vers le bas (secondaire)** : ↓ +* **Aller à droite** : d +* **Aller à droite (secondaire)** : → +* **Aller à gauche** : q +* **Aller à gauche (secondaire)** : ← +* **Valider le choix** : Entrée +* **Inventaire** : i +* **Utiliser un objet** : u +* **Équiper un objet** : e +* **Lacher un objet** : r +* **Parler** : t +* **Attendre** : w + +Autres +------ + +.. _ascii: texture-pack.html#Pack ASCII +.. _squirrel: texture-pack.html#Pack Écureuil + +* **Texture pack utilisé** : parmi ascii_ et squirrel_ +* **Langue utilisée** : parmi anglais, français, espagnol, allemand \ No newline at end of file diff --git a/docs/texture-pack.rst b/docs/texture-pack.rst index 377a3cf..6dc3b60 100644 --- a/docs/texture-pack.rst +++ b/docs/texture-pack.rst @@ -9,18 +9,30 @@ Pack de textures .. _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 +.. _Tournesol: entities/friendly.html#tournesol +.. _Marchand: entities/friendly.html#marchand +.. _Cœur: entities/items.html#coeur +.. _Bombe: entities/items.html#bombe +.. _Explosion: entities/items.html#bombe +.. _Potion d'arrachage de corps: entities/items.html +.. _Épée: entities/items.html#epee +.. _Bouclier: entities/items.html#bouclier +.. _Hazel: ../index.html +.. _Plastron: ../entities/items.html#plastron +.. _Pygargue: ../entities/monsters.html#Pygargue +.. _Casque: ../entities/items.html#Casque +.. _Anneau: ../entities/items.html#Anneau +.. _Trompette: ../entities/items.html#Trompette 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. +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. @@ -39,11 +51,23 @@ Chaque tuile fait un caractère de large. * Entités * Joueur_ : ``@`` * Hérisson_ : ``*`` - * Cœur_ : ``❤`` - * Bombe_ : ``o`` * Lapin_ : ``Y`` * Tigre_ : ``n`` * Nounours_ : ``8`` + * Tournesol_ : ``I`` + * Marchand_ : ``M`` + * Cœur_ : ``❤`` + * Bombe_ : ``o`` + * Explosion_ : ``%`` + * `Potion d'arrachage de corps`_ : ``S`` + * Épée_ : ``†`` + * Bouclier_ : ``D`` + * Hazel_ : ``¤`` + * Plastron_ : ``(`` + * Pygargue_ : ``µ`` + * Casque_ : ``0`` + * Anneau_ : ``o`` + * Trompette_ : ``/`` Pack Écureuil @@ -58,8 +82,20 @@ Chaque tuile fait 2 caractères de large pour afficher les émojis proprement. * Entités * Joueur_ : ``🐿`` * Hérisson_ : ``🦔`` - * Cœur_ : ``💜`` - * Bombe_ : ``💣`` * Lapin_ : ``🐇`` * Tigre_ : ``🐅`` * Nounours_ : ``🧸`` + * Tournesol_ : ``🌻`` + * Marchand_ : ``🦜`` + * Cœur_ : ``💜`` + * Bombe_ : ``💣`` + * Explosion_ : ``💥`` + * `Potion d'arrachage de corps`_ : ``🔀`` + * Épée_ : ``🗡️`` + * Bouclier_ : ``🛡️`` + * Hazel_ : ``🌰`` + * Plastron_ : ``🦺`` + * Pygargue_ : ``🦅`` + * Casque_ : ``⛑️`` + * Anneau_ : ``💍`` + * Trompette_ : ``🎺`` diff --git a/squirrelbattle/assets/ascii-art-ecureuil.txt b/squirrelbattle/assets/ascii-art-ecureuil.txt new file mode 100644 index 0000000..bd56744 --- /dev/null +++ b/squirrelbattle/assets/ascii-art-ecureuil.txt @@ -0,0 +1,44 @@ + + ⋀ + ┃|┃ + ┃|┃ ▓▓▒ ▓▓ + ┃|┃ ▓▓ ▓▓▒ + ┃|┃ ▓▓▓ ▓▓ ▓▓▓ ▒▒▒▒▒▒▒▒▒ + ┃|┃ ▓▓▓▓▓▓▓▓▓▓▓▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒ + ┃|┃ ▓▓▓▓▓▓▓▓▓▓▓▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ┃|┃ ▓▓▓▬█▓▓▓▓▓▓▬█▓▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ┃|┃ ▓▓▓▓░██░░▓▓░░██░▓▓▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ━━▓▓▓▓━━ ▓▓░░░░░░░░ ░░░░░░░░▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▓▓▓▓ ▓░░░░░░░░░░░░░░░░░░░░▓▓▒▒▒▒▒▒▒▒▒▒▒▒ + ┃ ▓▓▓▓▓ ▓░░░░░░░░▄▄▄▄░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒ + ┃ ▓▓▓▓▓ ▓▓░░░░░░░░░░░░░░░▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▓▓ ▓▓▓▓░░░░░░░▓▓ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▓▓▓▓▒▒░░░░░░░░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▓▓▒░░░░░░░░░░░░▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▒░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▒▒░░░░░░░░░░░░▓▓▓▓▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒ + ▓▓▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒ + ▓▓▓▒░░░░░░░░░░░░░▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▒▓▒▒░░░░░░░░░░░░░░▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒ + ▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒ + ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▒▒▒▒▒ + ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▒▒▒ + ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▒ + ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▒░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓ ░ + ▓▓▓▓▓▓▓▓▓▒░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓ ░░ + ▓▓▓▓▓▓▓▓▒▒░░░▒▒▒▒░░░░░░▓▓░▒▒▒▓▓▓▓▓▓▓▓▓▓░░░ ░ + ▓▓▓▓▓▓▓▒░░░░░░░░░▒░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓░░ ░░▒ + ░ ░░░░░▒░░░░░░▒░░░▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░░░░▒ + ▒▒░░▓▓░░▒░░░░░░░░▒░░░░░░▒░░░░░░░░▒░░░░░░░░░░▒░░░░░▒ ░░ + ▒▒▒▒▒▓▒▒▓░░░░░░░░░▒░░░░░░░░▒░░░░░░░░▒░░░░░░░░▒░░░░░░░░░░░░ + ▒▒█▒█▒▒▒▓░░▒░░░░░░░░░░░░░░░▒░░░░░░░░▒░░░░░░░░░░░░░░░░░░░░░ + ▒▒▒▒█▒▒▒▒░░░░▒░░░▒░░░░░░░░░░░░░░░░░░░░░░▒░░░░░░░░░░░░▒░░░ + ▓█▒▒▒▒█▒█▒▒▒▒░░▒░░░░░▒░░░░▒░░░░░░░░░░░░░░░░░▒░░░░▒░░░░░░░▒░░░░░▒▒ + ██▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░░░░░▒░░░░░░░░▒░░░░░░▒░░░░░░▒░░░░░▒░░░░░ + ▒▒▒▒█▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░░░░░ + ▒▒█▒▒▒▒▒░▒░▒░░░░▓▓▓░░░░░░░▒░░░░▒░░░▒░░░░░░░▓▓░░░░░░░░░░░░ ░ + ▒▒▒▒▒▒▒░▒░░░▓▓▓▓▓▓░░░░░░░▒░░░░░░░░▒░░░░▓▓▓▓▓▓░░░░░░░░ ░ + ░▓▓▓▓▓▓░░░░░░▒░░░░░░░░▒░░░░░░▓▓▓▓▓░░░ ░ ░░ diff --git a/squirrelbattle/assets/example_map.txt b/squirrelbattle/assets/example_map.txt index 5aaade9..be2e798 100644 --- a/squirrelbattle/assets/example_map.txt +++ b/squirrelbattle/assets/example_map.txt @@ -1,8 +1,8 @@ 1 6 ####### ############# - #.....# #...........# + #.H...# #...........# #.....# #####...........# - #.....# #...............# + #.....# #............H..# #.##### #.###...........# #.# #.# #...........# #.# #.# ############# diff --git a/squirrelbattle/assets/example_map_2.txt b/squirrelbattle/assets/example_map_2.txt index 8864f04..b9c751f 100644 --- a/squirrelbattle/assets/example_map_2.txt +++ b/squirrelbattle/assets/example_map_2.txt @@ -1,6 +1,6 @@ 1 17 ########### ######### - #.........# #.......# + #....H....# #.......# #.........# ############.......# #.........###############..........#.......############## #.........#........................#....................# @@ -13,7 +13,7 @@ ########.##########......# #.........# #.........# #...........##......# #.........# #.........# #...........##......# #.........# #.........# - #...........##......# #.........# ################.###### + #...........##..H...# #.........# ################.###### #...........##......# #.........# #.................############ #...........##......# ########.########.......#.........#..........# #...........##......# #...............#.......#.........#..........# diff --git a/squirrelbattle/assets/example_map_3.txt b/squirrelbattle/assets/example_map_3.txt new file mode 100644 index 0000000..c5dd8e3 --- /dev/null +++ b/squirrelbattle/assets/example_map_3.txt @@ -0,0 +1,41 @@ +1 6 +################################################################################ +#..............................................................................# +#..#...........................................................................# +#...........#..................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +#..............................................................................# +################################################################################ \ No newline at end of file diff --git a/squirrelbattle/display/creditsdisplay.py b/squirrelbattle/display/creditsdisplay.py new file mode 100644 index 0000000..93f2f72 --- /dev/null +++ b/squirrelbattle/display/creditsdisplay.py @@ -0,0 +1,97 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +import curses + +from ..display.display import Box, Display +from ..game import Game +from ..resources import ResourceManager +from ..translations import gettext as _ + + +class CreditsDisplay(Display): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.box = Box(*args, **kwargs) + self.pad = self.newpad(1, 1) + self.ascii_art_displayed = False + + def update(self, game: Game) -> None: + return + + def display(self) -> None: + self.box.refresh(self.y, self.x, self.height, self.width) + self.box.display() + self.pad.erase() + + messages = [ + _("Credits"), + "", + "Squirrel Battle", + "", + _("Developers:"), + "Yohann \"ÿnérant\" D'ANELLO", + "Mathilde \"eichhornchen\" DÉPRÉS", + "Nicolas \"nicomarg\" MARGULIES", + "Charles \"charsle\" PEYRAT", + "", + _("Translators:"), + "Hugo \"ifugao\" JACOB (español)", + ] + + for i, msg in enumerate(messages): + self.addstr(self.pad, i + (self.height - len(messages)) // 2, + (self.width - len(msg)) // 2, msg, + bold=(i == 0), italic=(":" in msg)) + + if self.ascii_art_displayed: + self.display_ascii_art() + + self.refresh_pad(self.pad, 0, 0, self.y + 1, self.x + 1, + self.height + self.y - 2, + self.width + self.x - 2) + + def display_ascii_art(self) -> None: + with open(ResourceManager.get_asset_path("ascii-art-ecureuil.txt"))\ + as f: + ascii_art = f.read().split("\n") + + height, width = len(ascii_art), len(ascii_art[0]) + y_offset, x_offset = (self.height - height) // 2,\ + (self.width - width) // 2 + + for i, line in enumerate(ascii_art): + for j, c in enumerate(line): + bg_color = curses.COLOR_WHITE + fg_color = curses.COLOR_BLACK + bold = False + if c == ' ': + bg_color = curses.COLOR_BLACK + elif c == '━' or c == '┃' or c == '⋀': + bold = True + fg_color = curses.COLOR_WHITE + bg_color = curses.COLOR_BLACK + elif c == '|': + bold = True # c = '┃' + fg_color = (100, 700, 1000) + bg_color = curses.COLOR_BLACK + elif c == '▓': + fg_color = (700, 300, 0) + elif c == '▒': + fg_color = (700, 300, 0) + bg_color = curses.COLOR_BLACK + elif c == '░': + fg_color = (350, 150, 0) + elif c == '█': + fg_color = (0, 0, 0) + bg_color = curses.COLOR_BLACK + elif c == '▬': + c = '█' + fg_color = (1000, 1000, 1000) + bg_color = curses.COLOR_BLACK + self.addstr(self.pad, y_offset + i, x_offset + j, c, + fg_color, bg_color, bold=bold) + + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: + if self.pad.inch(y - 1, x - 1) != ord(" "): + self.ascii_art_displayed = True diff --git a/squirrelbattle/display/display.py b/squirrelbattle/display/display.py index c5b1aa3..17d8c29 100644 --- a/squirrelbattle/display/display.py +++ b/squirrelbattle/display/display.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later import curses -from typing import Any, Optional, Union +import sys +from typing import Any, Optional, Tuple, Union from squirrelbattle.display.texturepack import TexturePack from squirrelbattle.game import Game @@ -16,14 +17,24 @@ class Display: height: int pad: Any + _color_pairs = {(curses.COLOR_WHITE, curses.COLOR_BLACK): 0} + _colors_rgb = {} + 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]: + """ + Overwrites the native curses function of the same name. + """ return curses.newpad(height, width) if self.screen else FakePad() def truncate(self, msg: str, height: int, width: int) -> str: + """ + Truncates a string into a string adapted to the width and height of + the screen. + """ height = max(0, height) width = max(0, width) lines = msg.split("\n") @@ -31,15 +42,86 @@ class Display: lines = [line[:width] for line in lines] return "\n".join(lines) - def addstr(self, pad: Any, y: int, x: int, msg: str, *options) -> None: + def translate_color(self, color: Union[int, Tuple[int, int, int]]) -> int: """ - Display a message onto the pad. + Translates a tuple (R, G, B) into a curses color index. + If we already have a color index, then nothing is processed. + If this is a tuple, we construct a new color index if non-existing + and we return this index. + The values of R, G and B must be between 0 and 1000, and not + between 0 and 255. + """ + if isinstance(color, tuple): + # The color is a tuple (R, G, B), that is potentially unknown. + # We translate it into a curses color number. + if color not in self._colors_rgb: + # The color does not exist, we create it. + color_nb = len(self._colors_rgb) + 8 + self.init_color(color_nb, color[0], color[1], color[2]) + self._colors_rgb[color] = color_nb + color = self._colors_rgb[color] + return color + + def addstr(self, pad: Any, y: int, x: int, msg: str, + fg_color: Union[int, Tuple[int, int, int]] = curses.COLOR_WHITE, + bg_color: Union[int, Tuple[int, int, int]] = curses.COLOR_BLACK, + *, altcharset: bool = False, blink: bool = False, + bold: bool = False, dim: bool = False, invis: bool = False, + italic: bool = False, normal: bool = False, + protect: bool = False, reverse: bool = False, + standout: bool = False, underline: bool = False, + horizontal: bool = False, left: bool = False, + low: bool = False, right: bool = False, top: bool = False, + vertical: bool = False, chartext: bool = False) -> None: + """ + Displays a message onto the pad. If the message is too large, it is truncated vertically and horizontally + The text can be bold, italic, blinking, ... if the right parameters are + given. These parameters are translated into curses attributes. + The foreground and background colors can be given as curses constants + (curses.COLOR_*), or by giving a tuple (R, G, B) that corresponds to + the color. R, G, B must be between 0 and 1000, and not 0 and 255. """ height, width = pad.getmaxyx() + # Truncate message if it is too large 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) + fg_color = self.translate_color(fg_color) + bg_color = self.translate_color(bg_color) + + # Get the pair number for the tuple (fg, bg) + # If it does not exist, create it and give a new unique id. + if (fg_color, bg_color) in self._color_pairs: + pair_nb = self._color_pairs[(fg_color, bg_color)] + else: + pair_nb = len(self._color_pairs) + self.init_pair(pair_nb, fg_color, bg_color) + self._color_pairs[(fg_color, bg_color)] = pair_nb + + # Compute curses attributes from the parameters + attr = self.color_pair(pair_nb) + attr |= curses.A_ALTCHARSET if altcharset else 0 + attr |= curses.A_BLINK if blink else 0 + attr |= curses.A_BOLD if bold else 0 + attr |= curses.A_DIM if dim else 0 + attr |= curses.A_INVIS if invis else 0 + # Italic is supported since Python 3.7 + italic &= sys.version_info >= (3, 7,) + attr |= curses.A_ITALIC if italic else 0 + attr |= curses.A_NORMAL if normal else 0 + attr |= curses.A_PROTECT if protect else 0 + attr |= curses.A_REVERSE if reverse else 0 + attr |= curses.A_STANDOUT if standout else 0 + attr |= curses.A_UNDERLINE if underline else 0 + attr |= curses.A_HORIZONTAL if horizontal else 0 + attr |= curses.A_LEFT if left else 0 + attr |= curses.A_LOW if low else 0 + attr |= curses.A_RIGHT if right else 0 + attr |= curses.A_TOP if top else 0 + attr |= curses.A_VERTICAL if vertical else 0 + attr |= curses.A_CHARTEXT if chartext else 0 + + return pad.addstr(y, x, msg, attr) def init_pair(self, number: int, foreground: int, background: int) -> None: return curses.init_pair(number, foreground, background) \ @@ -48,17 +130,28 @@ class Display: def color_pair(self, number: int) -> int: return curses.color_pair(number) if self.screen else 0 + def init_color(self, number: int, red: int, green: int, blue: int) -> None: + return curses.init_color(number, red, green, blue) \ + if self.screen else None + def resize(self, y: int, x: int, height: int, width: int, resize_pad: bool = True) -> None: + """ + Resizes a pad. + """ 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.erase() self.pad.resize(self.height + 1, self.width + 1) def refresh(self, *args, resize_pad: bool = True) -> None: + """ + Refreshes a pad + """ if len(args) == 4: self.resize(*args, resize_pad) self.display() @@ -67,10 +160,10 @@ class Display: window_y: int, window_x: int, last_y: int, last_x: int) -> None: """ - Refresh a pad on a part of the window. + Refreshes 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 + 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) @@ -82,17 +175,26 @@ class Display: 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) + pad.noutrefresh(top_y, top_x, window_y, window_x, last_y, last_x) def display(self) -> None: + """ + Draw the content of the display and refresh pads. + """ raise NotImplementedError - def handle_click(self, y: int, x: int, game: Game) -> None: + def update(self, game: Game) -> None: + """ + The game state was updated. + Indicate what to do with the new state. + """ + raise NotImplementedError + + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: """ A mouse click was performed on the coordinates (y, x) of the pad. - Maybe it can do something. + Maybe it should do something. """ - pass @property def rows(self) -> int: @@ -104,7 +206,9 @@ class Display: class VerticalSplit(Display): - + """ + A class to split the screen in two vertically with a pretty line. + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pad = self.newpad(self.rows, 1) @@ -125,7 +229,9 @@ class VerticalSplit(Display): class HorizontalSplit(Display): - + """ + A class to split the screen in two horizontally with a pretty line. + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pad = self.newpad(1, self.cols) @@ -146,23 +252,32 @@ class HorizontalSplit(Display): class Box(Display): + """ + A class for pretty boxes to print menus and other content. + """ + title: str = "" + + def update_title(self, title: str) -> None: + self.title = title 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) + self.fg_border_color) 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, i, 0, "┃", self.fg_border_color) + self.addstr(self.pad, i, self.width - 1, "┃", self.fg_border_color) self.addstr(self.pad, self.height - 1, 0, - "┗" + "━" * (self.width - 2) + "┛", self.pair) + "┗" + "━" * (self.width - 2) + "┛", self.fg_border_color) + + if self.title: + self.addstr(self.pad, 0, (self.width - len(self.title) - 8) // 2, + f" == {self.title} == ", curses.COLOR_GREEN, + italic=True, bold=True) + 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 index f9b3f01..a1d2ed9 100644 --- a/squirrelbattle/display/display_manager.py +++ b/squirrelbattle/display/display_manager.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later import curses + +from squirrelbattle.display.creditsdisplay import CreditsDisplay from squirrelbattle.display.display import VerticalSplit, HorizontalSplit, \ Display from squirrelbattle.display.mapdisplay import MapDisplay @@ -30,17 +32,21 @@ class DisplayManager: self.mainmenudisplay = MainMenuDisplay(self.game.main_menu, screen, pack) self.settingsmenudisplay = SettingsMenuDisplay(screen, pack) - self.messagedisplay = MessageDisplay(screen=screen, pack=None) + self.messagedisplay = MessageDisplay(screen, pack) self.hbar = HorizontalSplit(screen, pack) self.vbar = VerticalSplit(screen, pack) + self.creditsdisplay = CreditsDisplay(screen, pack) self.displays = [self.statsdisplay, self.mapdisplay, self.mainmenudisplay, self.settingsmenudisplay, self.logsdisplay, self.messagedisplay, self.playerinventorydisplay, - self.storeinventorydisplay] + self.storeinventorydisplay, self.creditsdisplay] self.update_game_components() def handle_display_action(self, action: DisplayActions, *params) -> None: + """ + Handles the differents values of display action. + """ if action == DisplayActions.REFRESH: self.refresh() elif action == DisplayActions.UPDATE: @@ -49,19 +55,18 @@ class DisplayManager: self.handle_mouse_click(*params) def update_game_components(self) -> None: + """ + The game state was updated. + Trigger all displays of these modifications. + """ 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.game.inventory_menu.update_player(self.game.player) - self.game.store_menu.update_merchant(self.game.player) - self.playerinventorydisplay.update_menu(self.game.inventory_menu) - self.storeinventorydisplay.update_menu(self.game.store_menu) - self.settingsmenudisplay.update_menu(self.game.settings_menu) - self.logsdisplay.update_logs(self.game.logs) - self.messagedisplay.update_message(self.game.message) + d.update(self.game) - def handle_mouse_click(self, y: int, x: int) -> None: + def handle_mouse_click(self, y: int, x: int, attr: int) -> None: + """ + Handles the mouse clicks. + """ displays = self.refresh() display = None for d in displays: @@ -71,10 +76,14 @@ class DisplayManager: # of that display display = d if display: - display.handle_click(y - display.y, x - display.x, self.game) + display.handle_click(y - display.y, x - display.x, attr, self.game) def refresh(self) -> List[Display]: + """ + Refreshes all components on the screen. + """ displays = [] + pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) if self.game.state == GameMode.PLAY \ or self.game.state == GameMode.INVENTORY \ @@ -97,27 +106,41 @@ class DisplayManager: if self.game.state == GameMode.INVENTORY: self.playerinventorydisplay.refresh( - self.rows // 10, self.cols // 2, - 8 * self.rows // 10, 2 * self.cols // 5) + self.rows // 10, + pack.tile_width * (self.cols // (2 * pack.tile_width)), + 8 * self.rows // 10, + pack.tile_width * (2 * self.cols // (5 * pack.tile_width))) displays.append(self.playerinventorydisplay) elif self.game.state == GameMode.STORE: self.storeinventorydisplay.refresh( - self.rows // 10, self.cols // 2, - 8 * self.rows // 10, 2 * self.cols // 5) + self.rows // 10, + pack.tile_width * (self.cols // (2 * pack.tile_width)), + 8 * self.rows // 10, + pack.tile_width * (2 * self.cols // (5 * pack.tile_width))) + self.playerinventorydisplay.refresh( + self.rows // 10, + pack.tile_width * (self.cols // (10 * pack.tile_width)), + 8 * self.rows // 10, + pack.tile_width * (2 * self.cols // (5 * pack.tile_width))) displays.append(self.storeinventorydisplay) + displays.append(self.playerinventorydisplay) elif self.game.state == GameMode.MAINMENU: self.mainmenudisplay.refresh(0, 0, self.rows, self.cols) displays.append(self.mainmenudisplay) elif self.game.state == GameMode.SETTINGS: self.settingsmenudisplay.refresh(0, 0, self.rows, self.cols) displays.append(self.settingsmenudisplay) + elif self.game.state == GameMode.CREDITS: + self.creditsdisplay.refresh(0, 0, self.rows, self.cols) + displays.append(self.creditsdisplay) 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 + y = pack.tile_width * (self.rows - height) // (2 * pack.tile_width) + x = pack.tile_width * ((self.cols - width) // (2 * pack.tile_width)) self.messagedisplay.refresh(y, x, height, width) displays.append(self.messagedisplay) @@ -127,7 +150,7 @@ class DisplayManager: def resize_window(self) -> bool: """ - If the window got resized, ensure that the screen size got updated. + When the window is resized, ensures that the screen size is updated. """ y, x = self.screen.getmaxyx() if self.screen else (0, 0) if self.screen and curses.is_term_resized(self.rows, @@ -138,8 +161,16 @@ class DisplayManager: @property def rows(self) -> int: + """ + Overwrites the native curses attribute of the same name, + for testing purposes. + """ return curses.LINES if self.screen else 42 @property def cols(self) -> int: + """ + Overwrites the native curses attribute of the same name, + for testing purposes. + """ return curses.COLS if self.screen else 42 diff --git a/squirrelbattle/display/logsdisplay.py b/squirrelbattle/display/logsdisplay.py index b768a0e..5c30b41 100644 --- a/squirrelbattle/display/logsdisplay.py +++ b/squirrelbattle/display/logsdisplay.py @@ -2,17 +2,23 @@ # SPDX-License-Identifier: GPL-3.0-or-later from squirrelbattle.display.display import Display +from squirrelbattle.game import Game from squirrelbattle.interfaces import Logs class LogsDisplay(Display): + """ + A class to handle the display of the logs. + """ + + logs: Logs 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 update(self, game: Game) -> None: + self.logs = game.logs def display(self) -> None: messages = self.logs.messages[-self.height:] diff --git a/squirrelbattle/display/mapdisplay.py b/squirrelbattle/display/mapdisplay.py index d403f7f..701f33e 100644 --- a/squirrelbattle/display/mapdisplay.py +++ b/squirrelbattle/display/mapdisplay.py @@ -3,27 +3,42 @@ from squirrelbattle.interfaces import Map from .display import Display +from ..game import Game class MapDisplay(Display): + """ + A class to handle the display of the map. + """ + + map: Map 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(self, game: Game) -> None: + self.map = game.map + self.pad = self.newpad(self.map.height, + self.pack.tile_width * self.map.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 j in range(len(self.map.tiles)): + for i in range(len(self.map.tiles[j])): + if not self.map.seen_tiles[j][i]: + continue + fg, bg = self.map.tiles[j][i].visible_color(self.pack) if \ + self.map.visibility[j][i] else \ + self.map.tiles[j][i].hidden_color(self.pack) + self.addstr(self.pad, j, self.pack.tile_width * i, + self.map.tiles[j][i].char(self.pack), fg, bg) 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)) + if self.map.visibility[e.y][e.x]: + self.addstr(self.pad, e.y, self.pack.tile_width * e.x, + self.pack[e.name.upper()], + self.pack.entity_fg_color, + self.pack.entity_bg_color) - # Display Path map for deubg purposes + # Display Path map for debug 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 @@ -42,7 +57,8 @@ class MapDisplay(Display): # else: # character = '←' # self.addstr(self.pad, y, self.pack.tile_width * x, - # character, self.color_pair(1)) + # character, self.pack.tile_fg_color, + # self.pack.tile_bg_color) def display(self) -> None: y, x = self.map.currenty, self.pack.tile_width * self.map.currentx diff --git a/squirrelbattle/display/menudisplay.py b/squirrelbattle/display/menudisplay.py index 8c2cce9..4e08436 100644 --- a/squirrelbattle/display/menudisplay.py +++ b/squirrelbattle/display/menudisplay.py @@ -2,11 +2,13 @@ # SPDX-License-Identifier: GPL-3.0-or-later import curses +from random import randint from typing import List -from squirrelbattle.menus import Menu, MainMenu -from .display import Display, Box -from ..enums import KeyValues +from squirrelbattle.menus import Menu, MainMenu, SettingsMenu, StoreMenu +from .display import Box, Display +from ..entities.player import Player +from ..enums import KeyValues, GameMode from ..game import Game from ..resources import ResourceManager from ..translations import gettext as _ @@ -14,8 +16,9 @@ from ..translations import gettext as _ class MenuDisplay(Display): """ - A class to display the menu objects + A class to display the menu objects. """ + menu: Menu position: int def __init__(self, *args, **kwargs): @@ -30,9 +33,9 @@ class MenuDisplay(Display): def update_pad(self) -> None: for i in range(self.trueheight): - self.addstr(self.pad, i, 0, " " + self.values[i]) + self.addstr(self.pad, i, 0, " " + self.values[i]) # set a marker on the selected line - self.addstr(self.pad, self.menu.position, 0, ">") + self.addstr(self.pad, self.menu.position, 0, " >") def display(self) -> None: cornery = 0 if self.height - 2 >= self.menu.position - 1 \ @@ -43,11 +46,11 @@ class MenuDisplay(Display): 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.refresh_pad(self.pad, cornery, 0, self.y + 1, self.x + 1, self.height - 2 + self.y, self.width - 2 + self.x) - def handle_click(self, y: int, x: int, game: Game) -> None: + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: """ We can select a menu item with the mouse. """ @@ -77,8 +80,13 @@ class MenuDisplay(Display): class SettingsMenuDisplay(MenuDisplay): """ - A class to display specifically a settingsmenu object + A class to display specifically a settingsmenu object. """ + menu: SettingsMenu + + def update(self, game: Game) -> None: + self.update_menu(game.settings_menu) + @property def values(self) -> List[str]: return [_(a[1][1]) + (" : " @@ -90,7 +98,7 @@ class SettingsMenuDisplay(MenuDisplay): class MainMenuDisplay(Display): """ - A class to display specifically a mainmenu object + A class to display specifically a mainmenu object. """ def __init__(self, menu: MainMenu, *args): super().__init__(*args) @@ -102,13 +110,18 @@ class MainMenuDisplay(Display): self.pad = self.newpad(max(self.rows, len(self.title) + 30), max(len(self.title[0]) + 5, self.cols)) + self.fg_color = curses.COLOR_WHITE + 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]) + - len(self.title[0]) // 2 - 1, 0), self.title[i], + self.fg_color) + msg = _("Credits") + self.addstr(self.pad, self.height - 1, self.width - 1 - len(msg), msg) self.refresh_pad(self.pad, 0, 0, self.y, self.x, self.height + self.y - 1, self.width + self.x - 1) @@ -118,26 +131,56 @@ class MainMenuDisplay(Display): menuy, menux, min(self.menudisplay.preferred_height, self.height - menuy), menuwidth) - def handle_click(self, y: int, x: int, game: Game) -> None: + def update(self, game: Game) -> None: + self.menudisplay.update_menu(game.main_menu) + + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: menuwidth = min(self.menudisplay.preferred_width, self.width) menuy, menux = len(self.title) + 8, self.width // 2 - menuwidth // 2 - 1 menuheight = min(self.menudisplay.preferred_height, self.height - menuy) if menuy <= y < menuy + menuheight and menux <= x < menux + menuwidth: - self.menudisplay.handle_click(y - menuy, x - menux, game) + self.menudisplay.handle_click(y - menuy, x - menux, attr, game) + + if y <= len(self.title): + self.fg_color = randint(0, 1000), randint(0, 1000), randint(0, 1000) + + if y == self.height - 1 and x >= self.width - 1 - len(_("Credits")): + game.state = GameMode.CREDITS class PlayerInventoryDisplay(MenuDisplay): - message = _("== INVENTORY ==") + """ + A class to handle the display of the player's inventory. + """ + player: Player = None + selected: bool = True + store_mode: bool = False + + def update(self, game: Game) -> None: + self.player = game.player + self.update_menu(game.inventory_menu) + self.store_mode = game.state == GameMode.STORE + self.selected = game.state == GameMode.INVENTORY \ + or (self.store_mode and not game.is_in_store_menu) def update_pad(self) -> None: - self.addstr(self.pad, 0, (self.width - len(self.message)) // 2, - self.message, curses.A_BOLD | curses.A_ITALIC) + self.menubox.update_title(_("INVENTORY")) 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()) + selection = f"[{rep}]" if i == self.menu.position \ + and self.selected else f" {rep} " + self.addstr(self.pad, i + 1, 0, selection + + " " + item.translated_name.capitalize() + + (f" ({item.description})" if item.description else "") + + (": " + str(item.price) + " Hazels" + if self.store_mode else "")) + + if self.store_mode: + price = f"{self.pack.HAZELNUT} {self.player.hazel} Hazels" + width = len(price) + (self.pack.tile_width - 1) + self.addstr(self.pad, self.height - 3, self.width - width - 2, + price, italic=True) @property def truewidth(self) -> int: @@ -147,27 +190,42 @@ class PlayerInventoryDisplay(MenuDisplay): def trueheight(self) -> int: return 2 + super().trueheight - def handle_click(self, y: int, x: int, game: Game) -> None: + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: """ We can select a menu item with the mouse. """ - self.menu.position = max(0, min(len(self.menu.values) - 1, y - 3)) + self.menu.position = max(0, min(len(self.menu.values) - 1, y - 2)) + game.is_in_store_menu = False game.handle_key_pressed(KeyValues.ENTER) class StoreInventoryDisplay(MenuDisplay): - message = _("== STALL ==") + """ + A class to handle the display of a merchant's inventory. + """ + menu: StoreMenu + selected: bool = False + + def update(self, game: Game) -> None: + self.update_menu(game.store_menu) + self.selected = game.is_in_store_menu def update_pad(self) -> None: - self.addstr(self.pad, 0, (self.width - len(self.message)) // 2, - self.message, curses.A_BOLD | curses.A_ITALIC) + self.menubox.update_title(_("STALL")) 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 + selection = f"[{rep}]" if i == self.menu.position \ + and self.selected else f" {rep} " + self.addstr(self.pad, i + 1, 0, selection + " " + item.translated_name.capitalize() + + (f" ({item.description})" if item.description else "") + ": " + str(item.price) + " Hazels") + price = f"{self.pack.HAZELNUT} {self.menu.merchant.hazel} Hazels" + width = len(price) + (self.pack.tile_width - 1) + self.addstr(self.pad, self.height - 3, self.width - width - 2, price, + italic=True) + @property def truewidth(self) -> int: return max(1, self.height if hasattr(self, "height") else 10) @@ -176,9 +234,10 @@ class StoreInventoryDisplay(MenuDisplay): def trueheight(self) -> int: return 2 + super().trueheight - def handle_click(self, y: int, x: int, game: Game) -> None: + def handle_click(self, y: int, x: int, attr: int, game: Game) -> None: """ We can select a menu item with the mouse. """ - self.menu.position = max(0, min(len(self.menu.values) - 1, y - 3)) + self.menu.position = max(0, min(len(self.menu.values) - 1, y - 2)) + game.is_in_store_menu = True game.handle_key_pressed(KeyValues.ENTER) diff --git a/squirrelbattle/display/messagedisplay.py b/squirrelbattle/display/messagedisplay.py index bcc2539..d850500 100644 --- a/squirrelbattle/display/messagedisplay.py +++ b/squirrelbattle/display/messagedisplay.py @@ -3,11 +3,12 @@ import curses from squirrelbattle.display.display import Box, Display +from squirrelbattle.game import Game class MessageDisplay(Display): """ - Display a message in a popup. + A class to handle the display of popup messages. """ def __init__(self, *args, **kwargs): @@ -17,15 +18,15 @@ class MessageDisplay(Display): self.message = "" self.pad = self.newpad(1, 1) - def update_message(self, msg: str) -> None: - self.message = msg + def update(self, game: Game) -> None: + self.message = game.message 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.addstr(self.pad, 0, 0, self.message, bold=True) 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 index ec0c90a..a60f6a2 100644 --- a/squirrelbattle/display/statsdisplay.py +++ b/squirrelbattle/display/statsdisplay.py @@ -3,32 +3,42 @@ import curses +from ..entities.items import Monocle from ..entities.player import Player +from ..game import Game +from ..interfaces import FightingEntity from ..translations import gettext as _ from .display import Display class StatsDisplay(Display): + """ + A class to handle the display of the stats of the player. + """ + game: Game 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(self, game: Game) -> None: + self.game = game + self.player = game.player 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) + string2 = f"{_(self.player.name).capitalize()} " \ + f"-- LVL {self.player.level} -- " \ + f"FLOOR {-self.player.map.floor}\n" \ + f"EXP {self.player.current_xp}/{self.player.max_xp}\n" \ + f"HP {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) + string3 = f"STR {self.player.strength}\n" \ + f"INT {self.player.intelligence}\n" \ + f"CHR {self.player.charisma}\n" \ + f"DEX {self.player.dexterity}\n" \ + f"CON {self.player.constitution}\n" \ + f"CRI {self.player.critical}%" self.addstr(self.pad, 3, 0, string3) inventory_str = _("Inventory:") + " " @@ -44,15 +54,73 @@ class StatsDisplay(Display): if count > 1: inventory_str += f"x{count} " printed_items.append(item) - self.addstr(self.pad, 8, 0, inventory_str) + self.addstr(self.pad, 9, 0, inventory_str) - self.addstr(self.pad, 9, 0, f"{self.pack.HAZELNUT} " - f"x{self.player.hazel}") + if self.player.equipped_main: + self.addstr(self.pad, 10, 0, + _("Equipped main:") + " " + f"{self.pack[self.player.equipped_main.name.upper()]}") + if self.player.equipped_secondary: + self.addstr(self.pad, 11, 0, + _("Equipped secondary:") + " " + + self.pack[self.player.equipped_secondary + .name.upper()]) + if self.player.equipped_armor: + self.addstr(self.pad, 12, 0, + _("Equipped chestplate:") + " " + + self.pack[self.player.equipped_armor.name.upper()]) + if self.player.equipped_helmet: + self.addstr(self.pad, 13, 0, + _("Equipped helmet:") + " " + + self.pack[self.player.equipped_helmet.name.upper()]) + + self.addstr(self.pad, 14, 0, f"{self.pack.HAZELNUT} " + f"x{self.player.hazel}") if self.player.dead: - self.addstr(self.pad, 11, 0, _("YOU ARE DEAD"), - curses.A_BOLD | curses.A_BLINK | curses.A_STANDOUT - | self.color_pair(3)) + self.addstr(self.pad, 15, 0, _("YOU ARE DEAD"), curses.COLOR_RED, + bold=True, blink=True, standout=True) + + if self.player.map.tiles[self.player.y][self.player.x].is_ladder(): + msg = _("Use {key} to use the ladder") \ + .format(key=self.game.settings.KEY_LADDER.upper()) + self.addstr(self.pad, self.height - 2, 0, msg, + italic=True, reverse=True) + + self.update_entities_stats() + + def update_entities_stats(self) -> None: + """ + Display information about a near entity if we have a monocle. + """ + for dy, dx in [(-1, 0), (0, -1), (0, 1), (1, 0)]: + for entity in self.player.map.find_entities(FightingEntity): + if entity == self.player: + continue + + if entity.y == self.player.y + dy \ + and entity.x == self.player.x + dx: + if entity.is_friendly(): + msg = _("Move to the friendly entity to talk to it") \ + if self.game.waiting_for_friendly_key else \ + _("Use {key} then move to talk to the entity") \ + .format(key=self.game.settings.KEY_CHAT.upper()) + self.addstr(self.pad, self.height - 1, 0, msg, + italic=True, reverse=True) + + if isinstance(self.player.equipped_secondary, Monocle): + # Truth monocle + message = f"{entity.translated_name.capitalize()} " \ + f"{self.pack[entity.name.upper()]}\n" \ + f"STR {entity.strength}\n" \ + f"INT {entity.intelligence}\n" \ + f"CHR {entity.charisma}\n" \ + f"DEX {entity.dexterity}\n" \ + f"CON {entity.constitution}\n" \ + f"CRI {entity.critical}%" + self.addstr(self.pad, 17, 0, message) + # Only display one entity + break def display(self) -> None: self.pad.erase() diff --git a/squirrelbattle/display/texturepack.py b/squirrelbattle/display/texturepack.py index a6ec3af..34c76ee 100644 --- a/squirrelbattle/display/texturepack.py +++ b/squirrelbattle/display/texturepack.py @@ -2,33 +2,44 @@ # SPDX-License-Identifier: GPL-3.0-or-later import curses -from typing import Any +from typing import Any, Union, Tuple class TexturePack: + """ + A class to handle displaying several textures. + """ _packs = dict() name: str tile_width: int - tile_fg_color: int - tile_bg_color: int - entity_fg_color: int - entity_bg_color: int + tile_fg_color: Union[int, Tuple[int, int, int]] + tile_fg_visible_color: Union[int, Tuple[int, int, int]] + tile_bg_color: Union[int, Tuple[int, int, int]] + entity_fg_color: Union[int, Tuple[int, int, int]] + entity_bg_color: Union[int, Tuple[int, int, int]] BODY_SNATCH_POTION: str BOMB: str - HEART: str - HEDGEHOG: str + CHESTPLATE: str + EAGLE: str EMPTY: str FLOOR: str HAZELNUT: str + HEART: str + HEDGEHOG: str + HELMET: str MERCHANT: str PLAYER: str RABBIT: str + RING_OF_CRITICAL_DAMAGE: str + RING_OF_MORE_EXPERIENCE: str + SHIELD: str SUNFLOWER: str SWORD: str TEDDY_BEAR: str TIGER: str + TRUMPET: str WALL: str ASCII_PACK: "TexturePack" @@ -54,49 +65,72 @@ class TexturePack: TexturePack.ASCII_PACK = TexturePack( name="ascii", tile_width=1, + tile_fg_visible_color=(1000, 1000, 1000), tile_fg_color=curses.COLOR_WHITE, tile_bg_color=curses.COLOR_BLACK, - entity_fg_color=curses.COLOR_WHITE, + entity_fg_color=(1000, 1000, 1000), entity_bg_color=curses.COLOR_BLACK, BODY_SNATCH_POTION='S', - BOMB='o', + BOMB='ç', + CHESTPLATE='(', + EAGLE='µ', EMPTY=' ', + EXPLOSION='%', FLOOR='.', + LADDER='H', HAZELNUT='¤', HEART='❤', HEDGEHOG='*', + HELMET='0', MERCHANT='M', + MONOCLE='ô', PLAYER='@', RABBIT='Y', + RING_OF_CRITICAL_DAMAGE='o', + RING_OF_MORE_EXPERIENCE='o', + SHIELD='D', SUNFLOWER='I', SWORD='\u2020', TEDDY_BEAR='8', TIGER='n', + TRUMPET='/', WALL='#', ) TexturePack.SQUIRREL_PACK = TexturePack( name="squirrel", tile_width=2, + tile_fg_visible_color=(1000, 1000, 1000), tile_fg_color=curses.COLOR_WHITE, tile_bg_color=curses.COLOR_BLACK, - entity_fg_color=curses.COLOR_WHITE, - entity_bg_color=curses.COLOR_WHITE, + entity_fg_color=(1000, 1000, 1000), + entity_bg_color=(1000, 1000, 1000), BODY_SNATCH_POTION='🔀', BOMB='💣', + CHESTPLATE='🦺', + EAGLE='🦅', EMPTY=' ', + EXPLOSION='💥', FLOOR='██', + LADDER=('🪜', curses.COLOR_WHITE, (1000, 1000, 1000), + curses.COLOR_WHITE, (1000, 1000, 1000)), HAZELNUT='🌰', HEART='💜', HEDGEHOG='🦔', + HELMET='⛑️ ', PLAYER='🐿️ ️', MERCHANT='🦜', + MONOCLE='🧐', RABBIT='🐇', + RING_OF_CRITICAL_DAMAGE='💍', + RING_OF_MORE_EXPERIENCE='💍', + SHIELD='🛡️ ', SUNFLOWER='🌻', - SWORD='🗡️', + SWORD='🗡️ ', TEDDY_BEAR='🧸', TIGER='🐅', + TRUMPET='🎺', WALL='🧱', ) diff --git a/squirrelbattle/entities/friendly.py b/squirrelbattle/entities/friendly.py index 3e965d5..974fe1f 100644 --- a/squirrelbattle/entities/friendly.py +++ b/squirrelbattle/entities/friendly.py @@ -1,17 +1,18 @@ -from ..interfaces import FriendlyEntity, InventoryHolder +from ..interfaces import FriendlyEntity, InventoryHolder, Map, FightingEntity from ..translations import gettext as _ from .player import Player +from .monsters import Monster from .items import Item -from random import choice +from random import choice, shuffle class Merchant(InventoryHolder, FriendlyEntity): """ - The class for merchants in the dungeon + The class of merchants in the dungeon. """ def keys(self) -> list: """ - Returns a friendly entitie's specific attributes + Returns a friendly entitie's specific attributes. """ return super().keys() + ["inventory", "hazel"] @@ -20,7 +21,6 @@ class Merchant(InventoryHolder, FriendlyEntity): super().__init__(name=name, *args, **kwargs) self.inventory = self.translate_inventory(inventory or []) self.hazel = hazel - if not self.inventory: for i in range(5): self.inventory.append(choice(Item.get_all_items())()) @@ -28,23 +28,101 @@ class Merchant(InventoryHolder, FriendlyEntity): def talk_to(self, player: Player) -> str: """ This function is used to open the merchant's inventory in a menu, - and allow the player to buy/sell objects + and allows the player to buy/sell objects. """ return _("I don't sell any squirrel") def change_hazel_balance(self, hz: int) -> None: """ - Change the number of hazel the merchant has by hz. + Changes the number of hazel the merchant has by hz. """ self.hazel += hz class Sunflower(FriendlyEntity): """ - A friendly sunflower + A friendly sunflower. """ - dialogue_option = [_("Flower power!!"), _("The sun is warm today")] - def __init__(self, maxhealth: int = 15, *args, **kwargs) -> None: super().__init__(name="sunflower", maxhealth=maxhealth, *args, **kwargs) + + @property + def dialogue_option(self) -> list: + """ + Lists all that a sunflower can say to the player. + """ + return [_("Flower power!!"), _("The sun is warm today")] + + +class Familiar(FightingEntity): + """ + A friendly familiar that helps the player defeat monsters. + """ + def __init__(self, maxhealth: int = 25, + *args, **kwargs) -> None: + super().__init__(maxhealth=maxhealth, *args, **kwargs) + self.target = None + +# @property +# def dialogue_option(self) -> list: +# """ +# Debug function (to see if used in the real game) +# """ +# return [_("My target is"+str(self.target))] + + def act(self, p: Player, m: Map) -> None: + """ + By default, the familiar tries to stay at distance at most 2 of the + player and if a monster comes in range 3, it focuses on the monster + and attacks it. + """ + if self.target is None: + # If the previous target is dead(or if there was no previous target) + # the familiar tries to get closer to the player. + self.target = p + elif self.target.dead: + self.target = p + if self.target == p: + # Look for monsters around the player to kill TOFIX : if monster is + # out of range, continue targetting player. + for entity in m.entities: + if (p.y - entity.y) ** 2 + (p.x - entity.x) ** 2 <= 9 and\ + isinstance(entity, Monster): + self.target = entity + entity.paths = dict() # Allows the paths to be calculated. + break + + # Familiars move according to a Dijkstra algorithm + # that targets their target. + # If they can not move and are already close to their target, + # they hit, except if their target is the player. + if self.target and (self.y, self.x) in self.target.paths: + # Moves to target player by choosing the best available path + for next_y, next_x in self.target.paths[(self.y, self.x)]: + moved = self.check_move(next_y, next_x, True) + if moved: + break + if self.distance_squared(self.target) <= 1 and \ + not isinstance(self.target, Player): + self.map.logs.add_message(self.hit(self.target)) + break + else: + # Moves in a random direction + # If the direction is not available, tries 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 Trumpet(Familiar): + """ + A class of familiars. + """ + def __init__(self, name: str = "trumpet", strength: int = 3, + maxhealth: int = 20, *args, **kwargs) -> None: + super().__init__(name=name, strength=strength, + maxhealth=maxhealth, *args, **kwargs) diff --git a/squirrelbattle/entities/items.py b/squirrelbattle/entities/items.py index b115f8d..25244cd 100644 --- a/squirrelbattle/entities/items.py +++ b/squirrelbattle/entities/items.py @@ -4,14 +4,13 @@ from random import choice, randint from typing import Optional -from .player import Player from ..interfaces import Entity, FightingEntity, Map, InventoryHolder from ..translations import gettext as _ class Item(Entity): """ - A class for items + A class for items. """ held: bool held_by: Optional[InventoryHolder] @@ -25,12 +24,19 @@ class Item(Entity): self.held_by = held_by self.price = price + @property + def description(self) -> str: + """ + In the inventory, indicate the usefulness of the item. + """ + return "" + def drop(self) -> None: """ - The item is dropped from the inventory onto the floor + The item is dropped from the inventory onto the floor. """ if self.held: - self.held_by.inventory.remove(self) + self.held_by.remove_from_inventory(self) self.held_by.map.add_entity(self) self.move(self.held_by.y, self.held_by.x) self.held = False @@ -45,19 +51,31 @@ class Item(Entity): """ Indicates what should be done when the item is equipped. """ + # Other objects are only equipped as secondary. + if self.held_by.equipped_secondary: + self.held_by.equipped_secondary.unequip() + self.held_by.remove_from_inventory(self) + self.held_by.equipped_secondary = self - def hold(self, player: InventoryHolder) -> None: + def unequip(self) -> None: """ - The item is taken from the floor and put into the inventory + Indicates what should be done when the item is unequipped. + """ + self.held_by.remove_from_inventory(self) + self.held_by.add_to_inventory(self) + + def hold(self, holder: InventoryHolder) -> None: + """ + The item is taken from the floor and put into the inventory. """ self.held = True - self.held_by = player + self.held_by = holder self.held_by.map.remove_entity(self) - player.add_to_inventory(self) + holder.add_to_inventory(self) def save_state(self) -> dict: """ - Saves the state of the entity into a dictionary + Saves the state of the item into a dictionary. """ d = super().save_state() d["held"] = self.held @@ -65,13 +83,17 @@ class Item(Entity): @staticmethod def get_all_items() -> list: - return [BodySnatchPotion, Bomb, Heart, Sword] + """ + Returns the list of all item classes. + """ + return [BodySnatchPotion, Chestplate, Bomb, Heart, Helmet, Monocle, + Shield, Sword, RingCritical, RingXP] def be_sold(self, buyer: InventoryHolder, seller: InventoryHolder) -> bool: """ Does all necessary actions when an object is to be sold. Is overwritten by some classes that cannot exist in the player's - inventory + inventory. """ if buyer.hazel >= self.price: self.hold(buyer) @@ -85,7 +107,7 @@ class Item(Entity): class Heart(Item): """ - A heart item to return health to the player + A heart item to return health to the player. """ healing: int @@ -94,16 +116,21 @@ class Heart(Item): super().__init__(name=name, price=price, *args, **kwargs) self.healing = healing + @property + def description(self) -> str: + return f"HP+{self.healing}" + def hold(self, entity: InventoryHolder) -> None: """ - When holding a heart, heal the player and don't put item in inventory. + When holding a heart, the player is healed and + the item is not put in the inventory. """ entity.health = min(entity.maxhealth, entity.health + self.healing) entity.map.remove_entity(self) def save_state(self) -> dict: """ - Saves the state of the header into a dictionary + Saves the state of the heart into a dictionary. """ d = super().save_state() d["healing"] = self.healing @@ -116,7 +143,7 @@ class Bomb(Item): """ damage: int = 5 exploding: bool - owner: Optional["Player"] + owner: Optional["InventoryHolder"] tick: int def __init__(self, name: str = "bomb", damage: int = 5, @@ -129,7 +156,7 @@ class Bomb(Item): def use(self) -> None: """ - When the bomb is used, throw it and explodes it. + When the bomb is used, it is thrown and then it explodes. """ if self.held: self.owner = self.held_by @@ -138,7 +165,7 @@ class Bomb(Item): def act(self, m: Map) -> None: """ - Special exploding action of the bomb + Special exploding action of the bomb. """ if self.exploding: if self.tick > 0: @@ -158,9 +185,13 @@ class Bomb(Item): m.logs.add_message(log_message) m.entities.remove(self) + # Add sparkles where the bomb exploded. + explosion = Explosion(y=self.y, x=self.x) + self.map.add_entity(explosion) + def save_state(self) -> dict: """ - Saves the state of the bomb into a dictionary + Saves the state of the bomb into a dictionary. """ d = super().save_state() d["exploding"] = self.exploding @@ -168,6 +199,25 @@ class Bomb(Item): return d +class Explosion(Item): + """ + When a bomb explodes, the explosion is displayed. + """ + def __init__(self, *args, **kwargs): + super().__init__(name="explosion", *args, **kwargs) + + def act(self, m: Map) -> None: + """ + The bomb disappears after exploding. + """ + m.remove_entity(self) + + def hold(self, player: InventoryHolder) -> None: + """ + The player can't hold an explosion. + """ + + class Weapon(Item): """ Non-throwable items that improve player damage @@ -178,6 +228,10 @@ class Weapon(Item): super().__init__(*args, **kwargs) self.damage = damage + @property + def description(self) -> str: + return f"STR+{self.damage}" if self.damage else super().description + def save_state(self) -> dict: """ Saves the state of the weapon into a dictionary @@ -186,14 +240,101 @@ class Weapon(Item): d["damage"] = self.damage return d + def equip(self) -> None: + """ + When a weapon is equipped, the player gains strength. + """ + self.held_by.remove_from_inventory(self) + self.held_by.equipped_main = self + self.held_by.strength += self.damage + + def unequip(self) -> None: + """ + Remove the strength earned by the weapon. + :return: + """ + super().unequip() + self.held_by.strength -= self.damage + class Sword(Weapon): """ A basic weapon """ - def __init__(self, name: str = "sword", price: int = 20, *args, **kwargs): + def __init__(self, name: str = "sword", price: int = 20, + *args, **kwargs): super().__init__(name=name, price=price, *args, **kwargs) - self.name = name + + +class Armor(Item): + """ + Class of items that increase the player's constitution. + """ + constitution: int + + def __init__(self, constitution: int, *args, **kwargs): + super().__init__(*args, **kwargs) + self.constitution = constitution + + @property + def description(self) -> str: + return f"CON+{self.constitution}" if self.constitution \ + else super().description + + def equip(self) -> None: + super().equip() + self.held_by.constitution += self.constitution + + def unequip(self) -> None: + super().unequip() + self.held_by.constitution -= self.constitution + + def save_state(self) -> dict: + d = super().save_state() + d["constitution"] = self.constitution + return d + + +class Shield(Armor): + """ + Class of shield items, they can be equipped in the other hand. + """ + def __init__(self, name: str = "shield", constitution: int = 2, + price: int = 16, *args, **kwargs): + super().__init__(name=name, constitution=constitution, price=price, + *args, **kwargs) + + +class Helmet(Armor): + """ + Class of helmet items, they can be equipped on the head. + """ + def __init__(self, name: str = "helmet", constitution: int = 2, + price: int = 18, *args, **kwargs): + super().__init__(name=name, constitution=constitution, price=price, + *args, **kwargs) + + def equip(self) -> None: + if self.held_by.equipped_helmet: + self.held_by.equipped_helmet.unequip() + self.held_by.remove_from_inventory(self) + self.held_by.equipped_helmet = self + + +class Chestplate(Armor): + """ + Class of chestplate items, they can be equipped on the body. + """ + def __init__(self, name: str = "chestplate", constitution: int = 4, + price: int = 30, *args, **kwargs): + super().__init__(name=name, constitution=constitution, price=price, + *args, **kwargs) + + def equip(self) -> None: + if self.held_by.equipped_armor: + self.held_by.equipped_armor.unequip() + self.held_by.remove_from_inventory(self) + self.held_by.equipped_armor = self class BodySnatchPotion(Item): @@ -228,3 +369,93 @@ class BodySnatchPotion(Item): self.held_by.recalculate_paths() self.held_by.inventory.remove(self) + + +class Ring(Item): + """ + A class of rings that boost the player's statistics. + """ + maxhealth: int + strength: int + intelligence: int + charisma: int + dexterity: int + constitution: int + critical: int + experience: float + + def __init__(self, maxhealth: int = 0, strength: int = 0, + intelligence: int = 0, charisma: int = 0, + dexterity: int = 0, constitution: int = 0, + critical: int = 0, experience: float = 0, *args, **kwargs): + super().__init__(*args, **kwargs) + self.maxhealth = maxhealth + self.strength = strength + self.intelligence = intelligence + self.charisma = charisma + self.dexterity = dexterity + self.constitution = constitution + self.critical = critical + self.experience = experience + + @property + def description(self) -> str: + fields = [("MAX HP", self.maxhealth), ("STR", self.strength), + ("INT", self.intelligence), ("CHR", self.charisma), + ("DEX", self.dexterity), ("CON", self.constitution), + ("CRI", self.critical), ("XP", self.experience)] + return ", ".join(f"{key}+{value}" for key, value in fields if value) + + def equip(self) -> None: + super().equip() + self.held_by.maxhealth += self.maxhealth + self.held_by.strength += self.strength + self.held_by.intelligence += self.intelligence + self.held_by.charisma += self.charisma + self.held_by.dexterity += self.dexterity + self.held_by.constitution += self.constitution + self.held_by.critical += self.critical + self.held_by.xp_buff += self.experience + + def unequip(self) -> None: + super().unequip() + self.held_by.maxhealth -= self.maxhealth + self.held_by.strength -= self.strength + self.held_by.intelligence -= self.intelligence + self.held_by.charisma -= self.charisma + self.held_by.dexterity -= self.dexterity + self.held_by.constitution -= self.constitution + self.held_by.critical -= self.critical + self.held_by.xp_buff -= self.experience + + def save_state(self) -> dict: + d = super().save_state() + d["maxhealth"] = self.maxhealth + d["strength"] = self.strength + d["intelligence"] = self.intelligence + d["charisma"] = self.charisma + d["dexterity"] = self.dexterity + d["constitution"] = self.constitution + d["critical"] = self.critical + d["experience"] = self.experience + return d + + +class RingCritical(Ring): + def __init__(self, name: str = "ring_of_critical_damage", price: int = 15, + critical: int = 20, *args, **kwargs): + super().__init__(name=name, price=price, critical=critical, + *args, **kwargs) + + +class RingXP(Ring): + def __init__(self, name: str = "ring_of_more_experience", price: int = 25, + experience: float = 2, *args, **kwargs): + super().__init__(name=name, price=price, experience=experience, + *args, **kwargs) + + +class Monocle(Item): + def __init__(self, name: str = "monocle", price: int = 10, + *args, **kwargs): + super().__init__(name=name, price=price, *args, **kwargs) diff --git a/squirrelbattle/entities/monsters.py b/squirrelbattle/entities/monsters.py index 34cd4bf..e22aa51 100644 --- a/squirrelbattle/entities/monsters.py +++ b/squirrelbattle/entities/monsters.py @@ -10,8 +10,8 @@ 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. + All specific monster classes overwrite 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: @@ -21,7 +21,7 @@ class Monster(FightingEntity): super().__init__(name="my_monster", strength=strength, maxhealth=maxhealth, *args, **kwargs) - With that way, attributes can be overwritten when the entity got created. + With that way, attributes can be overwritten when the entity is created. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -29,7 +29,7 @@ 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. + If the player is closeby, the monster runs to the player. """ target = None for entity in m.entities: @@ -38,12 +38,12 @@ class Monster(FightingEntity): 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. + # Monsters move according to a Dijkstra algorithm + # that targets the player. + # If they can not move and 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 + # Moves to target player by choosing the best available path for next_y, next_x in target.paths[(self.y, self.x)]: moved = self.check_move(next_y, next_x, True) if moved: @@ -52,8 +52,8 @@ class Monster(FightingEntity): 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 in a random direction + # If the direction is not available, tries another one moves = [self.move_up, self.move_down, self.move_left, self.move_right] shuffle(moves) @@ -61,10 +61,17 @@ class Monster(FightingEntity): if move(): break + def move(self, y: int, x: int) -> None: + """ + Overwrites the move function to recalculate paths. + """ + super().move(y, x) + self.recalculate_paths() + class Tiger(Monster): """ - A tiger monster + A tiger monster. """ def __init__(self, name: str = "tiger", strength: int = 2, maxhealth: int = 20, *args, **kwargs) -> None: @@ -74,7 +81,7 @@ class Tiger(Monster): class Hedgehog(Monster): """ - A really mean hedgehog monster + A really mean hedgehog monster. """ def __init__(self, name: str = "hedgehog", strength: int = 3, maxhealth: int = 10, *args, **kwargs) -> None: @@ -84,19 +91,31 @@ class Hedgehog(Monster): class Rabbit(Monster): """ - A rabbit monster + A rabbit monster. """ def __init__(self, name: str = "rabbit", strength: int = 1, - maxhealth: int = 15, *args, **kwargs) -> None: + maxhealth: int = 15, critical: int = 30, + *args, **kwargs) -> None: super().__init__(name=name, strength=strength, - maxhealth=maxhealth, *args, **kwargs) + maxhealth=maxhealth, critical=critical, + *args, **kwargs) class TeddyBear(Monster): """ - A cute 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) + + +class GiantSeaEagle(Monster): + """ + An eagle boss + """ + def __init__(self, name: str = "eagle", strength: int = 1000, + maxhealth: int = 5000, *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 index 19c8348..615dfd5 100644 --- a/squirrelbattle/entities/player.py +++ b/squirrelbattle/entities/player.py @@ -1,37 +1,54 @@ # 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 typing import Dict, Optional, Tuple +from .items import Item from ..interfaces import FightingEntity, InventoryHolder class Player(InventoryHolder, FightingEntity): """ - The class of the player + The class of the player. """ current_xp: int = 0 max_xp: int = 10 + xp_buff: float = 1 paths: Dict[Tuple[int, int], Tuple[int, int]] + equipped_main: Optional[Item] + equipped_secondary: Optional[Item] + equipped_helmet: Optional[Item] + equipped_armor: Optional[Item] 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, - hazel: int = 42, *args, **kwargs) \ - -> None: + hazel: int = 42, equipped_main: Optional[Item] = None, + equipped_armor: Optional[Item] = None, critical: int = 5, + equipped_secondary: Optional[Item] = None, + equipped_helmet: Optional[Item] = None, xp_buff: float = 1, + vision: int = 5, *args, **kwargs) -> None: super().__init__(name=name, maxhealth=maxhealth, strength=strength, intelligence=intelligence, charisma=charisma, dexterity=dexterity, constitution=constitution, - level=level, *args, **kwargs) + level=level, critical=critical, *args, **kwargs) self.current_xp = current_xp self.max_xp = max_xp + self.xp_buff = xp_buff self.inventory = self.translate_inventory(inventory or []) self.paths = dict() self.hazel = hazel + self.equipped_main = self.dict_to_item(equipped_main) \ + if isinstance(equipped_main, dict) else equipped_main + self.equipped_armor = self.dict_to_item(equipped_armor) \ + if isinstance(equipped_armor, dict) else equipped_armor + self.equipped_secondary = self.dict_to_item(equipped_secondary) \ + if isinstance(equipped_secondary, dict) else equipped_secondary + self.equipped_helmet = self.dict_to_item(equipped_helmet) \ + if isinstance(equipped_helmet, dict) else equipped_helmet + self.vision = vision def move(self, y: int, x: int) -> None: """ @@ -42,10 +59,11 @@ class Player(InventoryHolder, FightingEntity): self.map.currenty = y self.map.currentx = x self.recalculate_paths() + self.map.compute_visibility(self.y, self.x, self.vision) def level_up(self) -> None: """ - Add levels to the player as much as it is possible. + Add as many levels as possible to the player. """ while self.current_xp > self.max_xp: self.level += 1 @@ -59,12 +77,27 @@ class Player(InventoryHolder, FightingEntity): def add_xp(self, xp: int) -> None: """ - Add some experience to the player. - If the required amount is reached, level up. + Adds some experience to the player. + If the required amount is reached, the player levels up. """ - self.current_xp += xp + self.current_xp += int(xp * self.xp_buff) self.level_up() + def remove_from_inventory(self, obj: Item) -> None: + """ + Remove the given item from the inventory, even if the item is equipped. + """ + if obj == self.equipped_main: + self.equipped_main = None + elif obj == self.equipped_armor: + self.equipped_armor = None + elif obj == self.equipped_secondary: + self.equipped_secondary = None + elif obj == self.equipped_helmet: + self.equipped_helmet = None + else: + return super().remove_from_inventory(obj) + # noinspection PyTypeChecker,PyUnresolvedReferences def check_move(self, y: int, x: int, move_if_possible: bool = False) \ -> bool: @@ -87,56 +120,6 @@ class Player(InventoryHolder, FightingEntity): 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 @@ -144,4 +127,12 @@ class Player(InventoryHolder, FightingEntity): d = super().save_state() d["current_xp"] = self.current_xp d["max_xp"] = self.max_xp + d["equipped_main"] = self.equipped_main.save_state()\ + if self.equipped_main else None + d["equipped_armor"] = self.equipped_armor.save_state()\ + if self.equipped_armor else None + d["equipped_secondary"] = self.equipped_secondary.save_state()\ + if self.equipped_secondary else None + d["equipped_helmet"] = self.equipped_helmet.save_state()\ + if self.equipped_helmet else None return d diff --git a/squirrelbattle/enums.py b/squirrelbattle/enums.py index c04011b..906d6df 100644 --- a/squirrelbattle/enums.py +++ b/squirrelbattle/enums.py @@ -21,20 +21,20 @@ class DisplayActions(Enum): class GameMode(Enum): """ - Game mode options + Game mode options. """ MAINMENU = auto() PLAY = auto() SETTINGS = auto() INVENTORY = auto() STORE = auto() + CREDITS = auto() class KeyValues(Enum): """ - Key values options used in the game + Key values options used in the game. """ - MOUSE = auto() UP = auto() DOWN = auto() LEFT = auto() @@ -46,11 +46,13 @@ class KeyValues(Enum): DROP = auto() SPACE = auto() CHAT = auto() + WAIT = auto() + LADDER = auto() @staticmethod def translate_key(key: str, settings: Settings) -> Optional["KeyValues"]: """ - Translate the raw string key into an enum value that we can use. + Translates the raw string key into an enum value that we can use. """ if key in (settings.KEY_DOWN_SECONDARY, settings.KEY_DOWN_PRIMARY): @@ -78,4 +80,8 @@ class KeyValues(Enum): return KeyValues.SPACE elif key == settings.KEY_CHAT: return KeyValues.CHAT + elif key == settings.KEY_WAIT: + return KeyValues.WAIT + elif key == settings.KEY_LADDER: + return KeyValues.LADDER return None diff --git a/squirrelbattle/game.py b/squirrelbattle/game.py index bb917ec..d51a7fc 100644 --- a/squirrelbattle/game.py +++ b/squirrelbattle/game.py @@ -3,7 +3,7 @@ from json import JSONDecodeError from random import randint -from typing import Any, Optional +from typing import Any, Optional, List import curses import json import os @@ -23,7 +23,8 @@ class Game: """ The game object controls all actions in the game. """ - map: Map + maps: List[Map] + map_index: int player: Player screen: Any # display_actions is a display interface set by the bootstrapper @@ -31,10 +32,11 @@ class Game: def __init__(self) -> None: """ - Init the game. + Initiates the game. """ self.state = GameMode.MAINMENU self.waiting_for_friendly_key = False + self.is_in_store_menu = True self.settings = Settings() self.settings.load_settings() self.settings.write_settings() @@ -49,8 +51,11 @@ class Game: def new_game(self) -> None: """ - Create a new game on the screen. + Creates a new game on the screen. """ + # TODO generate a new map procedurally + self.maps = [] + self.map_index = 0 self.map = broguelike.Generator().run() self.map.logs = self.logs self.logs.clear() @@ -60,20 +65,44 @@ class Game: self.map.spawn_random_entities(randint(3, 10)) self.inventory_menu.update_player(self.player) - def run(self, screen: Any) -> None: + @property + def map(self) -> Map: + """ + Return the current map where the user is. + """ + return self.maps[self.map_index] + + @map.setter + def map(self, m: Map) -> None: + """ + Redefine the current map. + """ + if len(self.maps) == self.map_index: + # Insert new map + self.maps.append(m) + # Redefine the current map + self.maps[self.map_index] = m + + def run(self, screen: Any) -> None: # pragma no cover """ Main infinite loop. - We wait for the player's action, then we do what that should be done - when the given key gets pressed. + We wait for the player's action, then we do what should be done + when a key gets pressed. """ - while True: # pragma no cover + screen.refresh() + while True: screen.erase() - screen.refresh() + screen.noutrefresh() self.display_actions(DisplayActions.REFRESH) - key = screen.getkey() + curses.doupdate() + try: + key = screen.getkey() + except KeyboardInterrupt: + exit(0) + return if key == "KEY_MOUSE": - _ignored1, x, y, _ignored2, _ignored3 = curses.getmouse() - self.display_actions(DisplayActions.MOUSE, y, x) + _ignored1, x, y, _ignored2, attr = curses.getmouse() + self.display_actions(DisplayActions.MOUSE, y, x, attr) else: self.handle_key_pressed( KeyValues.translate_key(key, self.settings), key) @@ -81,7 +110,7 @@ class Game: def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str = '')\ -> None: """ - Indicates what should be done when the given key is pressed, + Indicates what should be done when a given key is pressed, according to the current game state. """ if self.message: @@ -103,36 +132,95 @@ class Game: self.settings_menu.handle_key_pressed(key, raw_key, self) elif self.state == GameMode.STORE: self.handle_key_pressed_store(key) + elif self.state == GameMode.CREDITS: + self.state = GameMode.MAINMENU self.display_actions(DisplayActions.REFRESH) - def handle_key_pressed_play(self, key: KeyValues) -> None: + def handle_key_pressed_play(self, key: KeyValues) -> None: # noqa: C901 """ In play mode, arrows or zqsd move the main character. """ if key == KeyValues.UP: if self.player.move_up(): - self.map.tick() + self.map.tick(self.player) elif key == KeyValues.DOWN: if self.player.move_down(): - self.map.tick() + self.map.tick(self.player) elif key == KeyValues.LEFT: if self.player.move_left(): - self.map.tick() + self.map.tick(self.player) elif key == KeyValues.RIGHT: if self.player.move_right(): - self.map.tick() + self.map.tick(self.player) elif key == KeyValues.INVENTORY: self.state = GameMode.INVENTORY + self.display_actions(DisplayActions.UPDATE) + elif key == KeyValues.USE and self.player.equipped_main: + if self.player.equipped_main: + self.player.equipped_main.use() + if self.player.equipped_secondary: + self.player.equipped_secondary.use() elif key == KeyValues.SPACE: self.state = GameMode.MAINMENU elif key == KeyValues.CHAT: # Wait for the direction of the friendly entity self.waiting_for_friendly_key = True + elif key == KeyValues.WAIT: + self.map.tick(self.player) + elif key == KeyValues.LADDER: + self.handle_ladder() + + def handle_ladder(self) -> None: + """ + The player pressed the ladder key to switch map + """ + # On a ladder, we switch level + y, x = self.player.y, self.player.x + if not self.map.tiles[y][x].is_ladder(): + return + + # We move up on the ladder of the beginning, + # down at the end of the stage + move_down = y != self.map.start_y and x != self.map.start_x + old_map = self.map + self.map_index += 1 if move_down else -1 + if self.map_index == -1: + self.map_index = 0 + return + while self.map_index >= len(self.maps): + # TODO: generate a new map + self.maps.append(Map.load(ResourceManager.get_asset_path( + "example_map_2.txt"))) + new_map = self.map + new_map.floor = self.map_index + old_map.remove_entity(self.player) + new_map.add_entity(self.player) + if move_down: + self.player.move(self.map.start_y, self.map.start_x) + self.logs.add_message( + _("The player climbs down to the floor {floor}.") + .format(floor=-self.map_index)) + else: + # Find the ladder of the end of the game + ladder_y, ladder_x = -1, -1 + for y in range(self.map.height): + for x in range(self.map.width): + if (y, x) != (self.map.start_y, self.map.start_x) \ + and self.map.tiles[y][x].is_ladder(): + ladder_y, ladder_x = y, x + break + self.player.move(ladder_y, ladder_x) + self.logs.add_message( + _("The player climbs up the floor {floor}.") + .format(floor=-self.map_index)) + + self.display_actions(DisplayActions.UPDATE) def handle_friendly_entity_chat(self, key: KeyValues) -> None: """ - If the player is talking to a friendly entity, we get the direction - where the entity is, then we interact with it. + If the player tries to talk to a friendly entity, the game waits for + a directional key to be pressed, verifies there is a friendly entity + in that direction and then lets the player interact with it. """ if not self.waiting_for_friendly_key: return @@ -160,7 +248,9 @@ class Game: self.logs.add_message(msg) if entity.is_merchant(): self.state = GameMode.STORE + self.is_in_store_menu = True self.store_menu.update_merchant(entity) + self.display_actions(DisplayActions.UPDATE) def handle_key_pressed_inventory(self, key: KeyValues) -> None: """ @@ -189,26 +279,37 @@ class Game: """ In a store menu, we can buy items or close the menu. """ - if key == KeyValues.SPACE: + menu = self.store_menu if self.is_in_store_menu else self.inventory_menu + + if key == KeyValues.SPACE or key == KeyValues.INVENTORY: self.state = GameMode.PLAY elif key == KeyValues.UP: - self.store_menu.go_up() + menu.go_up() elif key == KeyValues.DOWN: - self.store_menu.go_down() - if self.store_menu.values and not self.player.dead: + menu.go_down() + elif key == KeyValues.LEFT: + self.is_in_store_menu = False + self.display_actions(DisplayActions.UPDATE) + elif key == KeyValues.RIGHT: + self.is_in_store_menu = True + self.display_actions(DisplayActions.UPDATE) + if menu.values and not self.player.dead: if key == KeyValues.ENTER: - item = self.store_menu.validate() - flag = item.be_sold(self.player, self.store_menu.merchant) + item = menu.validate() + owner = self.store_menu.merchant if self.is_in_store_menu \ + else self.player + buyer = self.player if self.is_in_store_menu \ + else self.store_menu.merchant + flag = item.be_sold(buyer, owner) if not flag: - self.message = _("You do not have enough money") - self.display_actions(DisplayActions.UPDATE) + self.message = _("The buyer does not have enough money") + self.display_actions(DisplayActions.UPDATE) # Ensure that the cursor has a good position - self.store_menu.position = min(self.store_menu.position, - len(self.store_menu.values) - 1) + menu.position = min(menu.position, len(menu.values) - 1) def handle_key_pressed_main_menu(self, key: KeyValues) -> None: """ - In the main menu, we can navigate through options. + In the main menu, we can navigate through different options. """ if key == KeyValues.DOWN: self.main_menu.go_down() @@ -233,16 +334,18 @@ class Game: def save_state(self) -> dict: """ - Saves the game to a dictionary + Saves the game to a dictionary. """ - return self.map.save_state() + return dict(map_index=self.map_index, + maps=[m.save_state() for m in self.maps]) def load_state(self, d: dict) -> None: """ - Loads the game from a dictionary + Loads the game from a dictionary. """ try: - self.map.load_state(d) + self.map_index = d["map_index"] + self.maps = [Map().load_state(map_dict) for map_dict in d["maps"]] except KeyError: self.message = _("Some keys are missing in your save file.\n" "Your save seems to be corrupt. It got deleted.") @@ -259,11 +362,13 @@ class Game: return self.player = players[0] + self.map.compute_visibility(self.player.y, self.player.x, + self.player.vision) self.display_actions(DisplayActions.UPDATE) def load_game(self) -> None: """ - Loads the game from a file + Loads the game from a file. """ file_path = ResourceManager.get_config_path("save.json") if os.path.isfile(file_path): @@ -280,7 +385,7 @@ class Game: def save_game(self) -> None: """ - Saves the game to a file + 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 index 0802fb4..e9b2407 100644 --- a/squirrelbattle/interfaces.py +++ b/squirrelbattle/interfaces.py @@ -2,10 +2,12 @@ # 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, Any +from math import ceil, sqrt from itertools import product +from random import choice, choices, randint +from typing import List, Optional, Any, Dict, Tuple +from queue import PriorityQueue +from functools import reduce from .display.texturepack import TexturePack from .translations import gettext as _ @@ -13,7 +15,7 @@ from .translations import gettext as _ class Logs: """ - The logs object stores the messages to display. It is encapsulating a list + The logs object stores the messages to display. It encapsulates a list of such messages, to allow multiple pointers to keep track of it even if the list was to be reassigned. """ @@ -31,16 +33,47 @@ class Logs: self.messages = [] +class Slope(): + X: int + Y: int + + def __init__(self, y: int, x: int) -> None: + self.Y = y + self.X = x + + def compare(self, other: "Slope") -> int: + y, x = other.Y, other.X + return self.Y * x - self.X * y + + def __lt__(self, other: "Slope") -> bool: + return self.compare(other) < 0 + + def __eq__(self, other: "Slope") -> bool: + return self.compare(other) == 0 + + def __gt__(self, other: "Slope") -> bool: + return self.compare(other) > 0 + + def __le__(self, other: "Slope") -> bool: + return self.compare(other) <= 0 + + def __ge__(self, other: "Slope") -> bool: + return self.compare(other) >= 0 + + class Map: """ - Object that represents a Map with its width, height + The Map object represents a with its width, height and tiles, that have their custom properties. """ + floor: int width: int height: int start_y: int start_x: int tiles: List[List["Tile"]] + visibility: List[List[bool]] + seen_tiles: List[List[bool]] entities: List["Entity"] logs: Logs # coordinates of the point that should be @@ -48,28 +81,36 @@ class Map: currentx: int currenty: int - def __init__(self, width: int, height: int, tiles: list, - start_y: int, start_x: int): + def __init__(self, width: int = 0, height: int = 0, tiles: list = None, + start_y: int = 0, start_x: int = 0): + self.floor = 0 self.width = width self.height = height self.start_y = start_y self.start_x = start_x self.currenty = start_y self.currentx = start_x - self.tiles = tiles + self.tiles = tiles or [] + self.visibility = [[False for _ in range(len(self.tiles[0]))] + for _ in range(len(self.tiles))] + self.seen_tiles = [[False for _ in range(len(tiles[0]))] + for _ in range(len(self.tiles))] self.entities = [] self.logs = Logs() def add_entity(self, entity: "Entity") -> None: """ - Register a new entity in the map. + Registers a new entity in the map. """ - self.entities.append(entity) + if entity.is_familiar(): + self.entities.insert(1, entity) + else: + self.entities.append(entity) entity.map = self def remove_entity(self, entity: "Entity") -> None: """ - Unregister an entity from the map. + Unregisters an entity from the map. """ if entity in self.entities: self.entities.remove(entity) @@ -89,7 +130,7 @@ class Map: def entity_is_present(self, y: int, x: int) -> bool: """ Indicates that the tile at the coordinates (y, x) contains a killable - entity + entity. """ return 0 <= y < self.height and 0 <= x < self.width and \ any(entity.x == x and entity.y == y and entity.is_friendly() @@ -98,7 +139,8 @@ class Map: @staticmethod def load(filename: str) -> "Map": """ - Read a file that contains the content of a map, and build a Map object. + Reads a file that contains the content of a map, + and builds a Map object. """ with open(filename, "r") as f: file = f.read() @@ -107,7 +149,7 @@ class Map: @staticmethod def load_from_string(content: str) -> "Map": """ - Load a map represented by its characters and build a Map object. + Loads a map represented by its characters and builds a Map object. """ lines = content.split("\n") first_line = lines[0] @@ -123,7 +165,7 @@ class Map: @staticmethod def load_dungeon_from_string(content: str) -> List[List["Tile"]]: """ - Transforms a string into the list of corresponding tiles + Transforms a string into the list of corresponding tiles. """ lines = content.split("\n") tiles = [[Tile.from_ascii_char(c) @@ -132,7 +174,7 @@ class Map: def draw_string(self, pack: TexturePack) -> str: """ - Draw the current map as a string object that can be rendered + Draws 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) @@ -140,29 +182,153 @@ class Map: def spawn_random_entities(self, count: int) -> None: """ - Put randomly {count} entities on the map, where it is available. + Puts randomly {count} entities on the map, only on empty ground tiles. """ - for ignored in range(count): + 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 = choices(Entity.get_all_entity_classes(), + weights=Entity.get_weights(), k=1)[0]() entity.move(y, x) self.add_entity(entity) - def tick(self) -> None: + def compute_visibility(self, y: int, x: int, max_range: int) -> None: """ - Trigger all entity events. + Sets the visible tiles to be the ones visible by an entity at point + (y, x), using a twaked shadow casting algorithm + """ + + for line in self.visibility: + for i in range(len(line)): + line[i] = False + self.set_visible(0, 0, 0, (y, x)) + for octant in range(8): + self.compute_visibility_octant(octant, (y, x), max_range, 1, + Slope(1, 1), Slope(0, 1)) + + def crop_top_visibility(self, octant: int, origin: Tuple[int, int], + x: int, top: Slope) -> int: + if top.X == 1: + top_y = x + else: + top_y = ceil(((x * 2 - 1) * top.Y + top.X) / (top.X * 2)) + if self.is_wall(top_y, x, octant, origin): + top_y += top >= Slope(top_y * 2 + 1, x * 2) and not \ + self.is_wall(top_y + 1, x, octant, origin) + else: + ax = x * 2 + ax += self.is_wall(top_y + 1, x + 1, octant, origin) + top_y += top > Slope(top_y * 2 + 1, ax) + return top_y + + def crop_bottom_visibility(self, octant: int, origin: Tuple[int, int], + x: int, bottom: Slope) -> int: + if bottom.Y == 0: + bottom_y = 0 + else: + bottom_y = ceil(((x * 2 - 1) * bottom.Y + bottom.X) + / (bottom.X * 2)) + bottom_y += bottom >= Slope(bottom_y * 2 + 1, x * 2) and \ + self.is_wall(bottom_y, x, octant, origin) and \ + not self.is_wall(bottom_y + 1, x, octant, origin) + return bottom_y + + def compute_visibility_octant(self, octant: int, origin: Tuple[int, int], + max_range: int, distance: int, top: Slope, + bottom: Slope) -> None: + for x in range(distance, max_range + 1): + top_y = self.crop_top_visibility(octant, origin, x, top) + bottom_y = self.crop_bottom_visibility(octant, origin, x, bottom) + was_opaque = -1 + for y in range(top_y, bottom_y - 1, -1): + if x + y > max_range: + continue + is_opaque = self.is_wall(y, x, octant, origin) + is_visible = is_opaque\ + or ((y != top_y or top > Slope(y * 4 - 1, x * 4 + 1)) + and (y != bottom_y + or bottom < Slope(y * 4 + 1, x * 4 - 1))) + # is_visible = is_opaque\ + # or ((y != top_y or top >= Slope(y, x)) + # and (y != bottom_y or bottom <= Slope(y, x))) + if is_visible: + self.set_visible(y, x, octant, origin) + if x == max_range: + continue + if is_opaque and was_opaque == 0: + nx, ny = x * 2, y * 2 + 1 + nx -= self.is_wall(y + 1, x, octant, origin) + if top > Slope(ny, nx): + if y == bottom_y: + bottom = Slope(ny, nx) + break + else: + self.compute_visibility_octant( + octant, origin, max_range, x + 1, top, + Slope(ny, nx)) + elif y == bottom_y: # pragma: no cover + return + elif not is_opaque and was_opaque == 1: + nx, ny = x * 2, y * 2 + 1 + nx += self.is_wall(y + 1, x + 1, octant, origin) + if bottom >= Slope(ny, nx): # pragma: no cover + return + top = Slope(ny, nx) + was_opaque = is_opaque + if was_opaque != 0: + break + + @staticmethod + def translate_coord(y: int, x: int, octant: int, + origin: Tuple[int, int]) -> Tuple[int, int]: + ny, nx = origin + if octant == 0: + return ny - y, nx + x + elif octant == 1: + return ny - x, nx + y + elif octant == 2: + return ny - x, nx - y + elif octant == 3: + return ny - y, nx - x + elif octant == 4: + return ny + y, nx - x + elif octant == 5: + return ny + x, nx - y + elif octant == 6: + return ny + x, nx + y + elif octant == 7: + return ny + y, nx + x + + def is_wall(self, y: int, x: int, octant: int, + origin: Tuple[int, int]) -> bool: + y, x = self.translate_coord(y, x, octant, origin) + return 0 <= y < len(self.tiles) and 0 <= x < len(self.tiles[0]) and \ + self.tiles[y][x].is_wall() + + def set_visible(self, y: int, x: int, octant: int, + origin: Tuple[int, int]) -> None: + y, x = self.translate_coord(y, x, octant, origin) + if 0 <= y < len(self.tiles) and 0 <= x < len(self.tiles[0]): + self.visibility[y][x] = True + self.seen_tiles[y][x] = True + + def tick(self, p: Any) -> None: + """ + Triggers all entity events. """ for entity in self.entities: - entity.act(self) + if entity.is_familiar(): + entity.act(p, self) + else: + entity.act(self) def save_state(self) -> dict: """ - Saves the map's attributes to a dictionary + Saves the map's attributes to a dictionary. """ d = dict() d["width"] = self.width @@ -175,11 +341,12 @@ class Map: for enti in self.entities: d["entities"].append(enti.save_state()) d["map"] = self.draw_string(TexturePack.ASCII_PACK) + d["seen_tiles"] = self.seen_tiles return d - def load_state(self, d: dict) -> None: + def load_state(self, d: dict) -> "Map": """ - Loads the map's attributes from a dictionary + Loads the map's attributes from a dictionary. """ self.width = d["width"] self.height = d["height"] @@ -188,11 +355,16 @@ class Map: self.currentx = d["currentx"] self.currenty = d["currenty"] self.tiles = self.load_dungeon_from_string(d["map"]) + self.seen_tiles = d["seen_tiles"] + self.visibility = [[False for _ in range(len(self.tiles[0]))] + for _ in range(len(self.tiles))] self.entities = [] dictclasses = Entity.get_all_entity_classes_in_a_dict() for entisave in d["entities"]: self.add_entity(dictclasses[entisave["type"]](**entisave)) + return self + @staticmethod def neighbourhood(grid: List[List["Tile"]], y: int, x: int, large: bool = False, oob: bool = False) \ @@ -217,16 +389,17 @@ class Map: class Tile(Enum): """ - The internal representation of the tiles of the map + The internal representation of the tiles of the map. """ EMPTY = auto() WALL = auto() FLOOR = auto() + LADDER = auto() @staticmethod def from_ascii_char(ch: str) -> "Tile": """ - Maps an ascii character to its equivalent in the texture pack + Maps an ascii character to its equivalent in the texture pack. """ for tile in Tile: if tile.char(TexturePack.ASCII_PACK) == ch: @@ -236,9 +409,27 @@ class Tile(Enum): def char(self, pack: TexturePack) -> str: """ Translates a Tile to the corresponding character according - to the texture pack + to the texture pack. """ - return getattr(pack, self.name) + val = getattr(pack, self.name) + return val[0] if isinstance(val, tuple) else val + + def visible_color(self, pack: TexturePack) -> Tuple[int, int]: + """ + Retrieve the tuple (fg_color, bg_color) of the current Tile + if it is visible. + """ + val = getattr(pack, self.name) + return (val[2], val[4]) if isinstance(val, tuple) else \ + (pack.tile_fg_visible_color, pack.tile_bg_color) + + def hidden_color(self, pack: TexturePack) -> Tuple[int, int]: + """ + Retrieve the tuple (fg_color, bg_color) of the current Tile. + """ + val = getattr(pack, self.name) + return (val[1], val[3]) if isinstance(val, tuple) else \ + (pack.tile_fg_color, pack.tile_bg_color) def is_wall(self) -> bool: """ @@ -246,21 +437,28 @@ class Tile(Enum): """ return self == Tile.WALL + def is_ladder(self) -> bool: + """ + Is this Tile a ladder? + """ + return self == Tile.LADDER + def can_walk(self) -> bool: """ - Check if an entity (player or not) can move in this tile. + Checks 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 + An Entity object represents any entity present on the map. """ y: int x: int name: str map: Map + paths: Dict[Tuple[int, int], Tuple[int, int]] # noinspection PyShadowingBuiltins def __init__(self, y: int = 0, x: int = 0, name: Optional[str] = None, @@ -269,11 +467,12 @@ class Entity: self.x = x self.name = name self.map = map + self.paths = None def check_move(self, y: int, x: int, move_if_possible: bool = False)\ -> bool: """ - Checks if moving to (y,x) is authorized + Checks if moving to (y,x) is authorized. """ free = self.map.is_free(y, x) if free and move_if_possible: @@ -282,7 +481,7 @@ class Entity: def move(self, y: int, x: int) -> bool: """ - Moves an entity to (y,x) coordinates + Moves an entity to (y,x) coordinates. """ self.y = y self.x = x @@ -290,49 +489,100 @@ class Entity: def move_up(self, force: bool = False) -> bool: """ - Moves the entity up one tile, if possible + 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 + 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 + 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 + 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 recalculate_paths(self, max_distance: int = 12) -> None: + """ + Uses Dijkstra algorithm to calculate best paths for other entities to + go to this entity. If self.paths is None, does nothing. + """ + if self.paths is None: + return + 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 act(self, m: Map) -> None: """ - Define the action of the entity that is ran each tick. + Defines the action the entity will do at 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. + Gives the square of the distance to another entity. + Useful to check distances since taking the 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. + Gives the cartesian distance to another entity. """ return sqrt(self.distance_squared(other)) @@ -355,6 +605,13 @@ class Entity: """ return isinstance(self, FriendlyEntity) + def is_familiar(self) -> bool: + """ + Is this entity a familiar? + """ + from squirrelbattle.entities.friendly import Familiar + return isinstance(self, Familiar) + def is_merchant(self) -> bool: """ Is this entity a merchant? @@ -364,48 +621,71 @@ class Entity: @property def translated_name(self) -> str: + """ + Translates the name of entities. + """ return _(self.name.replace("_", " ")) @staticmethod def get_all_entity_classes() -> list: """ - Returns all entities subclasses + Returns all entities subclasses. """ from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart from squirrelbattle.entities.monsters import Tiger, Hedgehog, \ - Rabbit, TeddyBear - from squirrelbattle.entities.friendly import Merchant, Sunflower + Rabbit, TeddyBear, GiantSeaEagle + from squirrelbattle.entities.friendly import Merchant, Sunflower, \ + Trumpet return [BodySnatchPotion, Bomb, Heart, Hedgehog, Rabbit, TeddyBear, - Sunflower, Tiger, Merchant] + Sunflower, Tiger, Merchant, GiantSeaEagle, Trumpet] + + @staticmethod + def get_weights() -> list: + """ + Returns a weigth list associated to the above function, to + be used to spawn random entities with a certain probability. + """ + return [3, 5, 6, 5, 5, 5, + 5, 4, 4, 1, 2] @staticmethod def get_all_entity_classes_in_a_dict() -> dict: """ - Returns all entities subclasses in a dictionary + 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.friendly import Merchant, Sunflower + TeddyBear, GiantSeaEagle + from squirrelbattle.entities.friendly import Merchant, Sunflower, \ + Trumpet from squirrelbattle.entities.items import BodySnatchPotion, Bomb, \ - Heart, Sword + Heart, Monocle, Sword, Shield, Chestplate, Helmet, \ + RingCritical, RingXP return { - "Tiger": Tiger, "Bomb": Bomb, + "Chestplate": Chestplate, "Heart": Heart, "BodySnatchPotion": BodySnatchPotion, + "Eagle": GiantSeaEagle, "Hedgehog": Hedgehog, - "Rabbit": Rabbit, - "TeddyBear": TeddyBear, + "Helmet": Helmet, "Player": Player, "Merchant": Merchant, + "Monocle": Monocle, "Sunflower": Sunflower, "Sword": Sword, + "Trumpet": Trumpet, + "Shield": Shield, + "TeddyBear": TeddyBear, + "Tiger": Tiger, + "Rabbit": Rabbit, + "RingCritical": RingCritical, + "RingXP": RingXP, } def save_state(self) -> dict: """ - Saves the coordinates of the entity + Saves the coordinates of the entity. """ d = dict() d["x"] = self.x @@ -417,7 +697,7 @@ class Entity: class FightingEntity(Entity): """ A FightingEntity is an entity that can fight, and thus has a health, - level and stats + level and stats. """ maxhealth: int health: int @@ -427,11 +707,12 @@ class FightingEntity(Entity): dexterity: int constitution: int level: int + critical: 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: + critical: int = 0, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.maxhealth = maxhealth self.health = maxhealth if health is None else health @@ -441,49 +722,62 @@ class FightingEntity(Entity): self.dexterity = dexterity self.constitution = constitution self.level = level + self.critical = critical @property def dead(self) -> bool: + """ + Is this entity dead ? + """ return self.health <= 0 def hit(self, opponent: "FightingEntity") -> str: """ - Deals damage to the opponent, based on the stats + The entity deals damage to the opponent + based on their respective stats. """ + diceroll = randint(1, 100) + damage = self.strength + string = " " + if diceroll <= self.critical: # It is a critical hit + damage *= 4 + string = " " + _("It's a critical hit!") + " " return _("{name} hits {opponent}.")\ .format(name=_(self.translated_name.capitalize()), - opponent=_(opponent.translated_name)) + " " + \ - opponent.take_damage(self, self.strength) + opponent=_(opponent.translated_name)) + string + \ + opponent.take_damage(self, damage) def take_damage(self, attacker: "Entity", amount: int) -> str: """ - Take damage from the attacker, based on the stats + The entity takes damage from the attacker + based on their respective stats. """ - self.health -= amount + damage = max(0, amount - self.constitution) + self.health -= damage if self.health <= 0: self.die() - return _("{name} takes {amount} damage.")\ - .format(name=self.translated_name.capitalize(), amount=str(amount))\ + return _("{name} takes {damage} damage.")\ + .format(name=self.translated_name.capitalize(), damage=str(damage))\ + (" " + _("{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 + 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 entity's specific attributes + Returns a fighting entity's 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 + Saves the state of the entity into a dictionary. """ d = super().save_state() for name in self.keys(): @@ -493,18 +787,18 @@ class FightingEntity(Entity): class FriendlyEntity(FightingEntity): """ - Friendly entities are living entities which do not attack the player + Friendly entities are living entities which do not attack the player. """ dialogue_option: list def talk_to(self, player: Any) -> str: - a = randint(0, len(self.dialogue_option) - 1) - return "The " + self.translated_name \ - + " said : " + self.dialogue_option[a] + return _("{entity} said: {message}").format( + entity=self.translated_name.capitalize(), + message=choice(self.dialogue_option)) def keys(self) -> list: """ - Returns a friendly entity's specific attributes + Returns a friendly entity's specific attributes. """ return ["maxhealth", "health"] @@ -515,17 +809,17 @@ class InventoryHolder(Entity): def translate_inventory(self, inventory: list) -> list: """ - Translate the JSON-state of the inventory into a list of the items in + Translates the JSON save of the inventory into a list of the items in the inventory. """ for i in range(len(inventory)): if isinstance(inventory[i], dict): - inventory[i] = self.dict_to_inventory(inventory[i]) + inventory[i] = self.dict_to_item(inventory[i]) return inventory - def dict_to_inventory(self, item_dict: dict) -> Entity: + def dict_to_item(self, item_dict: dict) -> Entity: """ - Translate a dict object that contains the state of an item + Translates a dictionnary that contains the state of an item into an item object. """ entity_classes = self.get_all_entity_classes_in_a_dict() @@ -535,7 +829,7 @@ class InventoryHolder(Entity): def save_state(self) -> dict: """ - We save the inventory of the merchant formatted as JSON + The inventory of the merchant is saved in a JSON format. """ d = super().save_state() d["hazel"] = self.hazel @@ -544,19 +838,21 @@ class InventoryHolder(Entity): def add_to_inventory(self, obj: Any) -> None: """ - Adds an object to inventory + Adds an object to the inventory. """ - self.inventory.append(obj) + if obj not in self.inventory: + self.inventory.append(obj) def remove_from_inventory(self, obj: Any) -> None: """ - Removes an object from the inventory + Removes an object from the inventory. """ - self.inventory.remove(obj) + if obj in self.inventory: + self.inventory.remove(obj) def change_hazel_balance(self, hz: int) -> None: """ - Change the number of hazel the entity has by hz. hz is negative - when the player loses money and positive when he gains money + Changes the number of hazel the entity has by hz. hz is negative + when the entity loses money and positive when it gains money. """ self.hazel += hz diff --git a/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po index b5e8225..2cea0a3 100644 --- a/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po +++ b/squirrelbattle/locale/de/LC_MESSAGES/squirrelbattle.po @@ -1,12 +1,14 @@ -# German translation of Squirrel Battle -# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse, ifugao # This file is distributed under the same license as the squirrelbattle package. +# FIRST AUTHOR , YEAR. # +#, 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-11 18:06+0100\n" +"POT-Creation-Date: 2021-01-08 15:15+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -15,52 +17,116 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: squirrelbattle/display/menudisplay.py:113 -msgid "== INVENTORY ==" -msgstr "== BESTAND ==" +msgid "ring_of_critical_damage" +msgstr "" -#: squirrelbattle/display/menudisplay.py:134 -msgid "== STALL ==" -msgstr "== STAND ==" +msgid "ring_of_more_experience" +msgstr "" -#: squirrelbattle/display/statsdisplay.py:34 +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} nimmt {amount} Schadenspunkte." + +#: squirrelbattle/display/creditsdisplay.py:28 +#: squirrelbattle/display/menudisplay.py:123 +#: squirrelbattle/display/menudisplay.py:148 +msgid "Credits" +msgstr "" + +#: squirrelbattle/display/creditsdisplay.py:32 +msgid "Developers:" +msgstr "" + +#: squirrelbattle/display/creditsdisplay.py:38 +msgid "Translators:" +msgstr "" + +#: squirrelbattle/display/menudisplay.py:168 +msgid "INVENTORY" +msgstr "BESTAND" + +#: squirrelbattle/display/menudisplay.py:214 +msgid "STALL" +msgstr "STAND" + +#: squirrelbattle/display/statsdisplay.py:44 msgid "Inventory:" msgstr "Bestand:" -#: squirrelbattle/display/statsdisplay.py:53 +#: squirrelbattle/display/statsdisplay.py:61 +msgid "Equipped main:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:65 +msgid "Equipped secondary:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:70 +msgid "Equipped chestplate:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:74 +msgid "Equipped helmet:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:81 msgid "YOU ARE DEAD" msgstr "SIE WURDEN GESTORBEN" +#: squirrelbattle/display/statsdisplay.py:85 +#, python-brace-format +msgid "Use {key} to use the ladder" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:94 +msgid "Move to the friendly entity to talk to it" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:96 +#, python-brace-format +msgid "Use {key} then move to talk to the entity" +msgstr "" + #. TODO #: squirrelbattle/entities/friendly.py:33 msgid "I don't sell any squirrel" msgstr "Ich verkaufe keinen Eichhörnchen." -#: squirrelbattle/entities/friendly.py:46 +#: squirrelbattle/entities/friendly.py:55 msgid "Flower power!!" msgstr "Blumenmacht!!" -#: squirrelbattle/entities/friendly.py:46 +#: squirrelbattle/entities/friendly.py:55 msgid "The sun is warm today" msgstr "Die Sonne ist warm heute" #. 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:151 +#: squirrelbattle/entities/items.py:178 msgid "Bomb is exploding." msgstr "Die Bombe explodiert." -#: squirrelbattle/entities/items.py:224 +#: squirrelbattle/entities/items.py:365 #, python-brace-format msgid "{player} exchanged its body with {entity}." msgstr "{player} täuscht seinem Körper mit {entity} aus." -#: squirrelbattle/game.py:199 squirrelbattle/tests/game_test.py:537 -msgid "You do not have enough money" -msgstr "" +#: squirrelbattle/game.py:200 +#, python-brace-format +msgid "The player climbs down to the floor {floor}." +msgstr "Der Spieler klettert auf dem Stock {floor} hinunter." -#: squirrelbattle/game.py:243 +#: squirrelbattle/game.py:213 +#, python-brace-format +msgid "The player climbs up the floor {floor}." +msgstr "Der Spieler klettert auf dem Stock {floor} hinoben." + +#: squirrelbattle/game.py:304 squirrelbattle/tests/game_test.py:603 +msgid "The buyer does not have enough money" +msgstr "Der Kaufer hat nicht genug Geld" + +#: squirrelbattle/game.py:349 msgid "" "Some keys are missing in your save file.\n" "Your save seems to be corrupt. It got deleted." @@ -68,7 +134,7 @@ msgstr "" "In Ihrer Speicherdatei fehlen einige Schlüssel.\n" "Ihre Speicherung scheint korrupt zu sein. Es wird gelöscht." -#: squirrelbattle/game.py:251 +#: squirrelbattle/game.py:357 msgid "" "No player was found on this map!\n" "Maybe you died?" @@ -76,7 +142,7 @@ msgstr "" "Auf dieser Karte wurde kein Spieler gefunden!\n" "Vielleicht sind Sie gestorben?" -#: squirrelbattle/game.py:271 +#: squirrelbattle/game.py:379 msgid "" "The JSON file is not correct.\n" "Your save seems corrupted. It got deleted." @@ -84,27 +150,36 @@ msgstr "" "Die JSON-Datei ist nicht korrekt.\n" "Ihre Speicherung scheint korrumpiert. Sie wurde gelöscht." -#: squirrelbattle/interfaces.py:429 +#: squirrelbattle/interfaces.py:718 +msgid "It's a critical hit!" +msgstr "" + +#: squirrelbattle/interfaces.py:719 #, python-brace-format msgid "{name} hits {opponent}." msgstr "{name} schlägt {opponent}." -#: squirrelbattle/interfaces.py:441 +#: squirrelbattle/interfaces.py:733 #, python-brace-format -msgid "{name} takes {amount} damage." -msgstr "{name} nimmt {amount} Schadenspunkte." +msgid "{name} takes {damage} damage." +msgstr "" -#: squirrelbattle/interfaces.py:443 +#: squirrelbattle/interfaces.py:735 #, python-brace-format msgid "{name} dies." msgstr "{name} stirbt." +#: squirrelbattle/interfaces.py:769 +#, python-brace-format +msgid "{entity} said: {message}" +msgstr "{entity} hat gesagt: {message}" + #: squirrelbattle/menus.py:73 msgid "Back" msgstr "Zurück" -#: squirrelbattle/tests/game_test.py:314 squirrelbattle/tests/game_test.py:317 -#: squirrelbattle/tests/game_test.py:320 +#: squirrelbattle/tests/game_test.py:368 squirrelbattle/tests/game_test.py:371 +#: squirrelbattle/tests/game_test.py:374 squirrelbattle/tests/game_test.py:377 #: squirrelbattle/tests/translations_test.py:16 msgid "New game" msgstr "Neu Spiel" @@ -186,45 +261,93 @@ msgid "Key used to talk to a friendly entity" msgstr "Taste um mit einer friedlicher Entität zu sprechen" #: squirrelbattle/tests/translations_test.py:55 +msgid "Key used to wait" +msgstr "Wartentaste" + +#: squirrelbattle/tests/translations_test.py:56 +msgid "Key used to use ladders" +msgstr "Leitertaste" + +#: squirrelbattle/tests/translations_test.py:58 msgid "Texture pack" msgstr "Textur-Packung" -#: squirrelbattle/tests/translations_test.py:56 +#: squirrelbattle/tests/translations_test.py:59 msgid "Language" msgstr "Sprache" -#: squirrelbattle/tests/translations_test.py:59 +#: squirrelbattle/tests/translations_test.py:62 msgid "player" msgstr "Spieler" -#: squirrelbattle/tests/translations_test.py:61 -msgid "tiger" -msgstr "Tiger" - -#: squirrelbattle/tests/translations_test.py:62 +#: squirrelbattle/tests/translations_test.py:64 msgid "hedgehog" msgstr "Igel" -#: squirrelbattle/tests/translations_test.py:63 +#: squirrelbattle/tests/translations_test.py:65 +msgid "merchant" +msgstr "Kaufmann" + +#: squirrelbattle/tests/translations_test.py:66 msgid "rabbit" msgstr "Kanninchen" -#: squirrelbattle/tests/translations_test.py:64 +#: squirrelbattle/tests/translations_test.py:67 +msgid "sunflower" +msgstr "Sonnenblume" + +#: squirrelbattle/tests/translations_test.py:68 msgid "teddy bear" msgstr "Teddybär" -#: squirrelbattle/tests/translations_test.py:66 +#: squirrelbattle/tests/translations_test.py:69 +msgid "tiger" +msgstr "Tiger" + +#: squirrelbattle/tests/translations_test.py:70 +msgid "eagle" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:72 msgid "body snatch potion" msgstr "Leichenfleddererzaubertrank" -#: squirrelbattle/tests/translations_test.py:67 +#: squirrelbattle/tests/translations_test.py:73 msgid "bomb" msgstr "Bombe" -#: squirrelbattle/tests/translations_test.py:68 +#: squirrelbattle/tests/translations_test.py:74 +msgid "explosion" +msgstr "Explosion" + +#: squirrelbattle/tests/translations_test.py:75 msgid "heart" msgstr "Herz" -#: squirrelbattle/tests/translations_test.py:69 +#: squirrelbattle/tests/translations_test.py:76 msgid "sword" msgstr "schwert" + +#: squirrelbattle/tests/translations_test.py:77 +msgid "helmet" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:78 +msgid "chestplate" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:79 +msgid "shield" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:80 +msgid "ring of critical damage" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:82 +msgid "ring of more experience" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:84 +msgid "monocle" +msgstr "" diff --git a/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po index acbfb5a..18868c3 100644 --- a/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po +++ b/squirrelbattle/locale/es/LC_MESSAGES/squirrelbattle.po @@ -1,49 +1,131 @@ -# Spanish translation of Squirrel Battle -# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse, ifugao # This file is distributed under the same license as the squirrelbattle package. -# Translation by ifugaao +# FIRST AUTHOR , YEAR. # +#, 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" +"POT-Creation-Date: 2021-01-08 15:15+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: ifugao\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" -# Suggested in Weblate: == INVENTORIO == -#: squirrelbattle/display/menudisplay.py:105 -msgid "== INVENTORY ==" -msgstr "== INVENTORIO ==" +msgid "ring_of_critical_damage" +msgstr "" -# Suggested in Weblate: Inventorio : -#: squirrelbattle/display/statsdisplay.py:34 +msgid "ring_of_more_experience" +msgstr "" + +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} recibe {amount} daño." + +#: squirrelbattle/display/creditsdisplay.py:28 +#: squirrelbattle/display/menudisplay.py:123 +#: squirrelbattle/display/menudisplay.py:148 +msgid "Credits" +msgstr "" + +#: squirrelbattle/display/creditsdisplay.py:32 +msgid "Developers:" +msgstr "" + +#: squirrelbattle/display/creditsdisplay.py:38 +msgid "Translators:" +msgstr "" + +#: squirrelbattle/display/menudisplay.py:168 +msgid "INVENTORY" +msgstr "INVENTORIO" + +#: squirrelbattle/display/menudisplay.py:214 +msgid "STALL" +msgstr "PUESTO" + +#: squirrelbattle/display/statsdisplay.py:44 msgid "Inventory:" msgstr "Inventorio :" -# Suggested in Weblate: ERES MUERTO -#: squirrelbattle/display/statsdisplay.py:50 +#: squirrelbattle/display/statsdisplay.py:61 +msgid "Equipped main:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:65 +msgid "Equipped secondary:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:70 +msgid "Equipped chestplate:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:74 +msgid "Equipped helmet:" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:81 msgid "YOU ARE DEAD" msgstr "ERES MUERTO" +#: squirrelbattle/display/statsdisplay.py:85 +#, python-brace-format +msgid "Use {key} to use the ladder" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:94 +msgid "Move to the friendly entity to talk to it" +msgstr "" + +#: squirrelbattle/display/statsdisplay.py:96 +#, python-brace-format +msgid "Use {key} then move to talk to the entity" +msgstr "" + +#: squirrelbattle/entities/friendly.py:33 +msgid "I don't sell any squirrel" +msgstr "No vendo ninguna ardilla" + +#: squirrelbattle/entities/friendly.py:55 +msgid "Flower power!!" +msgstr "Poder de las flores!!" + +#: squirrelbattle/entities/friendly.py:55 +msgid "The sun is warm today" +msgstr "El sol está caliente hoy" + #. 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 +#: squirrelbattle/entities/items.py:178 msgid "Bomb is exploding." msgstr "La bomba está explotando." -#: squirrelbattle/entities/items.py:172 +#: squirrelbattle/entities/items.py:365 #, python-brace-format msgid "{player} exchanged its body with {entity}." msgstr "{player} intercambió su cuerpo con {entity}." -#: squirrelbattle/game.py:177 +#: squirrelbattle/game.py:200 +#, python-brace-format +msgid "The player climbs down to the floor {floor}." +msgstr "" + +#: squirrelbattle/game.py:213 +#, python-brace-format +msgid "The player climbs up the floor {floor}." +msgstr "" + +#: squirrelbattle/game.py:304 squirrelbattle/tests/game_test.py:603 +msgid "The buyer does not have enough money" +msgstr "El comprador no tiene suficiente dinero" + +#: squirrelbattle/game.py:349 msgid "" "Some keys are missing in your save file.\n" "Your save seems to be corrupt. It got deleted." @@ -51,7 +133,7 @@ msgstr "" "Algunas claves faltan en su archivo de guarda.\n" "Su guarda parece a ser corruptido. Fue eliminado." -#: squirrelbattle/game.py:185 +#: squirrelbattle/game.py:357 msgid "" "No player was found on this map!\n" "Maybe you died?" @@ -59,7 +141,7 @@ msgstr "" "No jugador encontrado sobre la carta !\n" "¿ Quizas murió ?" -#: squirrelbattle/game.py:205 +#: squirrelbattle/game.py:379 msgid "" "The JSON file is not correct.\n" "Your save seems corrupted. It got deleted." @@ -67,28 +149,36 @@ msgstr "" "El JSON archivo no es correcto.\n" "Su guarda parece corrupta. Fue eliminada." -#: squirrelbattle/interfaces.py:400 +#: squirrelbattle/interfaces.py:718 +msgid "It's a critical hit!" +msgstr "" + +#: squirrelbattle/interfaces.py:719 #, python-brace-format msgid "{name} hits {opponent}." msgstr "{name} golpea a {opponent}." -#: squirrelbattle/interfaces.py:412 +#: squirrelbattle/interfaces.py:733 #, python-brace-format -msgid "{name} takes {amount} damage." -msgstr "{name} recibe {amount} daño." +msgid "{name} takes {damage} damage." +msgstr "" -#: squirrelbattle/interfaces.py:414 +#: squirrelbattle/interfaces.py:735 #, python-brace-format msgid "{name} dies." msgstr "{name} se muere." -#: squirrelbattle/menus.py:72 +#: squirrelbattle/interfaces.py:769 +#, python-brace-format +msgid "{entity} said: {message}" +msgstr "{entity} dijo : {message}" + +#: squirrelbattle/menus.py:73 msgid "Back" msgstr "Volver" -#: squirrelbattle/tests/game_test.py:300, -#: squirrelbattle/tests/game_test.py:303, -#: squirrelbattle/tests/game_test.py:306, +#: squirrelbattle/tests/game_test.py:368 squirrelbattle/tests/game_test.py:371 +#: squirrelbattle/tests/game_test.py:374 squirrelbattle/tests/game_test.py:377 #: squirrelbattle/tests/translations_test.py:16 msgid "New game" msgstr "Nuevo partido" @@ -166,41 +256,97 @@ 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 "Key used to talk to a friendly entity" +msgstr "Tecla para hablar con una entidad amiga" + +#: squirrelbattle/tests/translations_test.py:55 +msgid "Key used to wait" +msgstr "Tecla para espera" + +#: squirrelbattle/tests/translations_test.py:56 +msgid "Key used to use ladders" +msgstr "Tecla para el uso de las escaleras" + +#: squirrelbattle/tests/translations_test.py:58 msgid "Texture pack" msgstr "Paquete de texturas" -#: squirrelbattle/tests/translations_test.py:54 +#: squirrelbattle/tests/translations_test.py:59 msgid "Language" msgstr "Languaje" -#: squirrelbattle/tests/translations_test.py:57 +#: squirrelbattle/tests/translations_test.py:62 msgid "player" msgstr "jugador" -#: squirrelbattle/tests/translations_test.py:59 -msgid "tiger" -msgstr "tigre" - -#: squirrelbattle/tests/translations_test.py:60 +#: squirrelbattle/tests/translations_test.py:64 msgid "hedgehog" msgstr "erizo" -#: squirrelbattle/tests/translations_test.py:61 +#: squirrelbattle/tests/translations_test.py:65 +msgid "merchant" +msgstr "comerciante" + +#: squirrelbattle/tests/translations_test.py:66 msgid "rabbit" msgstr "conejo" -#: squirrelbattle/tests/translations_test.py:62 +#: squirrelbattle/tests/translations_test.py:67 +msgid "sunflower" +msgstr "girasol" + +#: squirrelbattle/tests/translations_test.py:68 msgid "teddy bear" msgstr "osito de peluche" -#: squirrelbattle/tests/translations_test.py:64 +#: squirrelbattle/tests/translations_test.py:69 +msgid "tiger" +msgstr "tigre" + +#: squirrelbattle/tests/translations_test.py:70 +msgid "eagle" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:72 msgid "body snatch potion" msgstr "poción de intercambio" -#: squirrelbattle/tests/translations_test.py:65 +#: squirrelbattle/tests/translations_test.py:73 msgid "bomb" msgstr "bomba" -#: squirrelbattle/tests/translations_test.py:66 +#: squirrelbattle/tests/translations_test.py:74 +msgid "explosion" +msgstr "explosión" + +#: squirrelbattle/tests/translations_test.py:75 msgid "heart" msgstr "corazón" + +#: squirrelbattle/tests/translations_test.py:76 +msgid "sword" +msgstr "espada" + +#: squirrelbattle/tests/translations_test.py:77 +msgid "helmet" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:78 +msgid "chestplate" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:79 +msgid "shield" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:80 +msgid "ring of critical damage" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:82 +msgid "ring of more experience" +msgstr "" + +#: squirrelbattle/tests/translations_test.py:84 +msgid "monocle" +msgstr "" diff --git a/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po b/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po index 9c9f5b0..4657fe8 100644 --- a/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po +++ b/squirrelbattle/locale/fr/LC_MESSAGES/squirrelbattle.po @@ -1,67 +1,126 @@ -# French translation of Squirrel Battle -# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ÿnérant, eichhornchen, nicomarg, charlse, ifugao # This file is distributed under the same license as the squirrelbattle package. +# FIRST AUTHOR , YEAR. # #, 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-11 18:06+0100\n" +"POT-Creation-Date: 2021-01-08 15:15+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" -"Language: fr\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:113 -msgid "== INVENTORY ==" -msgstr "== INVENTAIRE ==" +#, python-brace-format +msgid "{name} takes {amount} damage." +msgstr "{name} prend {amount} points de dégât." -#: squirrelbattle/display/menudisplay.py:134 -msgid "== STALL ==" -msgstr "== STAND ==" +#: squirrelbattle/display/creditsdisplay.py:28 +#: squirrelbattle/display/menudisplay.py:123 +#: squirrelbattle/display/menudisplay.py:148 +msgid "Credits" +msgstr "" -#: squirrelbattle/display/statsdisplay.py:34 +#: squirrelbattle/display/creditsdisplay.py:32 +msgid "Developers:" +msgstr "" + +#: squirrelbattle/display/creditsdisplay.py:38 +msgid "Translators:" +msgstr "" + +#: squirrelbattle/display/menudisplay.py:168 +msgid "INVENTORY" +msgstr "INVENTAIRE" + +#: squirrelbattle/display/menudisplay.py:214 +msgid "STALL" +msgstr "STAND" + +#: squirrelbattle/display/statsdisplay.py:44 msgid "Inventory:" msgstr "Inventaire :" -#: squirrelbattle/display/statsdisplay.py:53 +#: squirrelbattle/display/statsdisplay.py:61 +msgid "Equipped main:" +msgstr "Équipement principal :" + +#: squirrelbattle/display/statsdisplay.py:65 +msgid "Equipped secondary:" +msgstr "Équipement secondaire :" + +#: squirrelbattle/display/statsdisplay.py:70 +msgid "Equipped chestplate:" +msgstr "Plastron équipé :" + +#: squirrelbattle/display/statsdisplay.py:74 +msgid "Equipped helmet:" +msgstr "Casque équipé :" + +#: squirrelbattle/display/statsdisplay.py:81 msgid "YOU ARE DEAD" msgstr "VOUS ÊTES MORT" +#: squirrelbattle/display/statsdisplay.py:85 +#, python-brace-format +msgid "Use {key} to use the ladder" +msgstr "Appuyez sur {key} pour utiliser l'échelle" + +#: squirrelbattle/display/statsdisplay.py:94 +msgid "Move to the friendly entity to talk to it" +msgstr "Avancez vers l'entité pour lui parler" + +#: squirrelbattle/display/statsdisplay.py:96 +#, python-brace-format +msgid "Use {key} then move to talk to the entity" +msgstr "Appuyez sur {key} puis déplacez-vous pour parler" + #. TODO #: squirrelbattle/entities/friendly.py:33 msgid "I don't sell any squirrel" msgstr "Je ne vends pas d'écureuil" -#: squirrelbattle/entities/friendly.py:46 +#: squirrelbattle/entities/friendly.py:55 msgid "Flower power!!" -msgstr "Pouvoir des fleurs !" +msgstr "Pouvoir des fleurs !!" -#: squirrelbattle/entities/friendly.py:46 +#: squirrelbattle/entities/friendly.py:55 msgid "The sun is warm today" msgstr "Le soleil est chaud aujourd'hui" #. 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:151 +#: squirrelbattle/entities/items.py:178 msgid "Bomb is exploding." msgstr "La bombe explose." -#: squirrelbattle/entities/items.py:224 +#: squirrelbattle/entities/items.py:365 #, python-brace-format msgid "{player} exchanged its body with {entity}." msgstr "{player} a échangé son corps avec {entity}." -#: squirrelbattle/game.py:199 squirrelbattle/tests/game_test.py:537 -msgid "You do not have enough money" -msgstr "" +#: squirrelbattle/game.py:200 +#, python-brace-format +msgid "The player climbs down to the floor {floor}." +msgstr "Le joueur descend à l'étage {floor}." -#: squirrelbattle/game.py:243 +#: squirrelbattle/game.py:213 +#, python-brace-format +msgid "The player climbs up the floor {floor}." +msgstr "Le joueur monte à l'étage {floor}." + +#: squirrelbattle/game.py:304 squirrelbattle/tests/game_test.py:603 +msgid "The buyer does not have enough money" +msgstr "L'acheteur n'a pas assez d'argent" + +#: squirrelbattle/game.py:349 msgid "" "Some keys are missing in your save file.\n" "Your save seems to be corrupt. It got deleted." @@ -69,7 +128,7 @@ msgstr "" "Certaines clés de votre ficher de sauvegarde sont manquantes.\n" "Votre sauvegarde semble corrompue. Elle a été supprimée." -#: squirrelbattle/game.py:251 +#: squirrelbattle/game.py:357 msgid "" "No player was found on this map!\n" "Maybe you died?" @@ -77,7 +136,7 @@ msgstr "" "Aucun joueur n'a été trouvé sur la carte !\n" "Peut-être êtes-vous mort ?" -#: squirrelbattle/game.py:271 +#: squirrelbattle/game.py:379 msgid "" "The JSON file is not correct.\n" "Your save seems corrupted. It got deleted." @@ -85,27 +144,36 @@ msgstr "" "Le fichier JSON de sauvegarde est incorrect.\n" "Votre sauvegarde semble corrompue. Elle a été supprimée." -#: squirrelbattle/interfaces.py:429 +#: squirrelbattle/interfaces.py:718 +msgid "It's a critical hit!" +msgstr "C'est un coup critique !" + +#: squirrelbattle/interfaces.py:719 #, python-brace-format msgid "{name} hits {opponent}." msgstr "{name} frappe {opponent}." -#: squirrelbattle/interfaces.py:441 +#: squirrelbattle/interfaces.py:733 #, python-brace-format -msgid "{name} takes {amount} damage." -msgstr "{name} prend {amount} points de dégât." +msgid "{name} takes {damage} damage." +msgstr "{name} prend {damage} dégâts." -#: squirrelbattle/interfaces.py:443 +#: squirrelbattle/interfaces.py:735 #, python-brace-format msgid "{name} dies." msgstr "{name} meurt." +#: squirrelbattle/interfaces.py:769 +#, python-brace-format +msgid "{entity} said: {message}" +msgstr "{entity} a dit : {message}" + #: squirrelbattle/menus.py:73 msgid "Back" msgstr "Retour" -#: squirrelbattle/tests/game_test.py:314 squirrelbattle/tests/game_test.py:317 -#: squirrelbattle/tests/game_test.py:320 +#: squirrelbattle/tests/game_test.py:368 squirrelbattle/tests/game_test.py:371 +#: squirrelbattle/tests/game_test.py:374 squirrelbattle/tests/game_test.py:377 #: squirrelbattle/tests/translations_test.py:16 msgid "New game" msgstr "Nouvelle partie" @@ -187,45 +255,93 @@ msgid "Key used to talk to a friendly entity" msgstr "Touche pour parler à une entité pacifique" #: squirrelbattle/tests/translations_test.py:55 +msgid "Key used to wait" +msgstr "Touche pour attendre" + +#: squirrelbattle/tests/translations_test.py:56 +msgid "Key used to use ladders" +msgstr "Touche pour utiliser les échelles" + +#: squirrelbattle/tests/translations_test.py:58 msgid "Texture pack" msgstr "Pack de textures" -#: squirrelbattle/tests/translations_test.py:56 +#: squirrelbattle/tests/translations_test.py:59 msgid "Language" msgstr "Langue" -#: squirrelbattle/tests/translations_test.py:59 +#: squirrelbattle/tests/translations_test.py:62 msgid "player" msgstr "joueur" -#: squirrelbattle/tests/translations_test.py:61 -msgid "tiger" -msgstr "tigre" - -#: squirrelbattle/tests/translations_test.py:62 +#: squirrelbattle/tests/translations_test.py:64 msgid "hedgehog" msgstr "hérisson" -#: squirrelbattle/tests/translations_test.py:63 +#: squirrelbattle/tests/translations_test.py:65 +msgid "merchant" +msgstr "marchand" + +#: squirrelbattle/tests/translations_test.py:66 msgid "rabbit" msgstr "lapin" -#: squirrelbattle/tests/translations_test.py:64 +#: squirrelbattle/tests/translations_test.py:67 +msgid "sunflower" +msgstr "tournesol" + +#: squirrelbattle/tests/translations_test.py:68 msgid "teddy bear" msgstr "nounours" -#: squirrelbattle/tests/translations_test.py:66 +#: squirrelbattle/tests/translations_test.py:69 +msgid "tiger" +msgstr "tigre" + +#: squirrelbattle/tests/translations_test.py:70 +msgid "eagle" +msgstr "pygargue" + +#: squirrelbattle/tests/translations_test.py:72 msgid "body snatch potion" msgstr "potion d'arrachage de corps" -#: squirrelbattle/tests/translations_test.py:67 +#: squirrelbattle/tests/translations_test.py:73 msgid "bomb" msgstr "bombe" -#: squirrelbattle/tests/translations_test.py:68 +#: squirrelbattle/tests/translations_test.py:74 +msgid "explosion" +msgstr "explosion" + +#: squirrelbattle/tests/translations_test.py:75 msgid "heart" msgstr "cœur" -#: squirrelbattle/tests/translations_test.py:69 +#: squirrelbattle/tests/translations_test.py:76 msgid "sword" msgstr "épée" + +#: squirrelbattle/tests/translations_test.py:77 +msgid "helmet" +msgstr "casque" + +#: squirrelbattle/tests/translations_test.py:78 +msgid "chestplate" +msgstr "plastron" + +#: squirrelbattle/tests/translations_test.py:79 +msgid "shield" +msgstr "bouclier" + +#: squirrelbattle/tests/translations_test.py:80 +msgid "ring of critical damage" +msgstr "anneau de coup critique" + +#: squirrelbattle/tests/translations_test.py:82 +msgid "ring of more experience" +msgstr "anneau de plus d'expérience" + +#: squirrelbattle/tests/translations_test.py:84 +msgid "monocle" +msgstr "monocle" diff --git a/squirrelbattle/menus.py b/squirrelbattle/menus.py index e0087a3..7732642 100644 --- a/squirrelbattle/menus.py +++ b/squirrelbattle/menus.py @@ -14,7 +14,7 @@ from .translations import gettext as _, Translator class Menu: """ - A Menu object is the logical representation of a menu in the game + A Menu object is the logical representation of a menu in the game. """ values: list @@ -23,26 +23,26 @@ class Menu: def go_up(self) -> None: """ - Moves the pointer of the menu on the previous value + 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 + 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 + Selects the value that is pointed by the menu pointer. """ return self.values[self.position] class MainMenuValues(Enum): """ - Values of the main menu + Values of the main menu. """ START = "New game" RESUME = "Resume" @@ -57,14 +57,14 @@ class MainMenuValues(Enum): class MainMenu(Menu): """ - A special instance of a menu : the main menu + A special instance of a menu : the main menu. """ values = [e for e in MainMenuValues] class SettingsMenu(Menu): """ - A special instance of a menu : the settings menu + A special instance of a menu : the settings menu. """ waiting_for_key: bool = False @@ -75,7 +75,7 @@ class SettingsMenu(Menu): def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str, game: Any) -> None: """ - In the setting menu, we van select a setting and change it + In the setting menu, we can select a setting and change it. """ if not self.waiting_for_key: # Navigate normally through the menu. @@ -121,22 +121,40 @@ class SettingsMenu(Menu): class InventoryMenu(Menu): + """ + A special instance of a menu : the menu for the inventory of the player. + """ player: Player def update_player(self, player: Player) -> None: + """ + Updates the player. + """ self.player = player @property def values(self) -> list: + """ + Returns the values of the menu. + """ return self.player.inventory class StoreMenu(Menu): - merchant: Merchant + """ + A special instance of a menu : the menu for the inventory of a merchant. + """ + merchant: Merchant = None def update_merchant(self, merchant: Merchant) -> None: + """ + Updates the merchant. + """ self.merchant = merchant @property def values(self) -> list: - return self.merchant.inventory + """ + Returns the values of the menu. + """ + return self.merchant.inventory if self.merchant else [] diff --git a/squirrelbattle/settings.py b/squirrelbattle/settings.py index 4e6040c..549fc5f 100644 --- a/squirrelbattle/settings.py +++ b/squirrelbattle/settings.py @@ -13,9 +13,10 @@ from .translations import gettext as _ class Settings: """ This class stores the settings of the game. - Settings can be get by using for example settings.TEXTURE_PACK directly. - The comment can be get by using settings.get_comment('TEXTURE_PACK'). - We can define the setting by simply use settings.TEXTURE_PACK = 'new_key' + Settings can be obtained by using for example settings.TEXTURE_PACK + directly. + The comment can be obtained by using settings.get_comment('TEXTURE_PACK'). + We can set the setting by simply using settings.TEXTURE_PACK = 'new_key' """ def __init__(self): self.KEY_UP_PRIMARY = ['z', 'Main key to move up'] @@ -32,6 +33,8 @@ class Settings: 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.KEY_CHAT = ['t', 'Key used to talk to a friendly entity'] + self.KEY_WAIT = ['w', 'Key used to wait'] + self.KEY_LADDER = ['<', 'Key used to use ladders'] self.TEXTURE_PACK = ['ascii', 'Texture pack'] self.LOCALE = [locale.getlocale()[0][:2], 'Language'] @@ -49,7 +52,7 @@ class Settings: def get_comment(self, item: str) -> str: """ - Retrieve the comment of a setting. + Retrieves the comment relative to a setting. """ if item in self.settings_keys: return _(object.__getattribute__(self, item)[1]) @@ -60,21 +63,22 @@ class Settings: @property def settings_keys(self) -> Generator[str, Any, None]: """ - Get the list of all parameters. + Gets the list of all parameters. """ return (key for key in self.__dict__) def loads_from_string(self, json_str: str) -> None: """ - Dump settings + Loads settings. """ d = json.loads(json_str) for key in d: - setattr(self, key, d[key]) + if hasattr(self, key): + setattr(self, key, d[key]) def dumps_to_string(self) -> str: """ - Dump settings + Dumps settings. """ d = dict() for key in self.settings_keys: diff --git a/squirrelbattle/term_manager.py b/squirrelbattle/term_manager.py index 5a98a4a..2e74fff 100644 --- a/squirrelbattle/term_manager.py +++ b/squirrelbattle/term_manager.py @@ -8,7 +8,7 @@ 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 + de-initializes the terminal after use. """ def __init__(self): self.screen = curses.initscr() @@ -21,7 +21,7 @@ class TermManager: # pragma: no cover # make cursor invisible curses.curs_set(False) # Catch mouse events - curses.mousemask(True) + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) # Enable colors curses.start_color() diff --git a/squirrelbattle/tests/entities_test.py b/squirrelbattle/tests/entities_test.py index 2c72abd..5729032 100644 --- a/squirrelbattle/tests/entities_test.py +++ b/squirrelbattle/tests/entities_test.py @@ -1,10 +1,13 @@ # Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse # SPDX-License-Identifier: GPL-3.0-or-later - +import random import unittest -from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart, Item -from squirrelbattle.entities.monsters import Tiger, Hedgehog, Rabbit, TeddyBear +from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart, Item, \ + Explosion +from squirrelbattle.entities.monsters import Tiger, Hedgehog, Rabbit,\ + TeddyBear, GiantSeaEagle +from squirrelbattle.entities.friendly import Trumpet from squirrelbattle.entities.player import Player from squirrelbattle.interfaces import Entity, Map from squirrelbattle.resources import ResourceManager @@ -13,16 +16,17 @@ from squirrelbattle.resources import ResourceManager class TestEntities(unittest.TestCase): def setUp(self) -> None: """ - Load example map that can be used in tests. + Loads example map that can be used in tests. """ self.map = Map.load(ResourceManager.get_asset_path("example_map.txt")) self.player = Player() + self.player.constitution = 0 self.map.add_entity(self.player) self.player.move(self.map.start_y, self.map.start_x) def test_basic_entities(self) -> None: """ - Test some random stuff with basic entities. + Tests some random stuff with basic entities. """ entity = Entity() entity.move(42, 64) @@ -37,7 +41,7 @@ class TestEntities(unittest.TestCase): def test_fighting_entities(self) -> None: """ - Test some random stuff with fighting entities. + Tests some random stuff with fighting entities. """ entity = Tiger() self.map.add_entity(entity) @@ -53,20 +57,21 @@ class TestEntities(unittest.TestCase): self.assertTrue(entity.dead) entity = Rabbit() + entity.critical = 0 self.map.add_entity(entity) entity.move(15, 44) # Move randomly - self.map.tick() + self.map.tick(self.player) self.assertFalse(entity.y == 15 and entity.x == 44) # Move to the player entity.move(3, 6) - self.map.tick() + self.map.tick(self.player) self.assertTrue(entity.y == 2 and entity.x == 6) # Rabbit should fight old_health = self.player.health - self.map.tick() + self.map.tick(self.player) 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], @@ -74,6 +79,7 @@ class TestEntities(unittest.TestCase): {self.player.name.capitalize()} takes {entity.strength} damage.") # Fight the rabbit + self.player.critical = 0 old_health = entity.health self.player.move_down() self.assertEqual(entity.health, old_health - self.player.strength) @@ -88,9 +94,50 @@ class TestEntities(unittest.TestCase): self.assertTrue(entity.dead) self.assertGreaterEqual(self.player.current_xp, 3) + # Test the familiars + fam = Trumpet() + entity = Rabbit() + self.map.add_entity(entity) + self.map.add_entity(fam) + self.player.move(1, 6) + entity.move(2, 6) + fam.move(2, 7) + + # Test fighting + entity.health = 2 + entity.paths = [] + entity.recalculate_paths() + fam.target = entity + self.map.tick(self.player) + self.assertTrue(entity.dead) + + # Test finding a new target + entity2 = Rabbit() + self.map.add_entity(entity2) + entity2.move(2, 6) + self.map.tick(self.player) + self.assertTrue(fam.target == entity2) + self.map.remove_entity(entity2) + + # Test following the player and finding the player as target + self.player.move(5, 5) + fam.move(4, 5) + fam.target = None + self.player.move_down() + self.map.tick(self.player) + self.assertTrue(fam.target == self.player) + self.assertEqual(fam.y, 5) + self.assertEqual(fam.x, 5) + + # Test random move + fam.move(13, 20) + fam.target = self.player + self.map.tick(self.player) + self.assertTrue(fam.x != 20 or fam.y != 13) + def test_items(self) -> None: """ - Test some random stuff with items. + Tests some random stuff with items. """ item = Item() self.map.add_entity(item) @@ -111,7 +158,7 @@ class TestEntities(unittest.TestCase): def test_bombs(self) -> None: """ - Test some random stuff with bombs. + Tests some random stuff with bombs. """ item = Bomb() hedgehog = Hedgehog() @@ -132,16 +179,30 @@ class TestEntities(unittest.TestCase): self.assertEqual(item.y, 42) self.assertEqual(item.x, 42) # Wait for the explosion - for ignored in range(5): + 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) + explosions = self.map.find_entities(Explosion) + self.assertTrue(explosions) + explosion = explosions[0] + self.assertEqual(explosion.y, item.y) + self.assertEqual(explosion.x, item.x) + + # The player can't hold the explosion + explosion.hold(self.player) + self.assertNotIn(explosion, self.player.inventory) + self.assertFalse(explosion.held) + + # The explosion disappears after one tick + explosion.act(self.map) + self.assertNotIn(explosion, self.map.entities) def test_hearts(self) -> None: """ - Test some random stuff with hearts. + Tests some random stuff with hearts. """ item = Heart() self.map.add_entity(item) @@ -156,7 +217,7 @@ class TestEntities(unittest.TestCase): def test_body_snatch_potion(self) -> None: """ - Test some random stuff with body snatch potions. + Tests some random stuff with body snatch potions. """ item = BodySnatchPotion() self.map.add_entity(item) @@ -174,7 +235,7 @@ class TestEntities(unittest.TestCase): def test_players(self) -> None: """ - Test some random stuff with players. + Tests some random stuff with players. """ player = Player() self.map.add_entity(player) @@ -204,3 +265,17 @@ class TestEntities(unittest.TestCase): player_state = player.save_state() self.assertEqual(player_state["current_xp"], 10) + + def test_critical_hit(self) -> None: + """ + Ensure that critical hits are working. + """ + random.seed(2) # Next random.randint(1, 100) will output 8 + self.player.critical = 10 + sea_eagle = GiantSeaEagle() + self.map.add_entity(sea_eagle) + sea_eagle.move(2, 6) + old_health = sea_eagle.health + self.player.hit(sea_eagle) + self.assertEqual(sea_eagle.health, + old_health - self.player.strength * 4) diff --git a/squirrelbattle/tests/game_test.py b/squirrelbattle/tests/game_test.py index e149444..2b8bbe4 100644 --- a/squirrelbattle/tests/game_test.py +++ b/squirrelbattle/tests/game_test.py @@ -1,6 +1,7 @@ # Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse # SPDX-License-Identifier: GPL-3.0-or-later +import curses import os import unittest @@ -8,11 +9,13 @@ from ..bootstrap import Bootstrap from ..display.display import Display from ..display.display_manager import DisplayManager from ..entities.friendly import Merchant, Sunflower -from ..entities.items import Bomb, Heart, Sword +from ..entities.items import Bomb, Heart, Sword, Explosion, Shield, Helmet, \ + Chestplate, RingCritical, Monocle +from ..entities.monsters import GiantSeaEagle from ..entities.player import Player from ..enums import DisplayActions from ..game import Game, KeyValues, GameMode -from ..interfaces import Tile, Map +from ..interfaces import Map, Tile from ..menus import MainMenuValues from ..resources import ResourceManager from ..settings import Settings @@ -22,21 +25,21 @@ from ..translations import gettext as _, Translator class TestGame(unittest.TestCase): def setUp(self) -> None: """ - Setup game. + Sets the game up. """ self.game = Game() self.game.new_game() - self.game.map = Map.load(ResourceManager.get_asset_path( - "example_map.txt")) - self.game.player.move(self.game.map.start_y, self.game.map.start_x) + self.game.map = Map.load( + ResourceManager.get_asset_path("example_map.txt")) self.game.map.add_entity(self.game.player) + self.game.player.move(self.game.map.start_y, self.game.map.start_x) 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. + Saves a game and reloads it. """ bomb = Bomb() self.game.map.add_entity(bomb) @@ -63,6 +66,7 @@ class TestGame(unittest.TestCase): new_state = self.game.save_state() self.assertEqual(old_state, new_state) + self.assertIsNone(self.game.message) # Ensure that the bomb is loaded self.assertTrue(self.game.player.inventory) @@ -90,7 +94,7 @@ class TestGame(unittest.TestCase): def test_bootstrap_fail(self) -> None: """ - Ensure that the test can't play the game, + Ensures that the test can't play the game, because there is no associated shell. Yeah, that's only for coverage. """ @@ -99,7 +103,7 @@ class TestGame(unittest.TestCase): def test_key_translation(self) -> None: """ - Test key bindings. + Tests key bindings. """ self.game.settings = Settings() @@ -145,6 +149,12 @@ class TestGame(unittest.TestCase): self.assertEqual(KeyValues.translate_key( self.game.settings.KEY_DROP, self.game.settings), KeyValues.DROP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_WAIT, self.game.settings), + KeyValues.WAIT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_LADDER, self.game.settings), + KeyValues.LADDER) self.assertEqual(KeyValues.translate_key(' ', self.game.settings), KeyValues.SPACE) self.assertEqual(KeyValues.translate_key('plop', self.game.settings), @@ -152,7 +162,7 @@ class TestGame(unittest.TestCase): def test_key_press(self) -> None: """ - Press a key and see what is done. + Presses a key and asserts what is done is correct. """ self.assertEqual(self.game.state, GameMode.MAINMENU) self.assertEqual(self.game.main_menu.validate(), @@ -238,17 +248,28 @@ class TestGame(unittest.TestCase): self.assertEqual(new_y, y) self.assertEqual(new_x, x - 1) + explosion = Explosion() + self.game.map.add_entity(explosion) + self.assertIn(explosion, self.game.map.entities) + self.game.handle_key_pressed(KeyValues.WAIT) + self.assertNotIn(explosion, self.game.map.entities) + self.game.handle_key_pressed(KeyValues.SPACE) self.assertEqual(self.game.state, GameMode.MAINMENU) def test_mouse_click(self) -> None: """ - Simulate mouse clicks. + Simulates mouse clicks. """ self.game.state = GameMode.MAINMENU + # Change the color of the artwork + self.game.display_actions(DisplayActions.MOUSE, 0, 10, + curses.BUTTON1_CLICKED) + # Settings menu - self.game.display_actions(DisplayActions.MOUSE, 25, 21) + self.game.display_actions(DisplayActions.MOUSE, 25, 21, + curses.BUTTON1_CLICKED) self.assertEqual(self.game.main_menu.position, 4) self.assertEqual(self.game.state, GameMode.SETTINGS) @@ -260,17 +281,19 @@ class TestGame(unittest.TestCase): self.game.state = GameMode.INVENTORY # Click nowhere - self.game.display_actions(DisplayActions.MOUSE, 0, 0) + self.game.display_actions(DisplayActions.MOUSE, 0, 0, + curses.BUTTON1_CLICKED) self.assertEqual(self.game.state, GameMode.INVENTORY) # Click on the second item - self.game.display_actions(DisplayActions.MOUSE, 8, 25) + self.game.display_actions(DisplayActions.MOUSE, 8, 25, + curses.BUTTON1_CLICKED) self.assertEqual(self.game.state, GameMode.INVENTORY) self.assertEqual(self.game.inventory_menu.position, 1) def test_new_game(self) -> None: """ - Ensure that the start button starts a new game. + Ensures that the start button starts a new game. """ old_map = self.game.map old_player = self.game.player @@ -293,7 +316,7 @@ class TestGame(unittest.TestCase): def test_settings_menu(self) -> None: """ - Ensure that the settings menu is working properly. + Ensures that the settings menu is working properly. """ self.game.settings = Settings() @@ -305,13 +328,13 @@ class TestGame(unittest.TestCase): self.game.handle_key_pressed(KeyValues.ENTER) self.assertEqual(self.game.state, GameMode.SETTINGS) - # Define the "move up" key to 'w' + # Define the "move up" key to 'h' 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.game.handle_key_pressed(None, 'h') self.assertFalse(self.game.settings_menu.waiting_for_key) - self.assertEqual(self.game.settings.KEY_UP_PRIMARY, 'w') + self.assertEqual(self.game.settings.KEY_UP_PRIMARY, 'h') # Navigate to "move left" self.game.handle_key_pressed(KeyValues.DOWN) @@ -332,7 +355,7 @@ class TestGame(unittest.TestCase): self.assertEqual(self.game.settings.KEY_LEFT_PRIMARY, 'a') # Navigate to "texture pack" - for ignored in range(10): + for ignored in range(12): self.game.handle_key_pressed(KeyValues.DOWN) # Change texture pack @@ -379,7 +402,7 @@ class TestGame(unittest.TestCase): def test_dead_screen(self) -> None: """ - Kill player and render dead screen. + Kills the player and renders the dead message on the fake screen. """ self.game.state = GameMode.PLAY # Kill player @@ -395,13 +418,14 @@ class TestGame(unittest.TestCase): def test_not_implemented(self) -> None: """ - Check that some functions are not implemented, only for coverage. + Checks that some functions are not implemented, only for coverage. """ self.assertRaises(NotImplementedError, Display.display, None) + self.assertRaises(NotImplementedError, Display.update, None, self.game) def test_messages(self) -> None: """ - Display error messages. + Displays error messages. """ self.game.message = "I am an error" self.game.display_actions(DisplayActions.UPDATE) @@ -411,7 +435,7 @@ class TestGame(unittest.TestCase): def test_inventory_menu(self) -> None: """ - Open the inventory menu and interact with items. + Opens the inventory menu and interacts with items. """ self.game.state = GameMode.PLAY # Open and close the inventory @@ -472,7 +496,7 @@ class TestGame(unittest.TestCase): def test_talk_to_sunflowers(self) -> None: """ - Interact with sunflowers + Interacts with sunflowers. """ self.game.state = GameMode.PLAY @@ -504,8 +528,8 @@ class TestGame(unittest.TestCase): self.assertEqual(self.game.state, GameMode.PLAY) self.assertTrue(self.game.logs.messages) # Ensure that the message is a good message - self.assertIn(self.game.logs.messages[1][21:], - Sunflower.dialogue_option) + self.assertTrue(any(self.game.logs.messages[1].endswith(msg) + for msg in Sunflower().dialogue_option)) # Test all directions to detect the friendly entity self.game.player.move(sunflower.y + 1, sunflower.x) @@ -523,7 +547,7 @@ class TestGame(unittest.TestCase): def test_talk_to_merchant(self) -> None: """ - Interact with merchants + Interacts with merchants. """ self.game.state = GameMode.PLAY @@ -545,21 +569,28 @@ class TestGame(unittest.TestCase): # Navigate in the menu self.game.handle_key_pressed(KeyValues.DOWN) self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.LEFT) + self.assertFalse(self.game.is_in_store_menu) + self.game.handle_key_pressed(KeyValues.RIGHT) + self.assertTrue(self.game.is_in_store_menu) self.game.handle_key_pressed(KeyValues.UP) self.assertEqual(self.game.store_menu.position, 1) + self.game.player.hazel = 0x7ffff42ff + # The second item is not a heart - merchant.inventory[1] = Sword() + merchant.inventory[1] = sword = Sword() # Buy the second item by clicking on it item = self.game.store_menu.validate() self.assertIn(item, merchant.inventory) - self.game.display_actions(DisplayActions.MOUSE, 8, 25) - self.game.handle_key_pressed(KeyValues.ENTER) + self.game.display_actions(DisplayActions.MOUSE, 7, 25, + curses.BUTTON1_CLICKED) self.assertIn(item, self.game.player.inventory) self.assertNotIn(item, merchant.inventory) # Buy a heart merchant.inventory[1] = Heart() + self.game.display_actions(DisplayActions.REFRESH) item = self.game.store_menu.validate() self.assertIn(item, merchant.inventory) self.assertEqual(item, merchant.inventory[1]) @@ -576,9 +607,169 @@ class TestGame(unittest.TestCase): self.game.handle_key_pressed(KeyValues.ENTER) self.assertNotIn(item, self.game.player.inventory) self.assertIn(item, merchant.inventory) - self.assertEqual(self.game.message, _("You do not have enough money")) + self.assertEqual(self.game.message, + _("The buyer does not have enough money")) self.game.handle_key_pressed(KeyValues.ENTER) + # Sell an item + self.game.inventory_menu.position = len(self.game.player.inventory) - 1 + self.game.handle_key_pressed(KeyValues.LEFT) + self.assertFalse(self.game.is_in_store_menu) + self.assertIn(sword, self.game.player.inventory) + self.assertEqual(self.game.inventory_menu.validate(), sword) + old_player_money, old_merchant_money = self.game.player.hazel,\ + merchant.hazel + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertNotIn(sword, self.game.player.inventory) + self.assertIn(sword, merchant.inventory) + self.assertEqual(self.game.player.hazel, old_player_money + sword.price) + self.assertEqual(merchant.hazel, old_merchant_money - sword.price) + # Exit the menu self.game.handle_key_pressed(KeyValues.SPACE) self.assertEqual(self.game.state, GameMode.PLAY) + + def test_equipment(self) -> None: + """ + Ensure that equipment is working. + """ + self.game.state = GameMode.INVENTORY + + # sword goes into the main equipment slot + sword = Sword() + sword.hold(self.game.player) + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_main, sword) + self.assertFalse(self.game.player.inventory) + + # shield goes into the secondary equipment slot + shield = Shield() + shield.hold(self.game.player) + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_secondary, shield) + self.assertFalse(self.game.player.inventory) + + # helmet goes into the helmet slot + helmet = Helmet() + helmet.hold(self.game.player) + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_helmet, helmet) + self.assertFalse(self.game.player.inventory) + + # helmet goes into the armor slot + chestplate = Chestplate() + chestplate.hold(self.game.player) + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_armor, chestplate) + self.assertFalse(self.game.player.inventory) + + # Use bomb + bomb = Bomb() + bomb.hold(self.game.player) + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_secondary, bomb) + self.assertIn(shield, self.game.player.inventory) + self.game.state = GameMode.PLAY + self.game.handle_key_pressed(KeyValues.USE) + self.assertIsNone(self.game.player.equipped_secondary) + self.game.state = GameMode.INVENTORY + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.equipped_secondary, shield) + self.assertFalse(self.game.player.inventory) + + # Reequip, which is useless but covers code + sword.equip() + shield.equip() + helmet.equip() + chestplate.equip() + self.game.save_state() + + # Unequip all + sword.unequip() + shield.unequip() + helmet.unequip() + chestplate.unequip() + self.assertIsNone(self.game.player.equipped_main) + self.assertIsNone(self.game.player.equipped_secondary) + self.assertIsNone(self.game.player.equipped_helmet) + self.assertIsNone(self.game.player.equipped_armor) + self.assertIn(sword, self.game.player.inventory) + self.assertIn(shield, self.game.player.inventory) + self.assertIn(helmet, self.game.player.inventory) + self.assertIn(chestplate, self.game.player.inventory) + + # Test rings + self.game.player.inventory.clear() + ring = RingCritical() + ring.hold(self.game.player) + old_critical = self.game.player.critical + self.game.handle_key_pressed(KeyValues.EQUIP) + self.assertEqual(self.game.player.critical, + old_critical + ring.critical) + self.game.save_state() + ring.unequip() + + def test_monocle(self) -> None: + """ + The player is wearing a monocle, then the stats are displayed. + """ + self.game.state = GameMode.PLAY + + monocle = Monocle() + monocle.hold(self.game.player) + monocle.equip() + + sea_eagle = GiantSeaEagle() + self.game.map.add_entity(sea_eagle) + sea_eagle.move(2, 6) + + self.game.display_actions(DisplayActions.REFRESH) + + def test_ladders(self) -> None: + """ + Ensure that the player can climb on ladders. + """ + self.game.state = GameMode.PLAY + + self.assertEqual(self.game.player.map.floor, 0) + self.game.handle_key_pressed(KeyValues.LADDER) + self.assertEqual(self.game.player.map.floor, 0) + + # Move nowhere + self.game.player.move(10, 10) + self.game.handle_key_pressed(KeyValues.LADDER) + self.assertEqual(self.game.player.map.floor, 0) + + # Move down + self.game.player.move(3, 40) # Move on a ladder + self.game.handle_key_pressed(KeyValues.LADDER) + self.assertEqual(self.game.map_index, 1) + self.assertEqual(self.game.player.map.floor, 1) + self.assertEqual(self.game.player.y, 1) + self.assertEqual(self.game.player.x, 17) + self.game.display_actions(DisplayActions.UPDATE) + + # Move up + self.game.handle_key_pressed(KeyValues.LADDER) + self.assertEqual(self.game.player.map.floor, 0) + self.assertEqual(self.game.player.y, 3) + self.assertEqual(self.game.player.x, 40) + self.game.display_actions(DisplayActions.UPDATE) + + def test_credits(self) -> None: + """ + Load credits menu. + """ + self.game.state = GameMode.MAINMENU + + self.game.display_actions(DisplayActions.MOUSE, 41, 41, + curses.BUTTON1_CLICKED) + self.assertEqual(self.game.state, GameMode.CREDITS) + self.game.display_actions(DisplayActions.MOUSE, 21, 21, + curses.BUTTON1_CLICKED) + self.game.display_actions(DisplayActions.REFRESH) + + self.game.state = GameMode.CREDITS + self.game.handle_key_pressed(KeyValues.ENTER) + + self.assertEqual(self.game.state, GameMode.MAINMENU) diff --git a/squirrelbattle/tests/interfaces_test.py b/squirrelbattle/tests/interfaces_test.py index c9f7253..df1cbea 100644 --- a/squirrelbattle/tests/interfaces_test.py +++ b/squirrelbattle/tests/interfaces_test.py @@ -4,14 +4,14 @@ import unittest from squirrelbattle.display.texturepack import TexturePack -from squirrelbattle.interfaces import Map, Tile +from squirrelbattle.interfaces import Map, Tile, Slope from squirrelbattle.resources import ResourceManager class TestInterfaces(unittest.TestCase): def test_map(self) -> None: """ - Create a map and check that it is well parsed. + Creates a map and checks that it is well parsed. """ m = Map.load_from_string("0 0\n.#\n#.\n") self.assertEqual(m.width, 2) @@ -20,7 +20,7 @@ class TestInterfaces(unittest.TestCase): def test_load_map(self) -> None: """ - Try to load a map from a file. + Tries to load a map from a file. """ m = Map.load(ResourceManager.get_asset_path("example_map.txt")) self.assertEqual(m.width, 52) @@ -28,7 +28,7 @@ class TestInterfaces(unittest.TestCase): def test_tiles(self) -> None: """ - Test some things about tiles. + Tests some things about tiles. """ self.assertFalse(Tile.FLOOR.is_wall()) self.assertTrue(Tile.WALL.is_wall()) @@ -37,3 +37,21 @@ class TestInterfaces(unittest.TestCase): self.assertFalse(Tile.WALL.can_walk()) self.assertFalse(Tile.EMPTY.can_walk()) self.assertRaises(ValueError, Tile.from_ascii_char, 'unknown') + + def test_slope(self) -> None: + """ + Test good behaviour of slopes (basically vectors, compared according to + the determinant) + """ + a = Slope(1, 1) + b = Slope(0, 1) + self.assertTrue(b < a) + self.assertTrue(b <= a) + self.assertTrue(a <= a) + self.assertTrue(a == a) + self.assertTrue(a > b) + self.assertTrue(a >= b) + + # def test_visibility(self) -> None: + # m = Map.load(ResourceManager.get_asset_path("example_map_3.txt")) + # m.compute_visibility(1, 1, 50) diff --git a/squirrelbattle/tests/screen.py b/squirrelbattle/tests/screen.py index 9a8afe6..57b7dcc 100644 --- a/squirrelbattle/tests/screen.py +++ b/squirrelbattle/tests/screen.py @@ -12,8 +12,8 @@ class FakePad: def addstr(self, y: int, x: int, message: str, color: int = 0) -> None: pass - def refresh(self, pminrow: int, pmincol: int, sminrow: int, - smincol: int, smaxrow: int, smaxcol: int) -> None: + def noutrefresh(self, pminrow: int, pmincol: int, sminrow: int, + smincol: int, smaxrow: int, smaxcol: int) -> None: pass def erase(self) -> None: @@ -24,3 +24,6 @@ class FakePad: def getmaxyx(self) -> Tuple[int, int]: return 42, 42 + + def inch(self, y: int, x: int) -> str: + return "i" diff --git a/squirrelbattle/tests/settings_test.py b/squirrelbattle/tests/settings_test.py index 06225b2..65cb25a 100644 --- a/squirrelbattle/tests/settings_test.py +++ b/squirrelbattle/tests/settings_test.py @@ -13,7 +13,7 @@ class TestSettings(unittest.TestCase): def test_settings(self) -> None: """ - Ensure that settings are well loaded. + Ensures that settings are well loaded. """ settings = Settings() self.assertEqual(settings.KEY_UP_PRIMARY, 'z') diff --git a/squirrelbattle/tests/translations_test.py b/squirrelbattle/tests/translations_test.py index 8176fd4..bdf0238 100644 --- a/squirrelbattle/tests/translations_test.py +++ b/squirrelbattle/tests/translations_test.py @@ -11,7 +11,7 @@ class TestTranslations(unittest.TestCase): def test_main_menu_translation(self) -> None: """ - Ensure that the main menu is translated. + Ensures that the main menu is translated. """ self.assertEqual(_("New game"), "Nouvelle partie") self.assertEqual(_("Resume"), "Continuer") @@ -22,7 +22,7 @@ class TestTranslations(unittest.TestCase): def test_settings_menu_translation(self) -> None: """ - Ensure that the settings menu is translated. + Ensures that the settings menu is translated. """ self.assertEqual(_("Main key to move up"), "Touche principale pour aller vers le haut") @@ -52,18 +52,33 @@ class TestTranslations(unittest.TestCase): "Touche pour jeter un objet de l'inventaire") self.assertEqual(_("Key used to talk to a friendly entity"), "Touche pour parler à une entité pacifique") + self.assertEqual(_("Key used to wait"), "Touche pour attendre") + self.assertEqual(_("Key used to use ladders"), + "Touche pour utiliser les échelles") 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(_("merchant"), "marchand") self.assertEqual(_("rabbit"), "lapin") + self.assertEqual(_("sunflower"), "tournesol") self.assertEqual(_("teddy bear"), "nounours") + self.assertEqual(_("tiger"), "tigre") + self.assertEqual(_("eagle"), "pygargue") self.assertEqual(_("body snatch potion"), "potion d'arrachage de corps") self.assertEqual(_("bomb"), "bombe") + self.assertEqual(_("explosion"), "explosion") self.assertEqual(_("heart"), "cœur") self.assertEqual(_("sword"), "épée") + self.assertEqual(_("helmet"), "casque") + self.assertEqual(_("chestplate"), "plastron") + self.assertEqual(_("shield"), "bouclier") + self.assertEqual(_("ring of critical damage"), + "anneau de coup critique") + self.assertEqual(_("ring of more experience"), + "anneau de plus d'expérience") + self.assertEqual(_("monocle"), "monocle") diff --git a/squirrelbattle/translations.py b/squirrelbattle/translations.py index 08d40d1..df140a2 100644 --- a/squirrelbattle/translations.py +++ b/squirrelbattle/translations.py @@ -13,7 +13,7 @@ class Translator: """ This module uses gettext to translate strings. Translator.setlocale defines the language of the strings, - then gettext() translates the message. + then gettext() translates the messages. """ SUPPORTED_LOCALES: List[str] = ["de", "en", "es", "fr"] locale: str = "en" @@ -22,7 +22,7 @@ class Translator: @classmethod def refresh_translations(cls) -> None: """ - Load compiled translations. + Loads compiled translations. """ for language in cls.SUPPORTED_LOCALES: rep = Path(__file__).parent / "locale" / language / "LC_MESSAGES" @@ -37,7 +37,7 @@ class Translator: @classmethod def setlocale(cls, lang: str) -> None: """ - Define the language used to translate the game. + Defines the language used to translate the game. The language must be supported, otherwise nothing is done. """ lang = lang[:2] @@ -51,7 +51,7 @@ class Translator: @classmethod def makemessages(cls) -> None: # pragma: no cover """ - Analyse all strings in the project and extract them. + Analyses all strings in the project and extracts them. """ for language in cls.SUPPORTED_LOCALES: if language == "en": @@ -83,7 +83,7 @@ class Translator: @classmethod def compilemessages(cls) -> None: """ - Compile translation messages from source files. + Compiles translation messages from source files. """ for language in cls.SUPPORTED_LOCALES: if language == "en": @@ -99,7 +99,7 @@ class Translator: def gettext(message: str) -> str: """ - Translate a message. + Translates a message. """ return Translator.get_translator().gettext(message)