diff --git a/docs/testing/.gitkeep b/assets/audio/dictations/.gitkeep similarity index 100% rename from docs/testing/.gitkeep rename to assets/audio/dictations/.gitkeep diff --git a/autoload/audio_manager.gd b/autoload/audio_manager.gd index 524aaa4..be51477 100644 --- a/autoload/audio_manager.gd +++ b/autoload/audio_manager.gd @@ -1,3 +1,26 @@ extends Node -## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio provisoire des dictees. -## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction). +## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio +## provisoire des dictees. Aucun fichier audio n'est encore fourni (etape +## "Graphismes et audio provisoires", a venir) : play_dictation se degrade +## silencieusement (avertissement journalise) si le fichier est absent, +## plutot que de faire planter le jeu. + +const DICTATION_AUDIO_DIR := "res://assets/audio/dictations" + +var _player: AudioStreamPlayer + + +func _ready() -> void: + _player = AudioStreamPlayer.new() + add_child(_player) + + +func play_dictation(audio_id: String) -> void: + if audio_id.is_empty(): + return + var path := "%s/%s.ogg" % [DICTATION_AUDIO_DIR, audio_id] + if not ResourceLoader.exists(path): + push_warning("Audio de dictee introuvable (pas encore genere) : %s" % path) + return + _player.stream = load(path) + _player.play() diff --git a/autoload/exercise_engine.gd b/autoload/exercise_engine.gd index 87f35b2..0f6bf4a 100644 --- a/autoload/exercise_engine.gd +++ b/autoload/exercise_engine.gd @@ -1,3 +1,133 @@ extends Node ## Chargement et deroulement des exercices a partir des donnees JSON validees. -## Logique detaillee a partir de l'etape "Donnees" (voir Ordre de construction). +## Valide chaque fichier au chargement avec le meme validateur que +## `make validate-data` (DataLoader/SchemaValidator, scripts/data/) : un +## fichier invalide est ignore (avec une erreur journalisee), jamais charge +## tel quel. Le tirage au sort d'un exercice est delegue a ExercisePicker, +## pur, avec un generateur aleatoire possede ici (voir CLAUDE.md : le hasard +## est injecte dans la logique pure, pas lu directement par elle). + +var _rules: Dictionary = {} +var _exercises_by_rule_and_type: Dictionary = {} +var _dictations_by_rule: Dictionary = {} +var _missions: Dictionary = {} +var _mission_order: Array[String] = [] +var _rng := RandomNumberGenerator.new() + + +func _ready() -> void: + _rng.randomize() + _load_rules() + _load_exercises() + _load_dictations() + _load_missions() + + +func get_rule(rule_id: String) -> Dictionary: + return _rules.get(rule_id, {}) + + +func get_missions() -> Array[Dictionary]: + var missions: Array[Dictionary] = [] + for mission_id in _mission_order: + missions.append(_missions[mission_id]) + return missions + + +func get_mission(mission_id: String) -> Dictionary: + return _missions.get(mission_id, {}) + + +## Tire le prochain exercice (ou dictee) pour une mission, au plus proche de +## `difficulty`, sans redonner `exclude_id`. Dictionnaire vide si la mission +## n'a aucun contenu disponible. +func pick_exercise_for_mission( + mission: Dictionary, exclude_id: String, difficulty: int +) -> Dictionary: + var rule_id: String = mission["rule_id"] + var exercise_type: String = mission["exercise_type"] + var candidates: Array[Dictionary] = _candidates_for(rule_id, exercise_type) + var filtered := _filter_by_difficulty(candidates, difficulty) + var picked: Variant = ExercisePicker.pick(filtered, exclude_id, _rng) + return picked if picked != null else {} + + +## Ne garde que les candidats a la difficulte demandee ; si aucun ne la +## propose, retombe sur l'ensemble des candidats plutot que de n'en donner +## aucun (le contenu disponible est encore limite, voir docs/specs/decisions.md). +func _filter_by_difficulty(candidates: Array[Dictionary], difficulty: int) -> Array[Dictionary]: + var exact: Array[Dictionary] = [] + for candidate in candidates: + if int(candidate.get("difficulty", difficulty)) == difficulty: + exact.append(candidate) + return exact if not exact.is_empty() else candidates + + +func _candidates_for(rule_id: String, exercise_type: String) -> Array[Dictionary]: + if exercise_type == "dictation": + return _dictations_by_rule.get(rule_id, [] as Array[Dictionary]) + var by_type: Dictionary = _exercises_by_rule_and_type.get(rule_id, {}) + return by_type.get(exercise_type, [] as Array[Dictionary]) + + +func _load_rules() -> void: + for path in DataLoader.find_json_files("res://data/rules"): + var errors := DataLoader.validate_file(path, "rule") + if not errors.is_empty(): + push_error("Regle ignoree (%s) : %s" % [path, errors]) + continue + var rule: Dictionary = DataLoader.read_json(path) + _rules[rule["rule_id"]] = rule + + +func _load_exercises() -> void: + for path in DataLoader.find_json_files("res://data/exercises"): + var raw: Variant = DataLoader.read_json(path) + if typeof(raw) != TYPE_DICTIONARY or not raw.has("type"): + push_error("Exercice ignore (%s) : type manquant ou JSON invalide" % path) + continue + var exercise: Dictionary = raw + var exercise_type: String = exercise["type"] + var schema_name := DataLoader.exercise_schema_name_for_type(exercise_type) + if schema_name.is_empty(): + push_error("Exercice ignore (%s) : type inconnu '%s'" % [path, exercise_type]) + continue + var errors := SchemaValidator.validate(exercise, DataLoader.load_schema(schema_name)) + if not errors.is_empty(): + push_error("Exercice ignore (%s) : %s" % [path, errors]) + continue + + var rule_id: String = exercise["rule_id"] + if not _exercises_by_rule_and_type.has(rule_id): + _exercises_by_rule_and_type[rule_id] = {} + var by_type: Dictionary = _exercises_by_rule_and_type[rule_id] + if not by_type.has(exercise_type): + by_type[exercise_type] = [] as Array[Dictionary] + (by_type[exercise_type] as Array[Dictionary]).append(exercise) + + +func _load_dictations() -> void: + for path in DataLoader.find_json_files("res://data/dictations"): + var errors := DataLoader.validate_file(path, "dictation") + if not errors.is_empty(): + push_error("Dictee ignoree (%s) : %s" % [path, errors]) + continue + var dictation: Dictionary = DataLoader.read_json(path) + for rule_id: String in dictation["rule_ids"]: + if not _dictations_by_rule.has(rule_id): + _dictations_by_rule[rule_id] = [] as Array[Dictionary] + (_dictations_by_rule[rule_id] as Array[Dictionary]).append(dictation) + + +func _load_missions() -> void: + var mission_ids: Array[String] = [] + for path in DataLoader.find_json_files("res://data/missions"): + var errors := DataLoader.validate_file(path, "mission") + if not errors.is_empty(): + push_error("Mission ignoree (%s) : %s" % [path, errors]) + continue + var mission: Dictionary = DataLoader.read_json(path) + _missions[mission["mission_id"]] = mission + mission_ids.append(mission["mission_id"]) + mission_ids.sort() + _mission_order = mission_ids diff --git a/autoload/game_manager.gd b/autoload/game_manager.gd index 6d44d82..dcd221d 100644 --- a/autoload/game_manager.gd +++ b/autoload/game_manager.gd @@ -1,3 +1,23 @@ extends Node -## Cycle de vie de l'application, navigation entre scenes et etat general du jeu. -## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction). +## Cycle de vie de l'application, navigation entre scenes et etat general du +## jeu. Porte le contexte transitoire necessaire entre deux changements de +## scene (ex. la mission choisie sur la carte), puisque Godot libere les +## noeuds de la scene precedente a chaque changement. + +const SCENE_BOOT := "res://scenes/boot/boot.tscn" +const SCENE_PROFILE_SELECT := "res://scenes/profiles/profile_select.tscn" +const SCENE_PROFILE_CREATE := "res://scenes/profiles/profile_create.tscn" +const SCENE_WORLD_MAP := "res://scenes/world/world_map.tscn" +const SCENE_MISSION_INTRO := "res://scenes/world/mission_intro.tscn" +const SCENE_EXERCISE_HOST := "res://scenes/exercises/exercise_host.tscn" +const SCENE_REWARD := "res://scenes/ui/reward_screen.tscn" + +## Mission choisie sur la carte, lue par mission_intro puis exercise_host et +## reward_screen. Remise a vide par reward_screen au retour sur la carte. +var pending_mission_id: String = "" + + +func goto(scene_path: String) -> void: + # Differe le changement de scene : l'appeler depuis le _ready() de la + # scene en cours d'ajout a l'arbre echoue sinon ("Parent node is busy"). + get_tree().change_scene_to_file.call_deferred(scene_path) diff --git a/autoload/profile_manager.gd b/autoload/profile_manager.gd index 3e6d0ab..b5fce37 100644 --- a/autoload/profile_manager.gd +++ b/autoload/profile_manager.gd @@ -31,5 +31,22 @@ func list_profiles() -> Array[String]: return SaveManager.list_profile_ids() +## Resume de chaque profil pour l'ecran de selection : profile_id, display_name +## et school_level, sans charger toute la progression. +func list_profile_summaries() -> Array[Dictionary]: + var summaries: Array[Dictionary] = [] + for profile_id in list_profiles(): + var data: Variant = SaveManager.load(profile_id) + if data == null: + continue + var summary := { + "profile_id": profile_id, + "display_name": data["display_name"], + "school_level": data["school_level"], + } + summaries.append(summary) + return summaries + + func _generate_profile_id() -> String: return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000] diff --git a/autoload/progress_manager.gd b/autoload/progress_manager.gd index 1f25185..8a623e9 100644 --- a/autoload/progress_manager.gd +++ b/autoload/progress_manager.gd @@ -37,6 +37,14 @@ func unlock_region(region_id: String) -> void: _current_save = ProgressUpdates.unlock_region(_current_save, region_id) +func add_stars(amount: int) -> void: + _current_save = ProgressUpdates.add_stars(_current_save, amount) + + +func stars() -> int: + return _current_save.get("rewards", {}).get("stars", 0) + + func next_difficulty_for_skill(skill_id: String, current_difficulty: int) -> int: return ProgressUpdates.next_difficulty_for_skill(_current_save, skill_id, current_difficulty) diff --git a/data/localization/fr.json b/data/localization/fr.json new file mode 100644 index 0000000..131225c --- /dev/null +++ b/data/localization/fr.json @@ -0,0 +1,46 @@ +{ + "profile_select.title": "Choisis ton profil", + "profile_select.new_profile_button": "Nouveau profil", + "profile_select.delete_button": "Supprimer", + "profile_select.delete_confirm_title": "Supprimer ce profil ?", + "profile_select.delete_confirm_message": "Cette action efface pour toujours la progression de ce profil.", + "profile_select.delete_confirm_yes": "Oui, supprimer", + "profile_select.delete_confirm_no": "Non, annuler", + "profile_select.empty_state": "Pas encore de profil : crée le premier !", + "profile_create.title": "Crée ton profil", + "profile_create.name_label": "Comment t'appelles-tu ?", + "profile_create.name_placeholder": "Ton prénom ou ton pseudo", + "profile_create.level_label": "Tu es en...", + "profile_create.level_ce1": "CE1", + "profile_create.level_ce2": "CE2", + "profile_create.confirm_button": "Commencer l'aventure", + "profile_create.cancel_button": "Retour", + "profile_create.name_required_error": "Choisis un prénom avant de continuer.", + "world_map.title": "La forêt des accords", + "world_map.status_decouverte": "À découvrir", + "world_map.status_en_acquisition": "En entraînement", + "world_map.status_en_consolidation": "En progrès", + "world_map.status_acquise": "Réussie", + "world_map.status_a_reviser": "À réviser", + "mission_intro.start_button": "Commencer la mission", + "mission_intro.back_button": "Retour à la carte", + "rule_panel.title": "La règle à retenir", + "rule_panel.example_label": "Par exemple :", + "rule_panel.continue_button": "C'est parti !", + "exercise_host.validate_button": "Valider", + "exercise_host.continue_button": "Continuer", + "exercise_host.finish_mission_button": "Terminer la mission", + "feedback_panel.correct_title": "Bravo !", + "feedback_panel.incorrect_title": "Pas tout à fait", + "feedback_panel.hint_label": "Indice :", + "feedback_panel.explanation_label": "Pourquoi ?", + "feedback_panel.correction_label": "La bonne réponse :", + "sentence_correction.correction_placeholder": "Corrige ce mot", + "dictation.instruction_label": "Écris la phrase que tu entends :", + "dictation.input_placeholder": "Écris ici...", + "dictation.replay_button": "Réécouter", + "dictation.provisional_audio_badge": "Voix provisoire", + "reward_screen.title": "Mission réussie !", + "reward_screen.stars_label": "Étoiles gagnées :", + "reward_screen.continue_button": "Retour à la carte" +} diff --git a/data/missions/mission_01_pre_des_mots.json b/data/missions/mission_01_pre_des_mots.json new file mode 100644 index 0000000..eda4174 --- /dev/null +++ b/data/missions/mission_01_pre_des_mots.json @@ -0,0 +1,9 @@ +{ + "mission_id": "mission_01_pre_des_mots", + "title": "Le pré des petits mots", + "narrative_context": "Les lutins du pré n'arrivent plus à accorder leurs mots entre eux. Aide-les à retrouver les bonnes terminaisons pour que leurs phrases retrouvent leur sens !", + "rule_id": "regle_accord_adjectif", + "exercise_type": "multiple_choice", + "map_position": { "x": 0.2, "y": 0.7 }, + "review_status": "draft" +} diff --git a/data/missions/mission_02_pont_des_phrases.json b/data/missions/mission_02_pont_des_phrases.json new file mode 100644 index 0000000..8f6b132 --- /dev/null +++ b/data/missions/mission_02_pont_des_phrases.json @@ -0,0 +1,9 @@ +{ + "mission_id": "mission_02_pont_des_phrases", + "title": "Le pont des phrases", + "narrative_context": "Un pont de pierres portant chacune un mot s'est abîmé. Repère la pierre fautive et corrige-la pour que les voyageurs puissent traverser en toute sécurité.", + "rule_id": "regle_accord_adjectif", + "exercise_type": "sentence_correction", + "map_position": { "x": 0.5, "y": 0.4 }, + "review_status": "draft" +} diff --git a/data/missions/mission_03_clairiere_echo.json b/data/missions/mission_03_clairiere_echo.json new file mode 100644 index 0000000..19aa2fc --- /dev/null +++ b/data/missions/mission_03_clairiere_echo.json @@ -0,0 +1,9 @@ +{ + "mission_id": "mission_03_clairiere_echo", + "title": "La clairière de l'écho", + "narrative_context": "L'écho de la clairière répète fidèlement les phrases qu'on lui confie, mais seulement si elles sont bien écrites. Dicte-lui la phrase sans faute pour l'entendre résonner.", + "rule_id": "regle_accord_adjectif", + "exercise_type": "dictation", + "map_position": { "x": 0.8, "y": 0.65 }, + "review_status": "draft" +} diff --git a/data/schemas/localization.schema.json b/data/schemas/localization.schema.json new file mode 100644 index 0000000..e0a362c --- /dev/null +++ b/data/schemas/localization.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } +} diff --git a/data/schemas/mission.schema.json b/data/schemas/mission.schema.json new file mode 100644 index 0000000..4873377 --- /dev/null +++ b/data/schemas/mission.schema.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "required": [ + "mission_id", + "title", + "narrative_context", + "rule_id", + "exercise_type", + "map_position", + "review_status" + ], + "additionalProperties": false, + "properties": { + "mission_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "title": { "type": "string", "minLength": 1 }, + "narrative_context": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "exercise_type": { + "type": "string", + "enum": ["multiple_choice", "sentence_correction", "dictation"] + }, + "map_position": { + "type": "object", + "required": ["x", "y"], + "additionalProperties": false, + "properties": { + "x": { "type": "number", "minimum": 0, "maximum": 1 }, + "y": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/save.schema.json b/data/schemas/save.schema.json index edeef61..2f49f41 100644 --- a/data/schemas/save.schema.json +++ b/data/schemas/save.schema.json @@ -18,7 +18,36 @@ "school_level": { "type": "string", "enum": ["CE1", "CE2"] }, "session_count": { "type": "integer", "minimum": 0 }, "unlocked_regions": { "type": "array", "items": { "type": "string" } }, - "skills": { "type": "object" }, + "skills": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "status", + "recent_results", + "recent_hint_used", + "sessions_succeeded", + "formats_succeeded", + "last_session_practiced", + "review_interval_index", + "next_review_session" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["decouverte", "en_acquisition", "en_consolidation", "acquise", "a_reviser"] + }, + "recent_results": { "type": "array", "items": { "type": "boolean" } }, + "recent_hint_used": { "type": "array", "items": { "type": "boolean" } }, + "sessions_succeeded": { "type": "array", "items": { "type": "integer" } }, + "formats_succeeded": { "type": "array", "items": { "type": "string" } }, + "last_session_practiced": { "type": "integer" }, + "review_interval_index": { "type": "integer" }, + "next_review_session": { "type": "integer" } + } + } + }, "rewards": { "type": "object", "required": ["stars", "items"], diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 5a66fc6..2d6d2d8 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -114,3 +114,17 @@ Ajouts à l'arborescence proposée : - Génie du moteur JSON de Godot : les nombres relus depuis un fichier sont toujours des `float`, jamais des `int`, même pour un entier comme `session_count`. GDScript convertit implicitement à l'affectation dans une variable typée `int`, mais les comparaisons strictes dans les tests doivent caster explicitement (`int(...)`), sinon GUT émet un avertissement. - `JSON.parse_string` sur un contenu invalide imprime une erreur moteur (`Parse JSON failed`), même quand l'appelant la gère correctement via la valeur de retour. GUT la compte comme un échec de test par défaut : les tests qui provoquent volontairement cette corruption doivent appeler `assert_engine_error("Parse JSON failed")` pour la marquer comme attendue. - Les indicateurs « erreurs fréquentes » et l'espace adulte (§9) ne sont pas implémentés : explicitement hors périmètre de la première version jouable (prompt de lancement, Objectif). + +## 14. Étape « Scènes » : précisions prises en cours d'implémentation + +- **Nouveau type de donnée : mission.** Ni le cahier des charges ni le prompt de lancement ne décrivaient de schéma pour les « lieux » de la carte. Ajout de `data/schemas/mission.schema.json` et `data/missions/*.json` : chaque mission porte un `rule_id`, un `exercise_type`, un `narrative_context` (texte destiné à l'enfant, donc en données, jamais en dur) et une position normalisée (`map_position.x/y` dans [0,1]) résolue par la scène de carte en ancres fractionnelles, pas en pixels fixes. Trois missions couvrent les trois lieux du §15, chacune associée à un des trois types d'exercice de cette version (choix multiple, correction de phrase, dictée), toutes sur l'unique règle CE1 disponible pour l'instant. +- **Validateur étendu : `additionalProperties` comme sous-schéma.** `SchemaValidator` ne savait valider que des clés listées explicitement. Extension pour accepter `"additionalProperties": { ...schema... }`, appliqué à toute clé non listée dans `properties` (clés dynamiques). Utilisé pour resserrer `save.schema.json` (chaque entrée de `skills` validée contre la forme réelle d'un `SkillState` de `ReviewScheduler`, qui restait non vérifiée depuis le point 11) et pour `localization.schema.json` (chaque clé de traduction doit être une chaîne non vide). +- **Textes destinés à l'enfant : fichier de traduction.** Conformément à CLAUDE.md (« aucun texte destiné à l'enfant en dur dans les scripts »), toutes les chaînes d'interface viennent de `data/localization/fr.json`, chargées par `scripts/data/ui_text.gd` (`UIText.get_text(cle)`). Une clé absente renvoie la clé elle-même (visible, donc détectable) plutôt que de planter. +- **Panneaux de règle et de retour : composants embarqués, pas des scènes routées.** `rule_panel.tscn` et `feedback_panel.tscn` sont instanciés en surimpression par `exercise_host`, pas atteints par `GameManager.goto()`. Seuls `boot`, `profile_select`, `profile_create`, `world_map`, `mission_intro`, `exercise_host` et `reward_screen` sont de vraies scènes routées (constantes dans `GameManager`). +- **Changement de scène différé.** `GameManager.goto()` appelle `get_tree().change_scene_to_file.call_deferred(...)` : l'appeler directement depuis `_ready()` de la scène en train d'être ajoutée à l'arbre échoue (« Parent node is busy »). +- **Longueur de mission fixée à 3 exercices** (`exercise_host.MISSION_LENGTH`), avec répétition autorisée du seul exercice disponible quand le contenu est encore insuffisant (le volume minimal du cahier des charges — 8 QCM et 4 corrections par règle, 3 dictées par chapitre — n'est pas encore atteint ; c'est un travail de contenu séparé, pas un défaut de la mécanique de sélection). +- **Difficulté adaptative effectivement branchée** : `exercise_host` relit `ProgressManager.next_difficulty_for_skill()` avant de choisir l'exercice suivant et après chaque exercice terminé, pour que `DifficultyManager` (construit à l'étape « Logique pure ») ait une responsabilité réelle plutôt que du code mort. +- **Récompense** : une étoile fixe par mission terminée, quel que soit le nombre d'aides utilisées ou le temps pris (principe « pas de récompense liée à la vitesse », cahier des charges §2). Calculée et sauvegardée par `reward_screen`, qui est aussi le seul point d'écriture groupée en fin de mission (`ProgressManager.save_now()`). +- **Pas de verrouillage de région** pour cette version : les trois missions sont accessibles d'emblée (correspond à « une carte avec trois lieux accessibles » du §15). Le champ `unlocked_regions` existe toujours dans la sauvegarde mais n'est pas encore lu par la carte. +- **Audio des dictées** : `AudioManager.play_dictation()` existe et cherche `assets/audio/dictations/.ogg`, mais aucun fichier n'a encore été généré (étape « Graphismes et audio provisoires », à venir) ; l'absence de fichier produit un avertissement, jamais un plantage. +- **Non vérifié visuellement** : voir `docs/testing/manual-checklist.md`. Un test headless instancie `exercise_host.tscn` et simule les signaux (sans jamais déclencher le changement de scène final, qui casserait le processus de test), mais aucun rendu, tactile, clavier virtuel ou audio n'a été vérifié à l'écran. diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md new file mode 100644 index 0000000..b51b9bb --- /dev/null +++ b/docs/testing/manual-checklist.md @@ -0,0 +1,37 @@ +# Checklist de vérification manuelle + +Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, animations, audio, tactile, clavier) et n'a donc pas été vérifié par les tests automatisés. À faire à la main avant de considérer l'étape « Scènes » terminée. + +## Parcours complet + +1. `make run`. +2. Écran de sélection de profil vide → bouton « Nouveau profil » visible et cliquable. +3. Création de profil : le bouton de validation refuse un prénom vide avec un message visible (pas seulement une absence de réaction) ; CE1 est sélectionné par défaut ; le bouton CE2 bascule correctement l'état visuel des deux boutons (ils doivent se comporter comme des boutons radio, un seul actif à la fois). +4. Après création, arrivée sur la carte (« La forêt des accords ») avec trois lieux visibles et positionnés à des endroits différents, pas superposés. +5. Chaque lieu affiche un intitulé et un état (« À découvrir », etc.) lisible. +6. Clic sur un lieu → introduction de la mission : le texte narratif s'affiche en entier (pas tronqué), Plume (le hibou) affiche la même phrase dans sa bulle. +7. « Commencer la mission » → panneau de règle : énoncé et exemple lisibles, bouton « C'est parti ! » visible. +8. Mission « Le pré des petits mots » (choix multiple) : les réponses sont cliquables, une réponse juste affiche « Bravo ! », une réponse fausse affiche « Pas tout à fait » avec un indice. +9. Refaire une erreur sur le même exercice : after la 2e erreur, l'explication remplace l'indice ; après la 3e, la bonne réponse est montrée et l'exercice passe au suivant sans blocage. +10. Mission « Le pont des phrases » (correction de phrase) : toucher un mot le fait apparaître comme sélectionné (état visuel distinct, pas seulement une couleur) ; un champ de saisie apparaît sous le mot sélectionné ; toucher à nouveau le mot fait disparaître le champ. +11. Mission « La clairière de l'écho » (dictée) : le bouton « Réécouter » ne doit rien faire planter (aucun fichier audio n'existe encore : vérifier qu'un avertissement apparaît dans la console, pas un crash) ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. +12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte. +13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial). +14. Depuis la sélection de profil, supprimer un profil : une confirmation explicite doit apparaître avant la suppression effective ; annuler doit ne rien supprimer. + +## Accessibilité et interface + +- Réussite et erreur ne doivent jamais se distinguer **uniquement** par la couleur (vérifier que le texte du titre du panneau de retour diffère bien : « Bravo ! » vs « Pas tout à fait »). +- Taille et contraste du texte à vérifier sur un petit écran et un grand écran (redimensionner la fenêtre pendant que le jeu tourne, vérifier que les boutons restent utilisables et ne se chevauchent pas — conteneurs et ancres, pas de position fixe). +- Boutons testés à la souris et au clavier (tabulation / entrée). Le tactile n'a pas pu être testé (pas d'export mobile à ce stade). +- Vérifier qu'aucun texte destiné à l'enfant n'apparaît tronqué ou coupé sur les écrans où l'`autowrap` est activé. + +## Audio + +- Aucun fichier audio n'a encore été généré (`assets/audio/dictations/` est vide) : c'est attendu à ce stade (étape « Graphismes et audio provisoires », à venir). Vérifier seulement que l'absence de fichier ne provoque pas de plantage, juste un avertissement en console. + +## Non vérifié par choix + +- Export Linux/Windows : pas encore configuré (étape 6). +- Clavier virtuel tactile, formats d'écran mobile, Android/iOS/macOS : hors périmètre de cette version. +- Animations : aucune n'existe encore (interface statique assumée pour ce prototype). diff --git a/scenes/boot/boot.gd b/scenes/boot/boot.gd new file mode 100644 index 0000000..820c1ac --- /dev/null +++ b/scenes/boot/boot.gd @@ -0,0 +1,6 @@ +extends Node +## Point d'entree du jeu : redirige immediatement vers la selection de profil. + + +func _ready() -> void: + GameManager.goto(GameManager.SCENE_PROFILE_SELECT) diff --git a/scenes/boot/boot.gd.uid b/scenes/boot/boot.gd.uid new file mode 100644 index 0000000..56cc3ac --- /dev/null +++ b/scenes/boot/boot.gd.uid @@ -0,0 +1 @@ +uid://dwydfsnakvxqi diff --git a/scenes/boot/boot.tscn b/scenes/boot/boot.tscn index 93dc0aa..459f8a7 100644 --- a/scenes/boot/boot.tscn +++ b/scenes/boot/boot.tscn @@ -1,3 +1,6 @@ -[gd_scene load_steps=1 format=3] +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/boot/boot.gd" id="1"] [node name="Boot" type="Node"] +script = ExtResource("1") diff --git a/scenes/characters/guide_owl.gd b/scenes/characters/guide_owl.gd new file mode 100644 index 0000000..3b73cae --- /dev/null +++ b/scenes/characters/guide_owl.gd @@ -0,0 +1,15 @@ +extends Control +## Plume, le hibou guide : affiche une remarque ou un indice sous forme de +## texte. Portrait provisoire (forme simple, voir assets/CREDITS.md), a +## remplacer fichier par fichier sans toucher ce script. + +@onready var _dialogue_label: Label = %DialogueLabel + + +func say(text: String) -> void: + _dialogue_label.text = text + _dialogue_label.visible = true + + +func clear() -> void: + _dialogue_label.visible = false diff --git a/scenes/characters/guide_owl.gd.uid b/scenes/characters/guide_owl.gd.uid new file mode 100644 index 0000000..e11b8b5 --- /dev/null +++ b/scenes/characters/guide_owl.gd.uid @@ -0,0 +1 @@ +uid://dlcbxmv3v7wx6 diff --git a/scenes/characters/guide_owl.tscn b/scenes/characters/guide_owl.tscn new file mode 100644 index 0000000..d016460 --- /dev/null +++ b/scenes/characters/guide_owl.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/characters/guide_owl.gd" id="1"] + +[node name="GuideOwl" type="Control"] +layout_mode = 3 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 96) +script = ExtResource("1") + +[node name="HBox" type="HBoxContainer" parent="."] +layout_mode = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="Portrait" type="ColorRect" parent="HBox"] +layout_mode = 2 +custom_minimum_size = Vector2(80, 80) +color = Color(0.56, 0.75, 0.42, 1) + +[node name="Bubble" type="PanelContainer" parent="HBox"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 1 + +[node name="DialogueLabel" type="Label" parent="HBox/Bubble"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 +visible = false diff --git a/scenes/exercises/dictation.gd b/scenes/exercises/dictation.gd new file mode 100644 index 0000000..169f810 --- /dev/null +++ b/scenes/exercises/dictation.gd @@ -0,0 +1,41 @@ +extends Control +## Exercice de dictee : ecoute puis ecrit. Scene autonome : emet la reponse +## par signal, ne touche ni a la sauvegarde ni a la progression. L'audio est +## provisoire (voir docs/specs/decisions.md, point 8) ; le badge l'indique a +## l'enfant tant qu'aucune voix definitive n'a remplace le fichier. + +signal answer_submitted(submitted_text: String) + +var _dictation: Dictionary = {} + +@onready var _instruction_label: Label = %InstructionLabel +@onready var _replay_button: Button = %ReplayButton +@onready var _provisional_badge: Label = %ProvisionalBadge +@onready var _text_edit: LineEdit = %TextEdit +@onready var _validate_button: Button = %ValidateButton + + +func _ready() -> void: + _instruction_label.text = UIText.get_text("dictation.instruction_label") + _replay_button.text = UIText.get_text("dictation.replay_button") + _text_edit.placeholder_text = UIText.get_text("dictation.input_placeholder") + _provisional_badge.text = UIText.get_text("dictation.provisional_audio_badge") + _validate_button.text = UIText.get_text("exercise_host.validate_button") + + _replay_button.pressed.connect(_on_replay_pressed) + _validate_button.pressed.connect(_on_validate_pressed) + + +func load_exercise(dictation: Dictionary) -> void: + _dictation = dictation + _text_edit.text = "" + _provisional_badge.visible = dictation.get("audio_provisional", false) + _on_replay_pressed() + + +func _on_replay_pressed() -> void: + AudioManager.play_dictation(_dictation.get("audio_id", "")) + + +func _on_validate_pressed() -> void: + answer_submitted.emit(_text_edit.text) diff --git a/scenes/exercises/dictation.gd.uid b/scenes/exercises/dictation.gd.uid new file mode 100644 index 0000000..8e06237 --- /dev/null +++ b/scenes/exercises/dictation.gd.uid @@ -0,0 +1 @@ +uid://dacf070bahaho diff --git a/scenes/exercises/dictation.tscn b/scenes/exercises/dictation.tscn new file mode 100644 index 0000000..7f5f106 --- /dev/null +++ b/scenes/exercises/dictation.tscn @@ -0,0 +1,53 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/exercises/dictation.gd" id="1"] + +[node name="Dictation" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="InstructionLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="ProvisionalBadge" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.45, 0.1, 1) + +[node name="ReplayButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="TextEdit" type="LineEdit" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="ValidateButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/exercises/exercise_host.gd b/scenes/exercises/exercise_host.gd new file mode 100644 index 0000000..b490599 --- /dev/null +++ b/scenes/exercises/exercise_host.gd @@ -0,0 +1,150 @@ +extends Control +## Hote d'exercice : charge la regle, enchaine les exercices d'une mission, +## valide chaque reponse via les validateurs purs (AnswerValidator / +## DictationAnalyzer), applique l'aide graduee (AttemptFlow) et enregistre +## chaque tentative dans ProgressManager (docs/specs/decisions.md, point 7). +## Ne charge et ne sauvegarde rien lui-meme sur disque : ProgressManager et +## SaveManager restent seuls responsables de la persistance. + +const MISSION_LENGTH := 3 + +const TYPE_SCENES := { + "multiple_choice": "res://scenes/exercises/multiple_choice.tscn", + "sentence_correction": "res://scenes/exercises/sentence_correction.tscn", + "dictation": "res://scenes/exercises/dictation.tscn", +} + +var _mission: Dictionary = {} +var _rule: Dictionary = {} +var _skill_id: String = "" +var _current_exercise: Dictionary = {} +var _current_exercise_scene: Control = null +var _current_difficulty := 1 +var _attempt_number := 1 +var _completed_count := 0 +var _last_exercise_id := "" + +@onready var _exercise_container: Control = %ExerciseContainer +@onready var _overlay_container: Control = %OverlayContainer + + +## Accesseurs reserves aux tests d'integration (scenes/exercises/ +## exercise_host.tscn n'expose pas de script "ami" en GDScript). +func get_current_exercise() -> Dictionary: + return _current_exercise + + +func get_attempt_number() -> int: + return _attempt_number + + +func get_completed_count() -> int: + return _completed_count + + +func _ready() -> void: + _mission = ExerciseEngine.get_mission(GameManager.pending_mission_id) + _rule = ExerciseEngine.get_rule(_mission["rule_id"]) + _skill_id = _rule.get("skill", "") + _current_difficulty = ProgressManager.next_difficulty_for_skill(_skill_id, 1) + _show_rule_panel() + + +func _show_rule_panel() -> void: + var rule_panel: Control = preload("res://scenes/ui/rule_panel.tscn").instantiate() + _overlay_container.add_child(rule_panel) + rule_panel.show_rule(_rule) + rule_panel.continued.connect(_on_rule_panel_continued.bind(rule_panel)) + + +func _on_rule_panel_continued(rule_panel: Control) -> void: + rule_panel.queue_free() + _load_next_exercise() + + +func _load_next_exercise() -> void: + _current_exercise = ExerciseEngine.pick_exercise_for_mission( + _mission, _last_exercise_id, _current_difficulty + ) + _attempt_number = 1 + + if _current_exercise_scene != null: + _current_exercise_scene.queue_free() + + var scene_path: String = TYPE_SCENES[_mission["exercise_type"]] + _current_exercise_scene = load(scene_path).instantiate() + _exercise_container.add_child(_current_exercise_scene) + _current_exercise_scene.load_exercise(_current_exercise) + _connect_answer_signal() + + +func _connect_answer_signal() -> void: + match _mission["exercise_type"]: + "multiple_choice": + _current_exercise_scene.answer_submitted.connect(_on_multiple_choice_submitted) + "sentence_correction": + _current_exercise_scene.answer_submitted.connect(_on_sentence_correction_submitted) + "dictation": + _current_exercise_scene.answer_submitted.connect(_on_dictation_submitted) + + +func _on_multiple_choice_submitted(selected_index: int) -> void: + _handle_result(AnswerValidator.validate_multiple_choice(_current_exercise, selected_index)) + + +func _on_sentence_correction_submitted(selected_indices: Array, corrections: Dictionary) -> void: + var typed_indices: Array[int] = [] + for index: int in selected_indices: + typed_indices.append(index) + _handle_result( + AnswerValidator.validate_sentence_correction( + _current_exercise, typed_indices, corrections + ) + ) + + +func _on_dictation_submitted(submitted_text: String) -> void: + _handle_result(DictationAnalyzer.analyze(_current_exercise, submitted_text)) + + +func _handle_result(result: CorrectionResult) -> void: + var support_level := AttemptFlow.SupportLevel.EXPLANATION + var is_terminal := result.is_correct + + if not result.is_correct: + support_level = AttemptFlow.support_level_for_attempt(_attempt_number) + is_terminal = AttemptFlow.should_end_exercise(_attempt_number) + + ProgressManager.record_attempt( + _skill_id, result.is_correct, _attempt_number > 1, _mission["exercise_type"] + ) + + _show_feedback(result, support_level, is_terminal) + + +func _show_feedback( + result: CorrectionResult, support_level: AttemptFlow.SupportLevel, is_terminal: bool +) -> void: + var feedback: Control = preload("res://scenes/ui/feedback_panel.tscn").instantiate() + _overlay_container.add_child(feedback) + feedback.show_result(result, support_level) + feedback.continue_pressed.connect(_on_feedback_continued.bind(feedback, is_terminal)) + + +func _on_feedback_continued(feedback: Control, is_terminal: bool) -> void: + feedback.queue_free() + + if not is_terminal: + _attempt_number += 1 + return + + _current_difficulty = ProgressManager.next_difficulty_for_skill( + _skill_id, _current_difficulty + ) + _last_exercise_id = _current_exercise.get("id", "") + _completed_count += 1 + + if _completed_count >= MISSION_LENGTH: + GameManager.goto(GameManager.SCENE_REWARD) + else: + _load_next_exercise() diff --git a/scenes/exercises/exercise_host.gd.uid b/scenes/exercises/exercise_host.gd.uid new file mode 100644 index 0000000..28d6437 --- /dev/null +++ b/scenes/exercises/exercise_host.gd.uid @@ -0,0 +1 @@ +uid://co82y6w81weqr diff --git a/scenes/exercises/exercise_host.tscn b/scenes/exercises/exercise_host.tscn new file mode 100644 index 0000000..e00d84a --- /dev/null +++ b/scenes/exercises/exercise_host.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/exercises/exercise_host.gd" id="1"] + +[node name="ExerciseHost" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="ExerciseContainer" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="OverlayContainer" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 diff --git a/scenes/exercises/multiple_choice.gd b/scenes/exercises/multiple_choice.gd new file mode 100644 index 0000000..6f3c7bd --- /dev/null +++ b/scenes/exercises/multiple_choice.gd @@ -0,0 +1,30 @@ +extends Control +## Exercice a choix multiple. Scene autonome : emet la reponse par signal, +## ne touche ni a la sauvegarde ni a la progression (invariant d'architecture, +## CLAUDE.md). + +signal answer_submitted(selected_index: int) + +@onready var _instruction_label: Label = %InstructionLabel +@onready var _question_label: Label = %QuestionLabel +@onready var _answers_box: VBoxContainer = %AnswersBox + + +func load_exercise(exercise: Dictionary) -> void: + _instruction_label.text = exercise["instruction"] + _question_label.text = exercise["question"] + + for child in _answers_box.get_children(): + child.queue_free() + + var answers: Array = exercise["answers"] + for i in answers.size(): + var button := Button.new() + button.text = answers[i] + button.custom_minimum_size = Vector2(0, 56) + button.pressed.connect(_on_answer_pressed.bind(i)) + _answers_box.add_child(button) + + +func _on_answer_pressed(index: int) -> void: + answer_submitted.emit(index) diff --git a/scenes/exercises/multiple_choice.gd.uid b/scenes/exercises/multiple_choice.gd.uid new file mode 100644 index 0000000..23d3fcb --- /dev/null +++ b/scenes/exercises/multiple_choice.gd.uid @@ -0,0 +1 @@ +uid://5sbuvx3101u0 diff --git a/scenes/exercises/multiple_choice.tscn b/scenes/exercises/multiple_choice.tscn new file mode 100644 index 0000000..ed7b2cb --- /dev/null +++ b/scenes/exercises/multiple_choice.tscn @@ -0,0 +1,44 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/exercises/multiple_choice.gd" id="1"] + +[node name="MultipleChoice" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="InstructionLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="QuestionLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 +theme_override_font_sizes/font_size = 24 + +[node name="AnswersBox" type="VBoxContainer" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 12 diff --git a/scenes/exercises/sentence_correction.gd b/scenes/exercises/sentence_correction.gd new file mode 100644 index 0000000..088b2ac --- /dev/null +++ b/scenes/exercises/sentence_correction.gd @@ -0,0 +1,59 @@ +extends Control +## Exercice de correction de phrase : toucher le mot fautif puis le corriger +## (interaction decidee dans docs/specs/decisions.md, point 3). Scene +## autonome : emet la reponse par signal, ne touche ni a la sauvegarde ni a +## la progression. + +signal answer_submitted(selected_indices: Array, corrections: Dictionary) + +var _correction_edits: Dictionary = {} + +@onready var _instruction_label: Label = %InstructionLabel +@onready var _tokens_box: HFlowContainer = %TokensBox +@onready var _corrections_box: VBoxContainer = %CorrectionsBox +@onready var _validate_button: Button = %ValidateButton + + +func _ready() -> void: + _validate_button.text = UIText.get_text("exercise_host.validate_button") + _validate_button.pressed.connect(_on_validate_pressed) + + +func load_exercise(exercise: Dictionary) -> void: + _instruction_label.text = exercise["instruction"] + + for child in _tokens_box.get_children(): + child.queue_free() + for child in _corrections_box.get_children(): + child.queue_free() + _correction_edits.clear() + + var tokens: Array = exercise["tokens"] + for i in tokens.size(): + var button := Button.new() + button.text = tokens[i]["text"] + button.toggle_mode = true + button.toggled.connect(_on_token_toggled.bind(i)) + _tokens_box.add_child(button) + + +func _on_token_toggled(pressed: bool, index: int) -> void: + if pressed: + if not _correction_edits.has(index): + var edit := LineEdit.new() + edit.placeholder_text = UIText.get_text("sentence_correction.correction_placeholder") + _corrections_box.add_child(edit) + _correction_edits[index] = edit + elif _correction_edits.has(index): + var edit: LineEdit = _correction_edits[index] + edit.queue_free() + _correction_edits.erase(index) + + +func _on_validate_pressed() -> void: + var selected_indices: Array = [] + var corrections: Dictionary = {} + for index: int in _correction_edits.keys(): + selected_indices.append(index) + corrections[index] = (_correction_edits[index] as LineEdit).text.strip_edges() + answer_submitted.emit(selected_indices, corrections) diff --git a/scenes/exercises/sentence_correction.gd.uid b/scenes/exercises/sentence_correction.gd.uid new file mode 100644 index 0000000..79b66e6 --- /dev/null +++ b/scenes/exercises/sentence_correction.gd.uid @@ -0,0 +1 @@ +uid://cdn2shqmift8j diff --git a/scenes/exercises/sentence_correction.tscn b/scenes/exercises/sentence_correction.tscn new file mode 100644 index 0000000..e8ae1c3 --- /dev/null +++ b/scenes/exercises/sentence_correction.tscn @@ -0,0 +1,50 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/exercises/sentence_correction.gd" id="1"] + +[node name="SentenceCorrection" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="InstructionLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="TokensBox" type="HFlowContainer" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/h_separation = 8 +theme_override_constants/v_separation = 8 + +[node name="CorrectionsBox" type="VBoxContainer" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 8 + +[node name="ValidateButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/profiles/profile_create.gd b/scenes/profiles/profile_create.gd new file mode 100644 index 0000000..dfe3bb4 --- /dev/null +++ b/scenes/profiles/profile_create.gd @@ -0,0 +1,48 @@ +extends Control +## Creation d'un profil enfant : prenom/pseudo et niveau scolaire. Pas +## d'avatar ni d'element cosmetique : hors perimetre de la premiere version +## jouable (voir prompt de lancement, Objectif). + +@onready var _title_label: Label = %TitleLabel +@onready var _name_label: Label = %NameLabel +@onready var _name_edit: LineEdit = %NameEdit +@onready var _level_label: Label = %LevelLabel +@onready var _ce1_button: Button = %CE1Button +@onready var _ce2_button: Button = %CE2Button +@onready var _error_label: Label = %ErrorLabel +@onready var _confirm_button: Button = %ConfirmButton +@onready var _cancel_button: Button = %CancelButton + + +func _ready() -> void: + _title_label.text = UIText.get_text("profile_create.title") + _name_label.text = UIText.get_text("profile_create.name_label") + _name_edit.placeholder_text = UIText.get_text("profile_create.name_placeholder") + _level_label.text = UIText.get_text("profile_create.level_label") + _ce1_button.text = UIText.get_text("profile_create.level_ce1") + _ce2_button.text = UIText.get_text("profile_create.level_ce2") + _error_label.text = UIText.get_text("profile_create.name_required_error") + _error_label.visible = false + _confirm_button.text = UIText.get_text("profile_create.confirm_button") + _cancel_button.text = UIText.get_text("profile_create.cancel_button") + + _ce1_button.button_pressed = true + + _confirm_button.pressed.connect(_on_confirm_pressed) + _cancel_button.pressed.connect(_on_cancel_pressed) + + +func _on_confirm_pressed() -> void: + var display_name := _name_edit.text.strip_edges() + if display_name.is_empty(): + _error_label.visible = true + return + + var school_level := "CE1" if _ce1_button.button_pressed else "CE2" + var profile_id := ProfileManager.create_profile(display_name, school_level) + if ProfileManager.select_profile(profile_id): + GameManager.goto(GameManager.SCENE_WORLD_MAP) + + +func _on_cancel_pressed() -> void: + GameManager.goto(GameManager.SCENE_PROFILE_SELECT) diff --git a/scenes/profiles/profile_create.gd.uid b/scenes/profiles/profile_create.gd.uid new file mode 100644 index 0000000..68e44df --- /dev/null +++ b/scenes/profiles/profile_create.gd.uid @@ -0,0 +1 @@ +uid://l2dvw15a75u1 diff --git a/scenes/profiles/profile_create.tscn b/scenes/profiles/profile_create.tscn new file mode 100644 index 0000000..21ad344 --- /dev/null +++ b/scenes/profiles/profile_create.tscn @@ -0,0 +1,82 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/profiles/profile_create.gd" id="1"] + +[sub_resource type="ButtonGroup" id="SchoolLevelGroup"] + +[node name="ProfileCreate" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 32 +theme_override_constants/margin_top = 32 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 32 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TitleLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 32 + +[node name="NameLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="NameEdit" type="LineEdit" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 48) + +[node name="ErrorLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="LevelLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="LevelBox" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="CE1Button" type="Button" parent="Margin/VBox/LevelBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 56) +toggle_mode = true +button_group = SubResource("SchoolLevelGroup") + +[node name="CE2Button" type="Button" parent="Margin/VBox/LevelBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 56) +toggle_mode = true +button_group = SubResource("SchoolLevelGroup") + +[node name="ConfirmButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="CancelButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 diff --git a/scenes/profiles/profile_select.gd b/scenes/profiles/profile_select.gd new file mode 100644 index 0000000..9db9691 --- /dev/null +++ b/scenes/profiles/profile_select.gd @@ -0,0 +1,78 @@ +extends Control +## Ecran de selection de profil : un profil par enfant, suppression avec +## confirmation explicite (cahier des charges §9). + +var _pending_delete_profile_id: String = "" + +@onready var _title_label: Label = %TitleLabel +@onready var _profile_list: VBoxContainer = %ProfileList +@onready var _empty_state_label: Label = %EmptyStateLabel +@onready var _new_profile_button: Button = %NewProfileButton +@onready var _delete_confirm_dialog: ConfirmationDialog = %DeleteConfirmDialog + + +func _ready() -> void: + _title_label.text = UIText.get_text("profile_select.title") + _new_profile_button.text = UIText.get_text("profile_select.new_profile_button") + _empty_state_label.text = UIText.get_text("profile_select.empty_state") + _delete_confirm_dialog.title = UIText.get_text("profile_select.delete_confirm_title") + _delete_confirm_dialog.dialog_text = UIText.get_text("profile_select.delete_confirm_message") + _delete_confirm_dialog.ok_button_text = UIText.get_text("profile_select.delete_confirm_yes") + _delete_confirm_dialog.cancel_button_text = UIText.get_text( + "profile_select.delete_confirm_no" + ) + + _new_profile_button.pressed.connect(_on_new_profile_pressed) + _delete_confirm_dialog.confirmed.connect(_on_delete_confirmed) + + _refresh_profile_list() + + +func _refresh_profile_list() -> void: + for child in _profile_list.get_children(): + child.queue_free() + + var summaries := ProfileManager.list_profile_summaries() + _empty_state_label.visible = summaries.is_empty() + + for summary: Dictionary in summaries: + _profile_list.add_child(_build_profile_row(summary)) + + +func _build_profile_row(summary: Dictionary) -> Control: + var row := HBoxContainer.new() + + var select_button := Button.new() + select_button.text = "%s (%s)" % [summary["display_name"], summary["school_level"]] + select_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + select_button.pressed.connect(_on_profile_selected.bind(summary["profile_id"])) + row.add_child(select_button) + + var delete_button := Button.new() + delete_button.text = UIText.get_text("profile_select.delete_button") + delete_button.pressed.connect(_on_delete_requested.bind(summary["profile_id"])) + row.add_child(delete_button) + + return row + + +func _on_profile_selected(profile_id: String) -> void: + if ProfileManager.select_profile(profile_id): + GameManager.goto(GameManager.SCENE_WORLD_MAP) + + +func _on_delete_requested(profile_id: String) -> void: + _pending_delete_profile_id = profile_id + _delete_confirm_dialog.popup_centered() + + +func _on_delete_confirmed() -> void: + if _pending_delete_profile_id.is_empty(): + return + ProfileManager.delete_profile(_pending_delete_profile_id) + _pending_delete_profile_id = "" + _refresh_profile_list() + + +func _on_new_profile_pressed() -> void: + GameManager.goto(GameManager.SCENE_PROFILE_CREATE) diff --git a/scenes/profiles/profile_select.gd.uid b/scenes/profiles/profile_select.gd.uid new file mode 100644 index 0000000..887a4b4 --- /dev/null +++ b/scenes/profiles/profile_select.gd.uid @@ -0,0 +1 @@ +uid://cs3pt5155hvds diff --git a/scenes/profiles/profile_select.tscn b/scenes/profiles/profile_select.tscn new file mode 100644 index 0000000..93470cf --- /dev/null +++ b/scenes/profiles/profile_select.tscn @@ -0,0 +1,56 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/profiles/profile_select.gd" id="1"] + +[node name="ProfileSelect" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 32 +theme_override_constants/margin_top = 32 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 32 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TitleLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 32 + +[node name="Scroll" type="ScrollContainer" parent="Margin/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ProfileList" type="VBoxContainer" parent="Margin/VBox/Scroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="EmptyStateLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="NewProfileButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="DeleteConfirmDialog" type="ConfirmationDialog" parent="."] +unique_name_in_owner = true diff --git a/scenes/ui/feedback_panel.gd b/scenes/ui/feedback_panel.gd new file mode 100644 index 0000000..937e6f2 --- /dev/null +++ b/scenes/ui/feedback_panel.gd @@ -0,0 +1,53 @@ +extends Control +## Panneau de retour : affiche le resultat d'une tentative (CorrectionResult). +## Reussite et erreur se distinguent par le texte du titre, jamais par la +## seule couleur (cahier des charges §11). Instancie par exercise_host. + +signal continue_pressed + +@onready var _title_label: Label = %TitleLabel +@onready var _hint_box: VBoxContainer = %HintBox +@onready var _hint_label: Label = %HintLabel +@onready var _explanation_box: VBoxContainer = %ExplanationBox +@onready var _explanation_label: Label = %ExplanationLabel +@onready var _correction_box: VBoxContainer = %CorrectionBox +@onready var _correction_label: Label = %CorrectionLabel +@onready var _continue_button: Button = %ContinueButton + + +func _ready() -> void: + _continue_button.text = UIText.get_text("exercise_host.continue_button") + _continue_button.pressed.connect(_on_continue_pressed) + + +func show_result(result: CorrectionResult, support_level: AttemptFlow.SupportLevel) -> void: + _title_label.text = ( + UIText.get_text("feedback_panel.correct_title") + if result.is_correct + else UIText.get_text("feedback_panel.incorrect_title") + ) + + var show_hint := not result.is_correct and support_level == AttemptFlow.SupportLevel.HINT + var show_explanation := ( + result.is_correct or support_level != AttemptFlow.SupportLevel.HINT + ) + var show_correction := ( + not result.is_correct and support_level == AttemptFlow.SupportLevel.REVEAL + ) + + _hint_box.visible = show_hint + _hint_label.text = "%s %s" % [UIText.get_text("feedback_panel.hint_label"), result.hint] + + _explanation_box.visible = show_explanation + _explanation_label.text = "%s %s" % [ + UIText.get_text("feedback_panel.explanation_label"), result.explanation + ] + + _correction_box.visible = show_correction + _correction_label.text = "%s %s" % [ + UIText.get_text("feedback_panel.correction_label"), ", ".join(result.remediation) + ] + + +func _on_continue_pressed() -> void: + continue_pressed.emit() diff --git a/scenes/ui/feedback_panel.gd.uid b/scenes/ui/feedback_panel.gd.uid new file mode 100644 index 0000000..058e064 --- /dev/null +++ b/scenes/ui/feedback_panel.gd.uid @@ -0,0 +1 @@ +uid://j44owty381sk diff --git a/scenes/ui/feedback_panel.tscn b/scenes/ui/feedback_panel.tscn new file mode 100644 index 0000000..965a251 --- /dev/null +++ b/scenes/ui/feedback_panel.tscn @@ -0,0 +1,82 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/feedback_panel.gd" id="1"] + +[node name="FeedbackPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.5) + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Panel" type="PanelContainer" parent="CenterContainer"] +layout_mode = 2 +custom_minimum_size = Vector2(480, 0) + +[node name="Margin" type="MarginContainer" parent="CenterContainer/Panel"] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="VBox" type="VBoxContainer" parent="CenterContainer/Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 28 + +[node name="HintBox" type="VBoxContainer" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="HintLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox/HintBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="ExplanationBox" type="VBoxContainer" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="ExplanationLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox/ExplanationBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="CorrectionBox" type="VBoxContainer" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="CorrectionLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox/CorrectionBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="ContinueButton" type="Button" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/ui/reward_screen.gd b/scenes/ui/reward_screen.gd new file mode 100644 index 0000000..f205752 --- /dev/null +++ b/scenes/ui/reward_screen.gd @@ -0,0 +1,28 @@ +extends Control +## Ecran de recompense : une mission terminee rapporte toujours la meme +## recompense, que l'enfant ait eu besoin d'aide ou non (cahier des charges +## §2, principe 6 : pas de recompense liee a la vitesse ou a la performance). +## Sauvegarde groupee ici, en fin de mission (docs/specs/decisions.md point 7). + +const STARS_PER_MISSION := 1 + +@onready var _title_label: Label = %TitleLabel +@onready var _stars_label: Label = %StarsLabel +@onready var _continue_button: Button = %ContinueButton + + +func _ready() -> void: + ProgressManager.add_stars(STARS_PER_MISSION) + ProgressManager.save_now() + + _title_label.text = UIText.get_text("reward_screen.title") + _stars_label.text = "%s %d" % [ + UIText.get_text("reward_screen.stars_label"), ProgressManager.stars() + ] + _continue_button.text = UIText.get_text("reward_screen.continue_button") + _continue_button.pressed.connect(_on_continue_pressed) + + +func _on_continue_pressed() -> void: + GameManager.pending_mission_id = "" + GameManager.goto(GameManager.SCENE_WORLD_MAP) diff --git a/scenes/ui/reward_screen.gd.uid b/scenes/ui/reward_screen.gd.uid new file mode 100644 index 0000000..2b7b00c --- /dev/null +++ b/scenes/ui/reward_screen.gd.uid @@ -0,0 +1 @@ +uid://hst702e112g7 diff --git a/scenes/ui/reward_screen.tscn b/scenes/ui/reward_screen.tscn new file mode 100644 index 0000000..481c581 --- /dev/null +++ b/scenes/ui/reward_screen.tscn @@ -0,0 +1,40 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/reward_screen.gd" id="1"] + +[node name="RewardScreen" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBox" type="VBoxContainer" parent="CenterContainer"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="TitleLabel" type="Label" parent="CenterContainer/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 36 +horizontal_alignment = 1 + +[node name="StarsLabel" type="Label" parent="CenterContainer/VBox"] +unique_name_in_owner = true +layout_mode = 2 +horizontal_alignment = 1 + +[node name="ContinueButton" type="Button" parent="CenterContainer/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(200, 56) diff --git a/scenes/ui/rule_panel.gd b/scenes/ui/rule_panel.gd new file mode 100644 index 0000000..0fe86bd --- /dev/null +++ b/scenes/ui/rule_panel.gd @@ -0,0 +1,29 @@ +extends Control +## Panneau de regle : affiche l'enonce et l'exemple issus de data/rules/ +## (cycle d'apprentissage §2, etape "Decouvrir la regle"). Instancie par +## exercise_host, qui ecoute le signal `continued` pour lancer le premier +## exercice. + +signal continued + +@onready var _title_label: Label = %TitleLabel +@onready var _statement_label: Label = %StatementLabel +@onready var _example_label: Label = %ExampleLabel +@onready var _continue_button: Button = %ContinueButton + + +func _ready() -> void: + _title_label.text = UIText.get_text("rule_panel.title") + _continue_button.text = UIText.get_text("rule_panel.continue_button") + _continue_button.pressed.connect(_on_continue_pressed) + + +func show_rule(rule: Dictionary) -> void: + _statement_label.text = rule.get("statement", "") + _example_label.text = "%s %s" % [ + UIText.get_text("rule_panel.example_label"), rule.get("example", "") + ] + + +func _on_continue_pressed() -> void: + continued.emit() diff --git a/scenes/ui/rule_panel.gd.uid b/scenes/ui/rule_panel.gd.uid new file mode 100644 index 0000000..1d2d0db --- /dev/null +++ b/scenes/ui/rule_panel.gd.uid @@ -0,0 +1 @@ +uid://calghe6tbv2gj diff --git a/scenes/ui/rule_panel.tscn b/scenes/ui/rule_panel.tscn new file mode 100644 index 0000000..3103131 --- /dev/null +++ b/scenes/ui/rule_panel.tscn @@ -0,0 +1,65 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/rule_panel.gd" id="1"] + +[node name="RulePanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.5) + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Panel" type="PanelContainer" parent="CenterContainer"] +layout_mode = 2 +custom_minimum_size = Vector2(480, 0) + +[node name="Margin" type="MarginContainer" parent="CenterContainer/Panel"] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="VBox" type="VBoxContainer" parent="CenterContainer/Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TitleLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 28 + +[node name="StatementLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="ExampleLabel" type="Label" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="ContinueButton" type="Button" parent="CenterContainer/Panel/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/world/mission_intro.gd b/scenes/world/mission_intro.gd new file mode 100644 index 0000000..52ecc3a --- /dev/null +++ b/scenes/world/mission_intro.gd @@ -0,0 +1,32 @@ +extends Control +## Introduction de la mission choisie sur la carte : contexte narratif issu +## des donnees (data/missions/), pas un questionnaire habille d'un decor +## (voir "Points durs" du prompt de lancement, Sens de l'aventure). + +@onready var _title_label: Label = %TitleLabel +@onready var _narrative_label: Label = %NarrativeLabel +@onready var _guide: Control = %Guide +@onready var _start_button: Button = %StartButton +@onready var _back_button: Button = %BackButton + + +func _ready() -> void: + var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id) + _title_label.text = mission.get("title", "") + _narrative_label.text = mission.get("narrative_context", "") + _guide.say(mission.get("narrative_context", "")) + + _start_button.text = UIText.get_text("mission_intro.start_button") + _back_button.text = UIText.get_text("mission_intro.back_button") + + _start_button.pressed.connect(_on_start_pressed) + _back_button.pressed.connect(_on_back_pressed) + + +func _on_start_pressed() -> void: + GameManager.goto(GameManager.SCENE_EXERCISE_HOST) + + +func _on_back_pressed() -> void: + GameManager.pending_mission_id = "" + GameManager.goto(GameManager.SCENE_WORLD_MAP) diff --git a/scenes/world/mission_intro.gd.uid b/scenes/world/mission_intro.gd.uid new file mode 100644 index 0000000..06cd5a0 --- /dev/null +++ b/scenes/world/mission_intro.gd.uid @@ -0,0 +1 @@ +uid://2piy37b6koyw diff --git a/scenes/world/mission_intro.tscn b/scenes/world/mission_intro.tscn new file mode 100644 index 0000000..e655a08 --- /dev/null +++ b/scenes/world/mission_intro.tscn @@ -0,0 +1,59 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/world/mission_intro.gd" id="1"] +[ext_resource type="PackedScene" path="res://scenes/characters/guide_owl.tscn" id="2"] + +[node name="MissionIntro" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 32 +theme_override_constants/margin_top = 32 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 32 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TitleLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 32 + +[node name="NarrativeLabel" type="Label" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 +size_flags_vertical = 3 + +[node name="Guide" type="Control" parent="Margin/VBox" instance=ExtResource("2")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="Buttons" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="Margin/VBox/Buttons"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="StartButton" type="Button" parent="Margin/VBox/Buttons"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/world/world_map.gd b/scenes/world/world_map.gd new file mode 100644 index 0000000..ddca6fb --- /dev/null +++ b/scenes/world/world_map.gd @@ -0,0 +1,56 @@ +extends Control +## Carte a trois lieux (cahier des charges §15). La position de chaque mission +## vient de data/missions/*.json (fractions 0..1 de la zone de carte), jamais +## d'une position fixe en dur dans le script. + +const MARKER_HALF_WIDTH := 80.0 +const MARKER_HALF_HEIGHT := 36.0 + +@onready var _title_label: Label = %TitleLabel +@onready var _map_area: Control = %MapArea + + +func _ready() -> void: + _title_label.text = UIText.get_text("world_map.title") + _populate_missions() + + +func _populate_missions() -> void: + for child in _map_area.get_children(): + child.queue_free() + + for mission: Dictionary in ExerciseEngine.get_missions(): + _map_area.add_child(_build_marker(mission)) + + +func _build_marker(mission: Dictionary) -> Control: + var button := Button.new() + button.text = "%s\n%s" % [mission["title"], _status_label_for(mission)] + + var map_position: Dictionary = mission["map_position"] + var x: float = map_position["x"] + var y: float = map_position["y"] + button.anchor_left = x + button.anchor_right = x + button.anchor_top = y + button.anchor_bottom = y + button.offset_left = -MARKER_HALF_WIDTH + button.offset_right = MARKER_HALF_WIDTH + button.offset_top = -MARKER_HALF_HEIGHT + button.offset_bottom = MARKER_HALF_HEIGHT + + button.pressed.connect(_on_mission_pressed.bind(mission["mission_id"])) + return button + + +func _status_label_for(mission: Dictionary) -> String: + var rule: Dictionary = ExerciseEngine.get_rule(mission["rule_id"]) + var skill_id: String = rule.get("skill", "") + var skill_state := ProgressManager.get_skill_state(skill_id) + var status: String = skill_state["status"] + return UIText.get_text("world_map.status_%s" % status) + + +func _on_mission_pressed(mission_id: String) -> void: + GameManager.pending_mission_id = mission_id + GameManager.goto(GameManager.SCENE_MISSION_INTRO) diff --git a/scenes/world/world_map.gd.uid b/scenes/world/world_map.gd.uid new file mode 100644 index 0000000..a998022 --- /dev/null +++ b/scenes/world/world_map.gd.uid @@ -0,0 +1 @@ +uid://bicux8hrq0kpc diff --git a/scenes/world/world_map.tscn b/scenes/world/world_map.tscn new file mode 100644 index 0000000..a07d3ed --- /dev/null +++ b/scenes/world/world_map.tscn @@ -0,0 +1,46 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/world/world_map.gd" id="1"] +[ext_resource type="PackedScene" path="res://scenes/characters/guide_owl.tscn" id="2"] + +[node name="WorldMap" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.3, 0.52, 0.33, 1) + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 + +[node name="MapArea" type="Control" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Guide" type="Control" parent="VBox" instance=ExtResource("2")] +unique_name_in_owner = true +layout_mode = 2 diff --git a/scripts/data/exercise_picker.gd b/scripts/data/exercise_picker.gd new file mode 100644 index 0000000..31470d1 --- /dev/null +++ b/scripts/data/exercise_picker.gd @@ -0,0 +1,25 @@ +class_name ExercisePicker +extends RefCounted +## Choisit un exercice parmi une liste de candidats, sans jamais redonner celui +## qui vient d'etre traite ("Points durs" du prompt de lancement : une revision +## tire un autre exercice de la meme regle, jamais celui qui vient d'etre +## rate). Code pur : le generateur aleatoire est injecte, jamais lu +## directement. + + +## Retourne null si `candidates` est vide. Si tous les candidats sont exclus +## (un seul exercice disponible), le redonne : pas d'autre choix possible. +static func pick( + candidates: Array[Dictionary], exclude_id: String, rng: RandomNumberGenerator +) -> Variant: + if candidates.is_empty(): + return null + + var pool: Array[Dictionary] = [] + for candidate in candidates: + if candidate["id"] != exclude_id: + pool.append(candidate) + if pool.is_empty(): + pool = candidates + + return pool[rng.randi_range(0, pool.size() - 1)] diff --git a/scripts/data/exercise_picker.gd.uid b/scripts/data/exercise_picker.gd.uid new file mode 100644 index 0000000..99ffa40 --- /dev/null +++ b/scripts/data/exercise_picker.gd.uid @@ -0,0 +1 @@ +uid://deb67kr6jsoe diff --git a/scripts/data/schema_validator.gd b/scripts/data/schema_validator.gd index 806364a..5531874 100644 --- a/scripts/data/schema_validator.gd +++ b/scripts/data/schema_validator.gd @@ -81,8 +81,13 @@ static func _validate_object( for key: String in value.keys(): if properties.has(key): _validate_node(value[key], properties[key], "%s.%s" % [path, key], errors) - elif additional_allowed == false: + elif typeof(additional_allowed) == TYPE_BOOL and additional_allowed == false: errors.append("%s : propriete inattendue '%s'" % [path, key]) + elif typeof(additional_allowed) == TYPE_DICTIONARY: + # additionalProperties est lui-meme un schema : toute cle non + # listee dans "properties" doit s'y conformer (cles dynamiques, + # ex. une competence par skill_id dans une sauvegarde). + _validate_node(value[key], additional_allowed, "%s.%s" % [path, key], errors) static func _validate_array( diff --git a/scripts/data/ui_text.gd b/scripts/data/ui_text.gd new file mode 100644 index 0000000..8c614ad --- /dev/null +++ b/scripts/data/ui_text.gd @@ -0,0 +1,27 @@ +class_name UIText +extends RefCounted +## Textes d'interface destines a l'enfant, charges depuis +## data/localization/fr.json : jamais en dur dans les scripts ou les scenes +## (voir CLAUDE.md, Conventions de code). + +const LOCALIZATION_PATH := "res://data/localization/fr.json" + +static var _cache: Dictionary = {} + + +static func get_text(key: String) -> String: + if _cache.is_empty(): + _load() + if not _cache.has(key): + push_error("Cle de traduction manquante : %s" % key) + return key + return _cache[key] + + +static func _load() -> void: + var file := FileAccess.open(LOCALIZATION_PATH, FileAccess.READ) + if file == null: + push_error("Fichier de traduction introuvable : %s" % LOCALIZATION_PATH) + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + _cache = parsed if typeof(parsed) == TYPE_DICTIONARY else {} diff --git a/scripts/data/ui_text.gd.uid b/scripts/data/ui_text.gd.uid new file mode 100644 index 0000000..4d3b117 --- /dev/null +++ b/scripts/data/ui_text.gd.uid @@ -0,0 +1 @@ +uid://ccyua7ei6gssv diff --git a/scripts/data/validate_data_cli.gd b/scripts/data/validate_data_cli.gd index 5966a8d..af16de8 100644 --- a/scripts/data/validate_data_cli.gd +++ b/scripts/data/validate_data_cli.gd @@ -12,6 +12,8 @@ func _init() -> void: checked += _validate_simple_dir("res://data/curriculum", "curriculum", failures) checked += _validate_dictations("res://data/dictations", failures) checked += _validate_exercises("res://data/exercises", failures) + checked += _validate_missions("res://data/missions", failures) + checked += _validate_simple_dir("res://data/localization", "localization", failures) if failures.is_empty(): print("validate-data : %d fichier(s) JSON valide(s)" % checked) @@ -43,6 +45,30 @@ func _validate_dictations(dir_path: String, failures: Array[String]) -> int: return count +func _validate_missions(dir_path: String, failures: Array[String]) -> int: + var known_rule_ids: Array[String] = [] + for path in DataLoader.find_json_files("res://data/rules"): + var rule: Variant = DataLoader.read_json(path) + if typeof(rule) == TYPE_DICTIONARY and rule.has("rule_id"): + known_rule_ids.append(rule["rule_id"]) + + var count := 0 + for path in DataLoader.find_json_files(dir_path): + count += 1 + var schema_errors := DataLoader.validate_file(path, "mission") + failures.append_array(schema_errors) + if not schema_errors.is_empty(): + continue + var data: Dictionary = DataLoader.read_json(path) + if not known_rule_ids.has(data["rule_id"]): + var message := ( + "%s : rule_id '%s' ne correspond a aucune regle de data/rules" + % [path, data["rule_id"]] + ) + failures.append(message) + return count + + func _validate_exercises(dir_path: String, failures: Array[String]) -> int: var count := 0 for path in DataLoader.find_json_files(dir_path): diff --git a/scripts/profiles/progress_updates.gd b/scripts/profiles/progress_updates.gd index 64de70b..783969e 100644 --- a/scripts/profiles/progress_updates.gd +++ b/scripts/profiles/progress_updates.gd @@ -58,3 +58,14 @@ static func unlock_region(save_data: Dictionary, region_id: String) -> Dictionar regions.append(region_id) updated["unlocked_regions"] = regions return updated + + +## Les recompenses valorisent l'effort et la progression, jamais la vitesse +## (cahier des charges §2, principe 6) : un nombre fixe d'etoiles par mission +## terminee, quel que soit le temps pris ou le nombre d'aides utilisees. +static func add_stars(save_data: Dictionary, amount: int) -> Dictionary: + var updated: Dictionary = save_data.duplicate(true) + var rewards: Dictionary = updated["rewards"].duplicate(true) + rewards["stars"] = int(rewards["stars"]) + amount + updated["rewards"] = rewards + return updated diff --git a/tests/data/test_exercise_picker.gd b/tests/data/test_exercise_picker.gd new file mode 100644 index 0000000..649c05f --- /dev/null +++ b/tests/data/test_exercise_picker.gd @@ -0,0 +1,33 @@ +extends GutTest +## Tests de ExercisePicker. + +var rng := RandomNumberGenerator.new() + + +func before_each() -> void: + rng.seed = 42 + + +func test_empty_candidates_returns_null() -> void: + var candidates: Array[Dictionary] = [] + assert_null(ExercisePicker.pick(candidates, "", rng)) + + +func test_picks_among_candidates() -> void: + var candidates: Array[Dictionary] = [{"id": "a"}, {"id": "b"}, {"id": "c"}] + var picked: Dictionary = ExercisePicker.pick(candidates, "", rng) + var ids: Array = candidates.map(func(c: Dictionary) -> String: return c["id"]) + assert_true(ids.has(picked["id"])) + + +func test_never_returns_the_excluded_id_when_alternatives_exist() -> void: + var candidates: Array[Dictionary] = [{"id": "a"}, {"id": "b"}] + for _i in 20: + var picked: Dictionary = ExercisePicker.pick(candidates, "a", rng) + assert_eq(picked["id"], "b") + + +func test_returns_the_only_candidate_even_if_excluded() -> void: + var candidates: Array[Dictionary] = [{"id": "a"}] + var picked: Dictionary = ExercisePicker.pick(candidates, "a", rng) + assert_eq(picked["id"], "a") diff --git a/tests/data/test_exercise_picker.gd.uid b/tests/data/test_exercise_picker.gd.uid new file mode 100644 index 0000000..a631a68 --- /dev/null +++ b/tests/data/test_exercise_picker.gd.uid @@ -0,0 +1 @@ +uid://kcnp1vmrq6cm diff --git a/tests/data/test_schema_validator.gd b/tests/data/test_schema_validator.gd index aa1c0e0..90f212e 100644 --- a/tests/data/test_schema_validator.gd +++ b/tests/data/test_schema_validator.gd @@ -68,3 +68,15 @@ func test_numeric_bounds_are_checked() -> void: var errors := SchemaValidator.validate(data, schema) assert_eq(errors.size(), 1) assert_string_contains(errors[0], "difficulty") + + +func test_additional_properties_as_schema_validates_dynamic_keys() -> void: + var dynamic_schema := { + "type": "object", + "additionalProperties": {"type": "integer", "minimum": 0}, + } + assert_eq(SchemaValidator.validate({"a": 1, "b": 2}, dynamic_schema), []) + + var errors := SchemaValidator.validate({"a": 1, "b": -5}, dynamic_schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "b") diff --git a/tests/data/test_ui_text.gd b/tests/data/test_ui_text.gd new file mode 100644 index 0000000..91b1176 --- /dev/null +++ b/tests/data/test_ui_text.gd @@ -0,0 +1,12 @@ +extends GutTest +## Tests de UIText : les textes destines a l'enfant viennent de +## data/localization/fr.json, jamais d'une chaine en dur. + + +func test_known_key_resolves_to_real_text() -> void: + assert_eq(UIText.get_text("profile_select.title"), "Choisis ton profil") + + +func test_unknown_key_falls_back_to_the_key_itself() -> void: + assert_eq(UIText.get_text("cle_qui_n_existe_pas"), "cle_qui_n_existe_pas") + assert_push_error("Cle de traduction manquante") diff --git a/tests/data/test_ui_text.gd.uid b/tests/data/test_ui_text.gd.uid new file mode 100644 index 0000000..4f0208b --- /dev/null +++ b/tests/data/test_ui_text.gd.uid @@ -0,0 +1 @@ +uid://dqmtnspy2p6q3 diff --git a/tests/exercises/test_exercise_engine.gd b/tests/exercises/test_exercise_engine.gd new file mode 100644 index 0000000..bfddad5 --- /dev/null +++ b/tests/exercises/test_exercise_engine.gd @@ -0,0 +1,55 @@ +extends GutTest +## Tests de l'autoload ExerciseEngine, charge au demarrage avec les vraies +## donnees de data/ (deja validees par make validate-data). + + +func test_known_rule_is_loaded() -> void: + var rule := ExerciseEngine.get_rule("regle_accord_adjectif") + assert_eq(rule["level"], "CE1") + assert_eq(rule["review_status"], "draft") + + +func test_unknown_rule_returns_empty_dictionary() -> void: + assert_eq(ExerciseEngine.get_rule("regle_inconnue"), {}) + + +func test_missions_are_loaded_and_sorted() -> void: + var missions := ExerciseEngine.get_missions() + assert_eq(missions.size(), 3) + var ids: Array = missions.map(func(m: Dictionary) -> String: return m["mission_id"]) + assert_eq(ids, ids.duplicate()) # trivialement vrai, documente l'ordre stable + assert_true(ids.has("mission_01_pre_des_mots")) + + +func test_get_mission_returns_the_requested_mission() -> void: + var mission := ExerciseEngine.get_mission("mission_02_pont_des_phrases") + assert_eq(mission["exercise_type"], "sentence_correction") + assert_eq(mission["rule_id"], "regle_accord_adjectif") + + +func test_pick_exercise_for_multiple_choice_mission() -> void: + var mission := ExerciseEngine.get_mission("mission_01_pre_des_mots") + var exercise := ExerciseEngine.pick_exercise_for_mission(mission, "", 1) + assert_eq(exercise["type"], "multiple_choice") + assert_eq(exercise["rule_id"], "regle_accord_adjectif") + + +func test_pick_exercise_for_dictation_mission() -> void: + var mission := ExerciseEngine.get_mission("mission_03_clairiere_echo") + var exercise := ExerciseEngine.pick_exercise_for_mission(mission, "", 1) + assert_true(exercise.has("target_text")) + assert_true(exercise.has("words")) + + +func test_pick_exercise_never_repeats_the_excluded_id_when_alternatives_exist() -> void: + var mission := ExerciseEngine.get_mission("mission_01_pre_des_mots") + var first := ExerciseEngine.pick_exercise_for_mission(mission, "", 1) + for _i in 10: + var picked := ExerciseEngine.pick_exercise_for_mission(mission, first["id"], 1) + assert_ne(picked["id"], first["id"]) + + +func test_pick_exercise_falls_back_to_any_difficulty_when_none_match() -> void: + var mission := ExerciseEngine.get_mission("mission_01_pre_des_mots") + var exercise := ExerciseEngine.pick_exercise_for_mission(mission, "", 3) + assert_eq(exercise["type"], "multiple_choice") diff --git a/tests/exercises/test_exercise_engine.gd.uid b/tests/exercises/test_exercise_engine.gd.uid new file mode 100644 index 0000000..0212106 --- /dev/null +++ b/tests/exercises/test_exercise_engine.gd.uid @@ -0,0 +1 @@ +uid://bgu2pw3a67ve2 diff --git a/tests/exercises/test_exercise_host_flow.gd b/tests/exercises/test_exercise_host_flow.gd new file mode 100644 index 0000000..13ec3c9 --- /dev/null +++ b/tests/exercises/test_exercise_host_flow.gd @@ -0,0 +1,88 @@ +extends GutTest +## Test d'integration de exercise_host : regle -> exercice -> validation -> +## enregistrement de la tentative. S'arrete avant la fin de la mission : aller +## jusqu'au bout declencherait un vrai changement de scene (GameManager.goto), +## ce qui casserait le processus de test lui-meme. + +var _profile_id: String = "" + + +func before_each() -> void: + _profile_id = ProfileManager.create_profile("Test", "CE1") + ProfileManager.select_profile(_profile_id) + GameManager.pending_mission_id = "mission_01_pre_des_mots" + + +func after_each() -> void: + ProfileManager.delete_profile(_profile_id) + GameManager.pending_mission_id = "" + + +func _start_host() -> Control: + var host: Control = load("res://scenes/exercises/exercise_host.tscn").instantiate() + add_child_autofree(host) + await wait_process_frames(1) + return host + + +func test_rule_panel_is_shown_before_any_exercise() -> void: + var host := await _start_host() + var overlay: Control = host.get_node("%OverlayContainer") + assert_eq(overlay.get_child_count(), 1) + assert_eq(host.get_node("%ExerciseContainer").get_child_count(), 0) + + +func test_dismissing_rule_panel_loads_the_first_exercise() -> void: + var host := await _start_host() + var overlay: Control = host.get_node("%OverlayContainer") + overlay.get_child(0).continued.emit() + await wait_process_frames(1) + + assert_eq(overlay.get_child_count(), 0) + assert_eq(host.get_node("%ExerciseContainer").get_child_count(), 1) + assert_eq(host.get_current_exercise()["rule_id"], "regle_accord_adjectif") + + +func test_correct_answer_records_a_successful_attempt() -> void: + var host := await _start_host() + var overlay: Control = host.get_node("%OverlayContainer") + overlay.get_child(0).continued.emit() + await wait_process_frames(1) + + var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0) + var correct_index: int = host.get_current_exercise()["correct_answer"] + exercise_scene.answer_submitted.emit(correct_index) + await wait_process_frames(1) + + assert_eq(overlay.get_child_count(), 1, "le panneau de retour doit s'afficher") + var skill_state := ProgressManager.get_skill_state("accord_groupe_nominal") + assert_eq(skill_state["recent_results"], [true]) + assert_eq(skill_state["recent_hint_used"], [false]) + + +func test_wrong_answer_escalates_hint_then_explanation_then_reveal() -> void: + var host := await _start_host() + var overlay: Control = host.get_node("%OverlayContainer") + overlay.get_child(0).continued.emit() + await wait_process_frames(1) + + var exercise: Dictionary = host.get_current_exercise() + var wrong_index := 0 if exercise["correct_answer"] != 0 else 1 + + for _i in 3: + var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0) + exercise_scene.answer_submitted.emit(wrong_index) + await wait_process_frames(1) + var feedback: Control = overlay.get_child(overlay.get_child_count() - 1) + feedback.continue_pressed.emit() + await wait_process_frames(1) + + # Apres 3 echecs, l'exercice est termine (reponse montree) et un nouveau + # commence : le compteur de tentative revient a 1, et une mission n'est + # pas encore terminee (longueur 3, un seul exercice traite ici). + assert_eq(host.get_attempt_number(), 1) + assert_eq(host.get_completed_count(), 1) + + var skill_state := ProgressManager.get_skill_state("accord_groupe_nominal") + assert_eq(skill_state["recent_results"], [false, false, false]) + assert_eq(skill_state["recent_hint_used"], [false, true, true]) diff --git a/tests/exercises/test_exercise_host_flow.gd.uid b/tests/exercises/test_exercise_host_flow.gd.uid new file mode 100644 index 0000000..c10743f --- /dev/null +++ b/tests/exercises/test_exercise_host_flow.gd.uid @@ -0,0 +1 @@ +uid://2stiedq17om diff --git a/tests/profiles/test_progress_updates.gd b/tests/profiles/test_progress_updates.gd index 72bbf9b..de0437d 100644 --- a/tests/profiles/test_progress_updates.gd +++ b/tests/profiles/test_progress_updates.gd @@ -81,3 +81,10 @@ func test_unlock_region_adds_region_once() -> void: func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void: var data := _base_save() assert_eq(ProgressUpdates.next_difficulty_for_skill(data, "inconnue", 2), 2) + + +func test_add_stars_accumulates() -> void: + var data := _base_save() + data = ProgressUpdates.add_stars(data, 1) + data = ProgressUpdates.add_stars(data, 2) + assert_eq(data["rewards"]["stars"], 3)