diff --git a/.gitignore b/.gitignore index f8b73e7..0e0ad79 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,9 @@ dmypy.json # Cython debug symbols cython_debug/ + +*_creations/ +*.exe +*.zip +*.csv +*.txt diff --git a/README.md b/README.md index eddbf3d..de66104 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ -# Devinddessin +# Devine-dessin +Jeu permettant de dessiner un mot choisi au hasard dans une liste. + +Tant que le jeu n'est pas quitté, les mots de la liste ne sont pas proposé à nouveau. + +## Liste de mot + +La liste de mot peut être au format texte brut, avec un mot par ligne, ou au format CSV. + +Si la liste est au format CSV, la première colonne doit contenir le mot à dessiner, et la seconde colonne peut contenir un indice. +Les colonnes du fichier CSV ne doivent pas avoir de titre. + +Par défaut, le jeu cherche une liste dans un fichier du dossier courant nommé `mots.csv`. + +## Installation + +Le jeu n'est pour l'instant pas packagé. +Pour le lancer: + +- Construire une liste de mot comme présenté plus haut +- Créer un venv : `python -m venv ` +- Activer le venv : `. /bin/activate` +- Installer les dépendances : `pip install -r requirements.txt` +- Récupérer le fichier `guess_what_I_draw.py` +- Lancer le jeu en exécutant le fichier. + + +## Contrôles: + +### Tout le temps: +- **`Échap` :** Quitter le jeu immédiatement + +### Quand le jeu n'est pas lancé: +- **`c`:** Ouvrir le menu de configuration +- **`Espace` :** Lancer la manche + +### Quand le jeu est en cours: +- **Clic gauche** dans la zone de dessin : dessiner +- **`Ctrl + z` :** Annuler le dernier trait +- **`Entrer` :** Passer le mot en cours (une pénalité de temps est appliquée) +- **`Espace` :** Valider le mot en cours comme "trouvé" +- **`p` :** Activer/Désactiver la pause. + +### Quand la manche est écoulée: +Le menu de récapitulation est affiché. +- **`Backspace` :** Passer au tour suivant + +## Configuration +Dans le menu de configuration, il est possible de configurer : +- Le chemin vers la liste de mot ; +- La durée d'une manche ; +- Le temps de pénalité appliqué pour chaque mot passé. + +Une fois la configuration effectuée, vous pouvez l'appliquer en cliquant en dehors de la pop-up du menu + + +## Dessins +Les dessins sont sauvegardés pour la postérité dans un dossier créé dans le répertoire courant, nommé `T_creations`. + +Le nom des fichiers de dessin sont au format `round-_.png`, avec `i` le numéro de la manche. diff --git a/guess_what_I_draw.py b/guess_what_I_draw.py new file mode 100755 index 0000000..2ec99fa --- /dev/null +++ b/guess_what_I_draw.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +import re +import os +import csv +from random import random, randint, choice +from datetime import timedelta, datetime +from kivy.app import App +from kivy.uix.widget import Widget +from kivy.uix.button import Button, ButtonBehavior +from kivy.uix.label import Label +from kivy.graphics import Color, Ellipse, Line, Rectangle +from kivy.core.window import Window +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.clock import Clock +from kivy.uix.popup import Popup +from kivy.uix.colorpicker import ColorPicker +from kivy.uix.scrollview import ScrollView +from kivy.properties import StringProperty, BooleanProperty +from kivy.lang import Builder +from kivy.uix.textinput import TextInput + +PASS_PENALTY = 4 +DRAWING_TIME = 60 +WORDS_FILE = "mots.csv" + +Builder.load_string(''' +: + Label: + size_hint_y: None + height: self.texture_size[1] + text_size: self.width, None + text: root.text + markup: root.markup +''') + +class ScrollableLabel(ScrollView): + text = StringProperty('') + markup = BooleanProperty(False) + + +class NumberInput(TextInput): + def __init__(self, init_value, **kwargs): + self.init_value = init_value + kwargs["text"] = str(init_value) + super().__init__(**kwargs) + + pat = r"^[0-9]+$" + def insert_text(self, substring, from_undo=False): + if not re.match(self.pat, substring): + s='' + else: + s = substring + super().insert_text(s, from_undo=from_undo) + +class MyPaintWidget(Label): + _game_state = {"game_started": False, + "game_paused": False, + "game_finished": False, + "word passed": timedelta(0), + "word found": timedelta(0) + } + _game_wordlists = {"possible_wordlist": dict(), + "found_words": set(), + "unfound_words": set() + } + pen_characteristics = {"current_color": (0,0,0,1), + "current_width": 4 + } + bg = None + current_round = 0 + current_word = "" + message_label = None + time = timedelta(seconds=DRAWING_TIME) + _popup = None + + def __init__(self, text="", **kwargs): + super().__init__(**kwargs) + self._keyboard = None + + self._load_words() + with self.canvas.before: + Color(1,1,1, mode='rgb') + self.bg = Rectangle(pos=self.pos, size=self.size) + def resize_bg(self, obj): + self.bg.size = self.size + self.bg.pos = self.pos + self.bind(size=resize_bg, pos=resize_bg) + + self._get_keyboard() + self._output_dir = datetime.now().isoformat(timespec='minutes').replace(":","-") + "_creations" + self._fullscreen = False + self.reset_game() + + def _load_words(self): + try: + if WORDS_FILE.endswith(".csv"): + # read a CSV file + with open(WORDS_FILE, "r", newline='', encoding="utf-8") as wordfile: + my_reader = csv.reader(wordfile) + self._game_wordlists["possible_wordlist"] = {line[0]: + line[1] for line in my_reader} + else: + # read a plain text file + with open(WORDS_FILE, "r", encoding="utf-8") as wordfile: + self._wordlist = (word.strip() for word in wordfile.readlines()) + self._game_wordlists["possible_wordlist"] = dict.fromkeys(self._wordlist) + except OSError: + errmsg = f"Erreur de lecture du fichier '{WORDS_FILE}'" + if self.bg: + Popup(title="Error", content=Label(text=errmsg)).open() + else: + print(errmsg) + self._game_wordlists["possible_wordlist"] = dict() + + + def _get_keyboard(self): + if self._keyboard is None: + self._keyboard = Window.request_keyboard( + self._keyboard_closed, self, 'text') + if self._keyboard.widget: + # If it exists, this widget is a VKeyboard object which you can use + # to change the keyboard layout. + pass + self._keyboard.bind(on_key_down=self._on_keyboard_down) + + def _keyboard_closed(self): + print('My keyboard has been closed!') + self._keyboard.unbind(on_key_down=self._on_keyboard_down) + self._keyboard = None + + def _on_keyboard_down(self, keyboard, keycode, text, modifiers): + print('The key', keycode, 'have been pressed') + print(' - text is %r' % text) + print(' - modifiers are %r' % modifiers) + if not self._game_state["game_started"]: + if keycode[0] == 32: + self.start_game() + elif keycode[1] == 'c': + self.open_config() + + elif keycode[1] == 'p': + self.toggle_pause() + + elif self.is_game_playing: + if keycode[0] == 32: + self.next_word() + elif keycode[0] == 13: + self.pass_word() + elif keycode[1] == 'z' and "ctrl" in modifiers: + if self.canvas.after.children: + self.canvas.after.remove(self.canvas.after.children[-1]) + self.canvas.after.remove(self.canvas.after.children[-1]) + self.canvas.after.remove(self.canvas.after.children[-1]) + elif self._game_state["game_finished"]: + if keycode[0] == 8: + self.reset_game() + + if keycode[0] == 292: + print(Window.fullscreen) + if self._fullscreen: + Window.fullscreen = False + else: + Window.fullscreen = "auto" + self._fullscreen = not self._fullscreen + + # Keycode is composed of an integer + a string + # If we hit escape, let the input through to leave the app + if keycode[1] == 'escape': + return False + + # Return True to accept the key. Otherwise, it will be used by + # the system. + return True + + def toggle_pause(self): + self._game_state["game_paused"] = not self._game_state["game_paused"] + + def start_game(self): + self.text="" + self._game_state["game_started"] = True + self._game_state["game_paused"] = False + self.current_round += 1 + self.next_word(True) + + def next_word(self, passed=False): + if not passed: + self._game_wordlists["found_words"].add(self.current_word) + if self.message_label and self.current_word: + if passed: + self.message_label.color = '#FF2A40' + self.message_label.text = f'"{self.current_word}" a été passé !' + else: + self.message_label.color = '#5BB834' + self.message_label.text = f'"{self.current_word}" a été trouvé !' + if not self._game_wordlists["possible_wordlist"]: + self.time = timedelta(0) + return + self.save_frame() + self.current_word = choice(list(self._game_wordlists["possible_wordlist"])) + value = self._game_wordlists["possible_wordlist"].pop(self.current_word) + if value: + self.message_label.text += '\n'+f'Le prochain mot est de type: {value}' + + self.clear_drawing() + + def pass_word(self): + self._game_wordlists["unfound_words"].add(self.current_word) + self.next_word(True) + if self.time - timedelta(seconds=PASS_PENALTY) > timedelta(0): + self.time -= timedelta(seconds=PASS_PENALTY) + else: + self.time = timedelta(0) + + def save_frame(self): + if self.current_word: + if not os.path.isdir(self._output_dir): + os.mkdir(self._output_dir) + filename = os.path.join(self._output_dir, + f"round-{self.current_round}_" + os.path.normcase(self.current_word) + ".png") + self.export_to_png(filename) + + @property + def is_game_playing(self): + return (self._game_state["game_started"] + and not self._game_state["game_paused"] + and not self._game_state["game_finished"]) + + @property + def current_color(self): + return self.pen_characteristics["current_color"] + + def on_touch_down(self, touch): + if self.is_game_playing: + with self.canvas.after: + if self.collide_point(touch.x, touch.y): + Color(*self.pen_characteristics["current_color"], mode='rgba') + diameter = self.pen_characteristics["current_width"] + Ellipse(pos=(touch.x - diameter / 2, touch.y - diameter / 2), + size=(2*diameter, 2*diameter)) + touch.ud['line'] = Line(points=(touch.x, touch.y), width=diameter) + + + + def on_touch_move(self, touch): + if self.is_game_playing: + if 'line' in touch.ud: + if self.collide_point(touch.x, touch.y): + touch.ud['line'].points += [touch.x, touch.y] + elif self.collide_point(touch.ud['line'].points[-2], touch.y): + touch.ud['line'].points += [touch.ud['line'].points[-2], touch.y] + elif self.collide_point(touch.x, touch.ud['line'].points[-1]): + touch.ud['line'].points += [touch.x, touch.ud['line'].points[-1]] + + def open_config(self): + layout = GridLayout() + layout.cols=2 + layout.add_widget(Label(text="Chemin vers la liste de mots")) + layout.add_widget(TextInput(text=WORDS_FILE, multiline=False)) + layout.add_widget(Label(text="Temps de dessin\n(secondes")) + layout.add_widget(NumberInput(DRAWING_TIME, multiline=False)) + layout.add_widget(Label(text="Temps de pénalité\nquand on passe\n(secondes)")) + layout.add_widget(NumberInput(PASS_PENALTY, multiline=False)) + conf_popup = Popup(title='Configuration', content=layout, size_hint=(0.5, 0.5)) + conf_popup.bind(on_dismiss=self.apply_conf) + conf_popup.open() + + def apply_conf(self, obj): + global DRAWING_TIME + global PASS_PENALTY + global WORDS_FILE + try: + drawing_time = int(obj.content.children[2].text) + if drawing_time > 0: + DRAWING_TIME = drawing_time + except ValueError: + Popup(title="Error", content=Label(text="Mauvaise valeur pour temps de dessin")).open() + try: + pass_penalty = int(obj.content.children[0].text) + if pass_penalty > 0 and pass_penalty < drawing_time: + PASS_PENALTY = pass_penalty + except ValueError: + Popup(title="Error", content=Label(text="Mauvaise valeur pour pénalité")).open() + self.reset_game() + if obj.content.children[4].text != WORDS_FILE: + old_file = WORDS_FILE + WORDS_FILE = obj.content.children[4].text + try: + self._load_words() + except OSError: + Popup(title="Error", content=Label(text=f"Erreur de lecture du fichier '{WORDS_FILE}'")).open() + WORDS_FILE = old_file + self._get_keyboard() + + + + def recap(self): + found_words = self._game_wordlists['found_words'] + unfound_words =self._game_wordlists['unfound_words'] + lines = ["[b]Tour terminé ![/b]\n"+ + f"Trouvés: [color=#5BB834]{len(found_words)}[/color]", + "-"+ "\n-".join(found_words) if found_words else "", + f"Passés: [color=#FF2A40]{len(unfound_words)}[/color]", + "- "+ "\n- ".join(unfound_words) if unfound_words else ""] + self.save_frame() + self.clear_drawing() + self._popup = Popup(title='Fini !', + content=ScrollableLabel(text="[size=24]"+"\n".join(lines)+"[/size]", + markup=True), + size_hint=(0.6,0.6), + auto_dismiss=False) + self._popup.open() + + def game_finished(self): + self._game_state["game_finished"] = True + self.recap() + + def reset_game(self): + if self._popup: + self._popup.dismiss() + if self.message_label: + self.message_label.text = "" + self.color = (0,0,0,1) + self.text = "Appuyer sur 'espace' pour commencer à jouer" + self.current_word = "" + self._game_wordlists["found_words"] = set() + self._game_wordlists["unfound_words"] = set() + + self.time = timedelta(seconds=DRAWING_TIME) + self._game_state["game_finished"] = False + self._game_state["game_started"] = False + + def clear_drawing(self): + self.canvas.after.clear() + +class SizeButton(ButtonBehavior, Widget): + def __init__(self, callback, size=1, **kwargs): + super().__init__(**kwargs) + self.dot_size = size + self.callback = callback + with self.canvas: + Color(0,0,0, mode="rgb") + self.bg = Rectangle(pos=self.pos, size=self.size) + Color(1,1,1, mode="rgb") + self.dot = Ellipse(pos=(self.center_x-size/2, self.center_y-size/2), + size = (size*2, size*2)) + def redraw(self, args): + self.dot.pos = (self.center_x-size/2, self.center_y-size/2) + self.bg.pos = self.pos + self.bg.size = self.size + self.bind(size=redraw, pos=redraw) + + def on_release(self): + self.callback(self.dot_size) + +class GuessWhatIDrawApp(App): + state = False + init = True + + def build(self): + Window.clearcolor = (1, 1, 1, 1) + #self.init_keyboard() + parent = BoxLayout(orientation='vertical') + + self.painter = MyPaintWidget(size_hint=(0.9, 1)) + Clock.schedule_interval(self.update_time, 1.0/10.0) + clearbtn = Button(text='Clear') + clearbtn.bind(on_release=self.clear_canvas) + togglebtn = Button(text='Inverser la couleur du fond') + togglebtn.bind(on_release=self.toggle_background) + + self.right_row = BoxLayout(orientation="vertical", size_hint=(0.1,1)) + color_picker_button = Button(text="Couleur") + color_picker_button.background_color = self.painter.pen_characteristics["current_color"] + color_picker_button.background_normal = '' + color_picker_button.bind(on_release=self.open_color_picker) + self.right_row.add_widget(color_picker_button) + for i in range(1,6): + size_button = SizeButton(self.set_cursor_size, 2*i) + self.right_row.add_widget(size_button) + + middle_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.75)) + middle_row.add_widget(self.painter) + middle_row.add_widget(self.right_row) + + bottom_row = BoxLayout(orientation='horizontal', spacing=5, size_hint=(1, .05)) + bottom_row.add_widget(togglebtn) + bottom_row.add_widget(clearbtn) + + top_row = BoxLayout(orientation='horizontal', spacing=5, size_hint=(1, .2)) + self.word_label = Label(text="Mot:", color=[0,0,0,1], halign="left", valign="top", size_hint=(0.2,1)) + def redraw(self, obj): + self.text_size = self.size + self.word_label.bind(size=redraw) + + self.time_label = Label(text=f"{self.painter.time}", color=[0,0,0,1], + font_size='50pt', size_hint=(0.2,1)) + message_label = Label(color=[0,0,0,1], halign="center", valign="top", + font_size='40pt', size_hint=(0.6,1)) + message_label.bind(size=redraw) + top_row.add_widget(self.word_label) + top_row.add_widget(message_label) + top_row.add_widget(self.time_label) + self.painter.message_label = message_label + + parent.add_widget(top_row) + parent.add_widget(middle_row) + parent.add_widget(bottom_row) + return parent + + def set_cursor_size(self, size): + print(size) + self.painter.pen_characteristics["current_width"] = size + + def open_color_picker(self, _): + clr_picker = ColorPicker() + clr_picker.color = self.painter.pen_characteristics["current_color"] + def on_color_change(instance, value): + self.painter.pen_characteristics["current_color"] = value + self.right_row.children[-1].background_color = self.painter.current_color + clr_picker.bind(color=on_color_change) + popup = Popup(title="Couleur du pinceau", + content=clr_picker, + size_hint=(0.5,0.5)) + popup.open() + + def update_time(self, _): + if self.painter.is_game_playing: + if self.painter.time > timedelta(0): + self.painter.time -= timedelta(milliseconds=100) + self.word_label.text = f"Mot: {self.painter.current_word}" + else: + self.painter.game_finished() + + self.time_label.text = f"{formatTimedelta(self.painter.time)}" + + def toggle_background(self, obj): + + if self.state: + Window.clearcolor = (1, 1, 1, 1) + with self.painter.canvas.before: + Color(1,1,1, mode='rgb') + self.painter.bg = Rectangle(pos=self.painter.pos, size=self.painter.size) + self.painter.pen_characteristics["current_color"] = (0,0,0,1) + self.right_row.children[-1].background_color = self.painter.current_color + self.word_label.color = (0,0,0,1) + self.time_label.color = (0,0,0,1) + self.state = False + else: + Window.clearcolor = (0,0,0,1) + with self.painter.canvas.before: + Color(0,0,0, mode='rgb') + self.painter.bg = Rectangle(pos=self.painter.pos, size=self.painter.size) + self.painter.pen_characteristics["current_color"] = (1,1,1,1) + self.right_row.children[-1].background_color = self.painter.current_color + self.word_label.color = (1,1,1,1) + self.time_label.color = (1,1,1,1) + self.state = True + self.clear_canvas(obj) + + def clear_canvas(self, _): + self.painter.clear_drawing() + +def formatTimedelta(time:timedelta): + seconds = int(time.total_seconds()) + minutes, reminder = divmod(seconds, 60) + + return f"{minutes:02}:{reminder:02}.{int(time.microseconds/10000):02}" + + + +if __name__ == '__main__': + GuessWhatIDrawApp().run() diff --git a/pictionnary.ico b/pictionnary.ico new file mode 100644 index 0000000..160cae6 Binary files /dev/null and b/pictionnary.ico differ