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
|
||||
## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio provisoire des dictees.
|
||||
## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction).
|
||||
## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio
|
||||
## provisoire des dictees. Aucun fichier audio n'est encore fourni (etape
|
||||
## "Graphismes et audio provisoires", a venir) : play_dictation se degrade
|
||||
## silencieusement (avertissement journalise) si le fichier est absent,
|
||||
## plutot que de faire planter le jeu.
|
||||
|
||||
const DICTATION_AUDIO_DIR := "res://assets/audio/dictations"
|
||||
|
||||
var _player: AudioStreamPlayer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_player = AudioStreamPlayer.new()
|
||||
add_child(_player)
|
||||
|
||||
|
||||
func play_dictation(audio_id: String) -> void:
|
||||
if audio_id.is_empty():
|
||||
return
|
||||
var path := "%s/%s.ogg" % [DICTATION_AUDIO_DIR, audio_id]
|
||||
if not ResourceLoader.exists(path):
|
||||
push_warning("Audio de dictee introuvable (pas encore genere) : %s" % path)
|
||||
return
|
||||
_player.stream = load(path)
|
||||
_player.play()
|
||||
|
||||
+131
-1
@@ -1,3 +1,133 @@
|
||||
extends Node
|
||||
## Chargement et deroulement des exercices a partir des donnees JSON validees.
|
||||
## Logique detaillee a partir de l'etape "Donnees" (voir Ordre de construction).
|
||||
## Valide chaque fichier au chargement avec le meme validateur que
|
||||
## `make validate-data` (DataLoader/SchemaValidator, scripts/data/) : un
|
||||
## fichier invalide est ignore (avec une erreur journalisee), jamais charge
|
||||
## tel quel. Le tirage au sort d'un exercice est delegue a ExercisePicker,
|
||||
## pur, avec un generateur aleatoire possede ici (voir CLAUDE.md : le hasard
|
||||
## est injecte dans la logique pure, pas lu directement par elle).
|
||||
|
||||
var _rules: Dictionary = {}
|
||||
var _exercises_by_rule_and_type: Dictionary = {}
|
||||
var _dictations_by_rule: Dictionary = {}
|
||||
var _missions: Dictionary = {}
|
||||
var _mission_order: Array[String] = []
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_rng.randomize()
|
||||
_load_rules()
|
||||
_load_exercises()
|
||||
_load_dictations()
|
||||
_load_missions()
|
||||
|
||||
|
||||
func get_rule(rule_id: String) -> Dictionary:
|
||||
return _rules.get(rule_id, {})
|
||||
|
||||
|
||||
func get_missions() -> Array[Dictionary]:
|
||||
var missions: Array[Dictionary] = []
|
||||
for mission_id in _mission_order:
|
||||
missions.append(_missions[mission_id])
|
||||
return missions
|
||||
|
||||
|
||||
func get_mission(mission_id: String) -> Dictionary:
|
||||
return _missions.get(mission_id, {})
|
||||
|
||||
|
||||
## Tire le prochain exercice (ou dictee) pour une mission, au plus proche de
|
||||
## `difficulty`, sans redonner `exclude_id`. Dictionnaire vide si la mission
|
||||
## n'a aucun contenu disponible.
|
||||
func pick_exercise_for_mission(
|
||||
mission: Dictionary, exclude_id: String, difficulty: int
|
||||
) -> Dictionary:
|
||||
var rule_id: String = mission["rule_id"]
|
||||
var exercise_type: String = mission["exercise_type"]
|
||||
var candidates: Array[Dictionary] = _candidates_for(rule_id, exercise_type)
|
||||
var filtered := _filter_by_difficulty(candidates, difficulty)
|
||||
var picked: Variant = ExercisePicker.pick(filtered, exclude_id, _rng)
|
||||
return picked if picked != null else {}
|
||||
|
||||
|
||||
## Ne garde que les candidats a la difficulte demandee ; si aucun ne la
|
||||
## propose, retombe sur l'ensemble des candidats plutot que de n'en donner
|
||||
## aucun (le contenu disponible est encore limite, voir docs/specs/decisions.md).
|
||||
func _filter_by_difficulty(candidates: Array[Dictionary], difficulty: int) -> Array[Dictionary]:
|
||||
var exact: Array[Dictionary] = []
|
||||
for candidate in candidates:
|
||||
if int(candidate.get("difficulty", difficulty)) == difficulty:
|
||||
exact.append(candidate)
|
||||
return exact if not exact.is_empty() else candidates
|
||||
|
||||
|
||||
func _candidates_for(rule_id: String, exercise_type: String) -> Array[Dictionary]:
|
||||
if exercise_type == "dictation":
|
||||
return _dictations_by_rule.get(rule_id, [] as Array[Dictionary])
|
||||
var by_type: Dictionary = _exercises_by_rule_and_type.get(rule_id, {})
|
||||
return by_type.get(exercise_type, [] as Array[Dictionary])
|
||||
|
||||
|
||||
func _load_rules() -> void:
|
||||
for path in DataLoader.find_json_files("res://data/rules"):
|
||||
var errors := DataLoader.validate_file(path, "rule")
|
||||
if not errors.is_empty():
|
||||
push_error("Regle ignoree (%s) : %s" % [path, errors])
|
||||
continue
|
||||
var rule: Dictionary = DataLoader.read_json(path)
|
||||
_rules[rule["rule_id"]] = rule
|
||||
|
||||
|
||||
func _load_exercises() -> void:
|
||||
for path in DataLoader.find_json_files("res://data/exercises"):
|
||||
var raw: Variant = DataLoader.read_json(path)
|
||||
if typeof(raw) != TYPE_DICTIONARY or not raw.has("type"):
|
||||
push_error("Exercice ignore (%s) : type manquant ou JSON invalide" % path)
|
||||
continue
|
||||
var exercise: Dictionary = raw
|
||||
var exercise_type: String = exercise["type"]
|
||||
var schema_name := DataLoader.exercise_schema_name_for_type(exercise_type)
|
||||
if schema_name.is_empty():
|
||||
push_error("Exercice ignore (%s) : type inconnu '%s'" % [path, exercise_type])
|
||||
continue
|
||||
var errors := SchemaValidator.validate(exercise, DataLoader.load_schema(schema_name))
|
||||
if not errors.is_empty():
|
||||
push_error("Exercice ignore (%s) : %s" % [path, errors])
|
||||
continue
|
||||
|
||||
var rule_id: String = exercise["rule_id"]
|
||||
if not _exercises_by_rule_and_type.has(rule_id):
|
||||
_exercises_by_rule_and_type[rule_id] = {}
|
||||
var by_type: Dictionary = _exercises_by_rule_and_type[rule_id]
|
||||
if not by_type.has(exercise_type):
|
||||
by_type[exercise_type] = [] as Array[Dictionary]
|
||||
(by_type[exercise_type] as Array[Dictionary]).append(exercise)
|
||||
|
||||
|
||||
func _load_dictations() -> void:
|
||||
for path in DataLoader.find_json_files("res://data/dictations"):
|
||||
var errors := DataLoader.validate_file(path, "dictation")
|
||||
if not errors.is_empty():
|
||||
push_error("Dictee ignoree (%s) : %s" % [path, errors])
|
||||
continue
|
||||
var dictation: Dictionary = DataLoader.read_json(path)
|
||||
for rule_id: String in dictation["rule_ids"]:
|
||||
if not _dictations_by_rule.has(rule_id):
|
||||
_dictations_by_rule[rule_id] = [] as Array[Dictionary]
|
||||
(_dictations_by_rule[rule_id] as Array[Dictionary]).append(dictation)
|
||||
|
||||
|
||||
func _load_missions() -> void:
|
||||
var mission_ids: Array[String] = []
|
||||
for path in DataLoader.find_json_files("res://data/missions"):
|
||||
var errors := DataLoader.validate_file(path, "mission")
|
||||
if not errors.is_empty():
|
||||
push_error("Mission ignoree (%s) : %s" % [path, errors])
|
||||
continue
|
||||
var mission: Dictionary = DataLoader.read_json(path)
|
||||
_missions[mission["mission_id"]] = mission
|
||||
mission_ids.append(mission["mission_id"])
|
||||
mission_ids.sort()
|
||||
_mission_order = mission_ids
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
extends Node
|
||||
## Cycle de vie de l'application, navigation entre scenes et etat general du jeu.
|
||||
## Logique detaillee a partir de l'etape "Scenes" (voir Ordre de construction).
|
||||
## Cycle de vie de l'application, navigation entre scenes et etat general du
|
||||
## jeu. Porte le contexte transitoire necessaire entre deux changements de
|
||||
## scene (ex. la mission choisie sur la carte), puisque Godot libere les
|
||||
## noeuds de la scene precedente a chaque changement.
|
||||
|
||||
const SCENE_BOOT := "res://scenes/boot/boot.tscn"
|
||||
const SCENE_PROFILE_SELECT := "res://scenes/profiles/profile_select.tscn"
|
||||
const SCENE_PROFILE_CREATE := "res://scenes/profiles/profile_create.tscn"
|
||||
const SCENE_WORLD_MAP := "res://scenes/world/world_map.tscn"
|
||||
const SCENE_MISSION_INTRO := "res://scenes/world/mission_intro.tscn"
|
||||
const SCENE_EXERCISE_HOST := "res://scenes/exercises/exercise_host.tscn"
|
||||
const SCENE_REWARD := "res://scenes/ui/reward_screen.tscn"
|
||||
|
||||
## Mission choisie sur la carte, lue par mission_intro puis exercise_host et
|
||||
## reward_screen. Remise a vide par reward_screen au retour sur la carte.
|
||||
var pending_mission_id: String = ""
|
||||
|
||||
|
||||
func goto(scene_path: String) -> void:
|
||||
# Differe le changement de scene : l'appeler depuis le _ready() de la
|
||||
# scene en cours d'ajout a l'arbre echoue sinon ("Parent node is busy").
|
||||
get_tree().change_scene_to_file.call_deferred(scene_path)
|
||||
|
||||
@@ -31,5 +31,22 @@ func list_profiles() -> Array[String]:
|
||||
return SaveManager.list_profile_ids()
|
||||
|
||||
|
||||
## Resume de chaque profil pour l'ecran de selection : profile_id, display_name
|
||||
## et school_level, sans charger toute la progression.
|
||||
func list_profile_summaries() -> Array[Dictionary]:
|
||||
var summaries: Array[Dictionary] = []
|
||||
for profile_id in list_profiles():
|
||||
var data: Variant = SaveManager.load(profile_id)
|
||||
if data == null:
|
||||
continue
|
||||
var summary := {
|
||||
"profile_id": profile_id,
|
||||
"display_name": data["display_name"],
|
||||
"school_level": data["school_level"],
|
||||
}
|
||||
summaries.append(summary)
|
||||
return summaries
|
||||
|
||||
|
||||
func _generate_profile_id() -> String:
|
||||
return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000]
|
||||
|
||||
@@ -37,6 +37,14 @@ func unlock_region(region_id: String) -> void:
|
||||
_current_save = ProgressUpdates.unlock_region(_current_save, region_id)
|
||||
|
||||
|
||||
func add_stars(amount: int) -> void:
|
||||
_current_save = ProgressUpdates.add_stars(_current_save, amount)
|
||||
|
||||
|
||||
func stars() -> int:
|
||||
return _current_save.get("rewards", {}).get("stars", 0)
|
||||
|
||||
|
||||
func next_difficulty_for_skill(skill_id: String, current_difficulty: int) -> int:
|
||||
return ProgressUpdates.next_difficulty_for_skill(_current_save, skill_id, current_difficulty)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user