mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
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:
@@ -1,3 +1,26 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio provisoire des dictees.
|
## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio
|
||||||
## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction).
|
## 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
@@ -1,3 +1,133 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Chargement et deroulement des exercices a partir des donnees JSON validees.
|
## 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
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Cycle de vie de l'application, navigation entre scenes et etat general du jeu.
|
## Cycle de vie de l'application, navigation entre scenes et etat general du
|
||||||
## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction).
|
## 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)
|
||||||
|
|||||||
@@ -31,5 +31,22 @@ func list_profiles() -> Array[String]:
|
|||||||
return SaveManager.list_profile_ids()
|
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:
|
func _generate_profile_id() -> String:
|
||||||
return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000]
|
return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000]
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ func unlock_region(region_id: String) -> void:
|
|||||||
_current_save = ProgressUpdates.unlock_region(_current_save, region_id)
|
_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:
|
func next_difficulty_for_skill(skill_id: String, current_difficulty: int) -> int:
|
||||||
return ProgressUpdates.next_difficulty_for_skill(_current_save, skill_id, current_difficulty)
|
return ProgressUpdates.next_difficulty_for_skill(_current_save, skill_id, current_difficulty)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,36 @@
|
|||||||
"school_level": { "type": "string", "enum": ["CE1", "CE2"] },
|
"school_level": { "type": "string", "enum": ["CE1", "CE2"] },
|
||||||
"session_count": { "type": "integer", "minimum": 0 },
|
"session_count": { "type": "integer", "minimum": 0 },
|
||||||
"unlocked_regions": { "type": "array", "items": { "type": "string" } },
|
"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": {
|
"rewards": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["stars", "items"],
|
"required": ["stars", "items"],
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- `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).
|
- 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.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dwydfsnakvxqi
|
||||||
@@ -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"]
|
[node name="Boot" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dlcbxmv3v7wx6
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dacf070bahaho
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://co82y6w81weqr
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://5sbuvx3101u0
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://l2dvw15a75u1
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cs3pt5155hvds
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://j44owty381sk
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hst702e112g7
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://calghe6tbv2gj
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://2piy37b6koyw
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bicux8hrq0kpc
|
||||||
@@ -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
|
||||||
@@ -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)]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://deb67kr6jsoe
|
||||||
@@ -81,8 +81,13 @@ static func _validate_object(
|
|||||||
for key: String in value.keys():
|
for key: String in value.keys():
|
||||||
if properties.has(key):
|
if properties.has(key):
|
||||||
_validate_node(value[key], properties[key], "%s.%s" % [path, key], errors)
|
_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])
|
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(
|
static func _validate_array(
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ccyua7ei6gssv
|
||||||
@@ -12,6 +12,8 @@ func _init() -> void:
|
|||||||
checked += _validate_simple_dir("res://data/curriculum", "curriculum", failures)
|
checked += _validate_simple_dir("res://data/curriculum", "curriculum", failures)
|
||||||
checked += _validate_dictations("res://data/dictations", failures)
|
checked += _validate_dictations("res://data/dictations", failures)
|
||||||
checked += _validate_exercises("res://data/exercises", 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():
|
if failures.is_empty():
|
||||||
print("validate-data : %d fichier(s) JSON valide(s)" % checked)
|
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
|
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:
|
func _validate_exercises(dir_path: String, failures: Array[String]) -> int:
|
||||||
var count := 0
|
var count := 0
|
||||||
for path in DataLoader.find_json_files(dir_path):
|
for path in DataLoader.find_json_files(dir_path):
|
||||||
|
|||||||
@@ -58,3 +58,14 @@ static func unlock_region(save_data: Dictionary, region_id: String) -> Dictionar
|
|||||||
regions.append(region_id)
|
regions.append(region_id)
|
||||||
updated["unlocked_regions"] = regions
|
updated["unlocked_regions"] = regions
|
||||||
return updated
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kcnp1vmrq6cm
|
||||||
@@ -68,3 +68,15 @@ func test_numeric_bounds_are_checked() -> void:
|
|||||||
var errors := SchemaValidator.validate(data, schema)
|
var errors := SchemaValidator.validate(data, schema)
|
||||||
assert_eq(errors.size(), 1)
|
assert_eq(errors.size(), 1)
|
||||||
assert_string_contains(errors[0], "difficulty")
|
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")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dqmtnspy2p6q3
|
||||||
@@ -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
|
||||||
@@ -81,3 +81,10 @@ func test_unlock_region_adds_region_once() -> void:
|
|||||||
func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void:
|
func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void:
|
||||||
var data := _base_save()
|
var data := _base_save()
|
||||||
assert_eq(ProgressUpdates.next_difficulty_for_skill(data, "inconnue", 2), 2)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user