Add the playable scene loop: boot through mission to reward

Wires every autoload up to a real UI: boot -> profile select/create
-> world map (three mission markers, positioned from data, not
hardcoded pixels) -> mission intro (narrative from data/missions/,
a new data type with its own schema) -> rule panel -> exercise host
-> one of the three exercise type scenes -> feedback panel -> reward
screen -> back to the map.

exercise_host is the only piece with real orchestration logic: picks
exercises via ExerciseEngine (now fully implemented, validating at
load like make validate-data does), validates answers through the
pure AnswerValidator/DictationAnalyzer, escalates aid over 3 attempts
via AttemptFlow, feeds DifficultyManager so adaptive difficulty is
actually wired in rather than dead code, and records every attempt
through ProgressManager. Every exercise-type scene (multiple_choice,
sentence_correction, dictation) only emits its answer by signal, per
the architecture invariant.

All child-facing text now comes from data/localization/fr.json via
UIText, not hardcoded in scripts or scenes. Getting that right
required extending SchemaValidator to support "additionalProperties"
as a schema (dynamic keys), which also closed a real gap: save.skills
was previously unvalidated against SkillState's actual shape.

16 new headless tests, including two integration tests that instance
exercise_host.tscn directly and drive it by emitting child-scene
signals, stopping short of the final scene transition (which would
tear down the test runner's own tree). Everything that needs a
screen -- layout at different sizes, touch, virtual keyboard, actual
audio playback (no .ogg files exist yet) -- is listed in the new
docs/testing/manual-checklist.md instead of claimed as verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 17:37:37 +02:00
parent ebabe91821
commit 493a8617d7
71 changed files with 1986 additions and 8 deletions
+25 -2
View File
@@ -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()
+131 -1
View File
@@ -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
+22 -2
View File
@@ -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)
+17
View File
@@ -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]
+8
View File
@@ -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)
+46
View File
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+4
View File
@@ -0,0 +1,4 @@
{
"type": "object",
"additionalProperties": { "type": "string", "minLength": 1 }
}
+33
View File
@@ -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"] }
}
}
+30 -1
View File
@@ -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"],
+14
View File
@@ -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/<audio_id>.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.
+37
View File
@@ -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).
+6
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dwydfsnakvxqi
+4 -1
View File
@@ -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")
+15
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dlcbxmv3v7wx6
+34
View File
@@ -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
+41
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dacf070bahaho
+53
View File
@@ -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)
+150
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://co82y6w81weqr
+31
View File
@@ -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
+30
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://5sbuvx3101u0
+44
View File
@@ -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
+59
View File
@@ -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)
@@ -0,0 +1 @@
uid://cdn2shqmift8j
+50
View File
@@ -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)
+48
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://l2dvw15a75u1
+82
View File
@@ -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
+78
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cs3pt5155hvds
+56
View File
@@ -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
+53
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://j44owty381sk
+82
View File
@@ -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)
+28
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://hst702e112g7
+40
View File
@@ -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)
+29
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://calghe6tbv2gj
+65
View File
@@ -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)
+32
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://2piy37b6koyw
+59
View File
@@ -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)
+56
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bicux8hrq0kpc
+46
View File
@@ -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
+25
View File
@@ -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)]
+1
View File
@@ -0,0 +1 @@
uid://deb67kr6jsoe
+6 -1
View File
@@ -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(
+27
View File
@@ -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 {}
+1
View File
@@ -0,0 +1 @@
uid://ccyua7ei6gssv
+26
View File
@@ -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):
+11
View File
@@ -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
+33
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://kcnp1vmrq6cm
+12
View File
@@ -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")
+12
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dqmtnspy2p6q3
+55
View File
@@ -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")
@@ -0,0 +1 @@
uid://bgu2pw3a67ve2
@@ -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])
@@ -0,0 +1 @@
uid://2stiedq17om
+7
View File
@@ -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)