From dbf513e6861394855b47fe190255bb8788c199b8 Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Sun, 11 Oct 2026 22:49:01 +0200 Subject: [PATCH] Add settings screen and adult space with progress report and save export/restore (0.4.0) Co-Authored-By: Claude Sonnet 5.5 --- README-EN.md | 2 +- README.md | 2 +- assets/interface/slider_grabber.svg | 3 + assets/interface/slider_grabber.svg.import | 43 +++++ assets/interface/theme.tres | 28 ++- autoload/game_manager.gd | 6 + autoload/profile_manager.gd | 5 + autoload/settings_manager.gd | 111 ++++++++++- data/localization/fr.json | 38 +++- docs/specs/decisions.md | 10 + docs/testing/manual-checklist.md | 9 + project.godot | 2 +- scenes/exercises/multiple_choice.gd | 2 +- scenes/exercises/sentence_correction.gd | 2 +- scenes/profiles/profile_select.gd | 8 + scenes/profiles/profile_select.tscn | 6 + scenes/ui/adult_space.gd | 207 +++++++++++++++++++++ scenes/ui/adult_space.gd.uid | 1 + scenes/ui/adult_space.tscn | 139 ++++++++++++++ scenes/ui/settings.gd | 85 +++++++++ scenes/ui/settings.gd.uid | 1 + scenes/ui/settings.tscn | 91 +++++++++ scenes/world/world_map.gd | 8 + scenes/world/world_map.tscn | 9 + scripts/pedagogy/progress_report.gd | 63 +++++++ scripts/pedagogy/progress_report.gd.uid | 1 + scripts/profiles/adult_gate.gd | 24 +++ scripts/profiles/adult_gate.gd.uid | 1 + scripts/profiles/save_transfer.gd | 131 +++++++++++++ scripts/profiles/save_transfer.gd.uid | 1 + scripts/settings/app_settings.gd | 55 ++++++ scripts/settings/app_settings.gd.uid | 1 + tests/data/test_skill_labels.gd | 14 ++ tests/data/test_skill_labels.gd.uid | 1 + tests/pedagogy/test_progress_report.gd | 70 +++++++ tests/pedagogy/test_progress_report.gd.uid | 1 + tests/profiles/test_adult_gate.gd | 33 ++++ tests/profiles/test_adult_gate.gd.uid | 1 + tests/profiles/test_save_transfer.gd | 91 +++++++++ tests/profiles/test_save_transfer.gd.uid | 1 + tests/settings/test_app_settings.gd | 37 ++++ tests/settings/test_app_settings.gd.uid | 1 + tests/world/test_settings_scenes.gd | 31 +++ tests/world/test_settings_scenes.gd.uid | 1 + tools/screenshot.gd | 27 ++- 45 files changed, 1390 insertions(+), 14 deletions(-) create mode 100644 assets/interface/slider_grabber.svg create mode 100644 assets/interface/slider_grabber.svg.import create mode 100644 scenes/ui/adult_space.gd create mode 100644 scenes/ui/adult_space.gd.uid create mode 100644 scenes/ui/adult_space.tscn create mode 100644 scenes/ui/settings.gd create mode 100644 scenes/ui/settings.gd.uid create mode 100644 scenes/ui/settings.tscn create mode 100644 scripts/pedagogy/progress_report.gd create mode 100644 scripts/pedagogy/progress_report.gd.uid create mode 100644 scripts/profiles/adult_gate.gd create mode 100644 scripts/profiles/adult_gate.gd.uid create mode 100644 scripts/profiles/save_transfer.gd create mode 100644 scripts/profiles/save_transfer.gd.uid create mode 100644 scripts/settings/app_settings.gd create mode 100644 scripts/settings/app_settings.gd.uid create mode 100644 tests/data/test_skill_labels.gd create mode 100644 tests/data/test_skill_labels.gd.uid create mode 100644 tests/pedagogy/test_progress_report.gd create mode 100644 tests/pedagogy/test_progress_report.gd.uid create mode 100644 tests/profiles/test_adult_gate.gd create mode 100644 tests/profiles/test_adult_gate.gd.uid create mode 100644 tests/profiles/test_save_transfer.gd create mode 100644 tests/profiles/test_save_transfer.gd.uid create mode 100644 tests/settings/test_app_settings.gd create mode 100644 tests/settings/test_app_settings.gd.uid create mode 100644 tests/world/test_settings_scenes.gd create mode 100644 tests/world/test_settings_scenes.gd.uid diff --git a/README-EN.md b/README-EN.md index c6a284e..47611f3 100644 --- a/README-EN.md +++ b/README-EN.md @@ -2,7 +2,7 @@ An educational French-language game for 7-9 year olds (French grades CE1-CE2): an offline 2D adventure with an explorable map, a guide character, and grammar, spelling, and dictation exercises. -Open source project, in development. Current status: version 0.1.0, playable offline on Linux and Windows — profiles, saves, three exercise types (multiple choice, sentence correction, dictation), a CE1 chapter on noun-group agreement (3 rules) and a CE2 chapter on grammatical homophones (a/à, et/est, son/sont, on/ont), 21 missions. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product). +Open source project, in development. Current status: version 0.4.0, playable offline on Linux and Windows — profiles, saves (with export and restore in the adult area), stars and an item collection, settings (volume, text size, animations), a progress dashboard for adults, three exercise types (multiple choice, sentence correction, dictation), a CE1 chapter on noun-group agreement (3 rules) and a CE2 chapter on grammatical homophones (a/à, et/est, son/sont, on/ont), 21 missions. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product). ## Tech stack diff --git a/README.md b/README.md index 389b2c6..d030db4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Jeu éducatif de français pour les CE1-CE2 (7-9 ans) : une aventure 2D hors ligne, avec une carte à explorer, un personnage guide, et des exercices de grammaire, d'orthographe et de dictées. -Projet open source, en développement. Statut actuel : version 0.1.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes, trois types d'exercices (choix multiple, correction de phrase, dictée), chapitre CE1 « accords dans le groupe nominal » (3 règles) et chapitre CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 21 missions. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète). +Projet open source, en développement. Statut actuel : version 0.4.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes (avec export et restauration dans l'espace adulte), étoiles et collection d'objets, réglages (volume, taille du texte, animations), tableau de progrès pour l'adulte, trois types d'exercices (choix multiple, correction de phrase, dictée), chapitre CE1 « accords dans le groupe nominal » (3 règles) et chapitre CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 21 missions. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète). ## Pile technique diff --git a/assets/interface/slider_grabber.svg b/assets/interface/slider_grabber.svg new file mode 100644 index 0000000..887cde5 --- /dev/null +++ b/assets/interface/slider_grabber.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/interface/slider_grabber.svg.import b/assets/interface/slider_grabber.svg.import new file mode 100644 index 0000000..886c65c --- /dev/null +++ b/assets/interface/slider_grabber.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://mqgtoutc6apf" +path="res://.godot/imported/slider_grabber.svg-b318c142ebe6680738fc52de4bcd86af.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/interface/slider_grabber.svg" +dest_files=["res://.godot/imported/slider_grabber.svg-b318c142ebe6680738fc52de4bcd86af.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/interface/theme.tres b/assets/interface/theme.tres index 900a330..ada50dc 100644 --- a/assets/interface/theme.tres +++ b/assets/interface/theme.tres @@ -1,8 +1,28 @@ -[gd_resource type="Theme" load_steps=20 format=3] +[gd_resource type="Theme" load_steps=24 format=3] [ext_resource type="FontFile" path="res://assets/fonts/AtkinsonHyperlegible-Regular.ttf" id="font_regular"] [ext_resource type="FontFile" path="res://assets/fonts/AtkinsonHyperlegible-Bold.ttf" id="font_bold"] +[ext_resource type="Texture2D" path="res://assets/interface/slider_grabber.svg" id="slider_grabber"] + +[sub_resource type="StyleBoxFlat" id="slider_track"] +bg_color = Color(0.82, 0.78, 0.66, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 +content_margin_top = 8 +content_margin_bottom = 8 + +[sub_resource type="StyleBoxFlat" id="slider_fill"] +bg_color = Color(0.184, 0.502, 0.251, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 +content_margin_top = 8 +content_margin_bottom = 8 + [sub_resource type="StyleBoxFlat" id="btn_normal"] bg_color = Color(0.184, 0.502, 0.251, 1) corner_radius_top_left = 18 @@ -334,6 +354,12 @@ SoftCard/base_type = &"PanelContainer" SoftCard/styles/panel = SubResource("card_soft") Pill/base_type = &"PanelContainer" Pill/styles/panel = SubResource("pill") +HSlider/styles/slider = SubResource("slider_track") +HSlider/styles/grabber_area = SubResource("slider_fill") +HSlider/styles/grabber_area_highlight = SubResource("slider_fill") +HSlider/icons/grabber = ExtResource("slider_grabber") +HSlider/icons/grabber_highlight = ExtResource("slider_grabber") +HSlider/icons/grabber_disabled = ExtResource("slider_grabber") LineEdit/colors/font_color = Color(0.149, 0.196, 0.290, 1) LineEdit/colors/font_placeholder_color = Color(0.45, 0.48, 0.56, 1) LineEdit/colors/caret_color = Color(0.149, 0.196, 0.290, 1) diff --git a/autoload/game_manager.gd b/autoload/game_manager.gd index ea954cb..3c9a321 100644 --- a/autoload/game_manager.gd +++ b/autoload/game_manager.gd @@ -12,6 +12,8 @@ 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" const SCENE_COLLECTION := "res://scenes/ui/collection.tscn" +const SCENE_SETTINGS := "res://scenes/ui/settings.tscn" +const SCENE_ADULT_SPACE := "res://scenes/ui/adult_space.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. @@ -21,6 +23,10 @@ var pending_mission_id: String = "" ## (MissionScoring.OUTCOME_*), transmis par exercise_host a reward_screen. var last_outcomes: Array[String] = [] +## Scene a laquelle l'ecran de reglages rend la main (carte ou selection de +## profil, selon d'ou il a ete ouvert). +var settings_return_scene: String = SCENE_WORLD_MAP + func goto(scene_path: String) -> void: # Differe le changement de scene : l'appeler depuis le _ready() de la diff --git a/autoload/profile_manager.gd b/autoload/profile_manager.gd index b5fce37..d5b0d67 100644 --- a/autoload/profile_manager.gd +++ b/autoload/profile_manager.gd @@ -48,5 +48,10 @@ func list_profile_summaries() -> Array[Dictionary]: return summaries +## Identifiant neuf pour un profil restaure depuis une sauvegarde exportee. +func new_profile_id() -> String: + return _generate_profile_id() + + func _generate_profile_id() -> String: return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000] diff --git a/autoload/settings_manager.gd b/autoload/settings_manager.gd index 15e4aa4..e0b9bf3 100644 --- a/autoload/settings_manager.gd +++ b/autoload/settings_manager.gd @@ -1,6 +1,111 @@ extends Node -## Volume, taille du texte et autres preferences de l'enfant ou de l'adulte. -## Pour l'instant : l'interrupteur des animations, lu par Motion. L'ecran de -## reglages qui le pilotera vient avec l'espace adulte (docs/specs/decisions.md). +## Volume, taille du texte et animations. Les reglages sont ceux de l'appareil +## (user://settings.json), pas ceux d'un profil. Les valeurs sont nettoyees par +## AppSettings a chaque lecture ; un fichier absent ou abime redonne les valeurs +## par defaut sans erreur. Le volume agit sur le bus principal ; la taille du +## texte met a l'echelle les tailles du theme global. + +signal changed + +const SETTINGS_PATH := "user://settings.json" var animations_enabled := true +var volume := 0.8 +var text_scale_index := 0 + +var _base_font_sizes: Dictionary = {} +var _base_default_font_size := 0 + + +func _ready() -> void: + load_from_disk() + apply() + + +func text_scale() -> float: + return AppSettings.TEXT_SCALES[text_scale_index] + + +## Taille d'un texte fixe dans un script, mise a l'echelle du reglage. +func scaled_font_size(base: int) -> int: + return int(round(base * text_scale())) + + +func set_volume(value: float) -> void: + volume = clampf(value, 0.0, 1.0) + _commit() + + +func set_text_scale_index(index: int) -> void: + text_scale_index = clampi(index, 0, AppSettings.TEXT_SCALES.size() - 1) + _commit() + + +func set_animations_enabled(enabled: bool) -> void: + animations_enabled = enabled + _commit() + + +func load_from_disk() -> void: + var raw: Variant = null + if FileAccess.file_exists(SETTINGS_PATH): + var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) + if file != null: + raw = JSON.parse_string(file.get_as_text()) + var clean := AppSettings.sanitize(raw) + volume = clean["volume"] + text_scale_index = clean["text_scale_index"] + animations_enabled = clean["animations_enabled"] + + +## Ecriture atomique : fichier temporaire puis remplacement. +func save_to_disk() -> bool: + var data := AppSettings.defaults() + data["volume"] = volume + data["text_scale_index"] = text_scale_index + data["animations_enabled"] = animations_enabled + var tmp_path := SETTINGS_PATH + ".tmp" + var file := FileAccess.open(tmp_path, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(data)) + file.close() + var dir := DirAccess.open("user://") + if dir == null: + return false + return dir.rename(tmp_path, SETTINGS_PATH) == OK + + +## Applique les reglages au moteur : volume du bus principal et tailles du +## theme global. +func apply() -> void: + AudioServer.set_bus_volume_db(0, AppSettings.volume_db(volume)) + _apply_text_scale() + + +func _commit() -> void: + apply() + save_to_disk() + changed.emit() + + +func _apply_text_scale() -> void: + var theme := ThemeDB.get_project_theme() + if theme == null: + return + # Les tailles d'origine sont memorisees une fois : on recalcule toujours + # a partir d'elles pour ne pas accumuler les facteurs. + if _base_font_sizes.is_empty(): + _base_default_font_size = theme.default_font_size + for type_name in theme.get_font_size_type_list(): + for item_name in theme.get_font_size_list(type_name): + _base_font_sizes["%s/%s" % [type_name, item_name]] = theme.get_font_size( + item_name, type_name + ) + var factor := text_scale() + theme.default_font_size = int(round(_base_default_font_size * factor)) + for key: String in _base_font_sizes: + var parts := key.split("/") + if AppSettings.UNSCALED_THEME_TYPES.has(parts[0]): + continue + theme.set_font_size(parts[1], parts[0], int(round(_base_font_sizes[key] * factor))) diff --git a/data/localization/fr.json b/data/localization/fr.json index 3988160..7ac1435 100644 --- a/data/localization/fr.json +++ b/data/localization/fr.json @@ -71,5 +71,41 @@ "world_map.total_stars_label": "Total d'étoiles :", "world_map.mission_stars_label": "Étoiles :", "world_map.collection_button": "Ma collection", - "reward_screen.continue_button": "Retour à la carte" + "reward_screen.continue_button": "Retour à la carte", + "world_map.settings_button": "Réglages", + "profile_select.settings_button": "Réglages", + "settings.title": "Réglages", + "settings.volume_label": "Volume du son", + "settings.text_size_label": "Taille du texte", + "settings.text_size_0": "Normal", + "settings.text_size_1": "Grand", + "settings.text_size_2": "Très grand", + "settings.animations_label": "Animations", + "settings.adult_button": "Espace adulte", + "settings.back_button": "Retour", + "adult.title": "Espace adulte", + "adult.gate_question": "Pour entrer, calcule %d × %d.", + "adult.gate_button": "Entrer", + "adult.gate_error": "Ce n'est pas le bon résultat. Réessaie.", + "adult.no_profile": "Aucun profil pour le moment.", + "adult.profile_unreadable": "La sauvegarde de ce profil est illisible.", + "adult.summary": "Étoiles : %d. Missions réussies : %d sur %d. Objets trouvés : %d sur %d. Séances : %d.", + "adult.no_skills_yet": "Pas encore d'exercice fait : rien à afficher.", + "adult.skill_line": "%s : %s. Réussites récentes : %d sur %d, avec aide : %d.", + "adult.needs_work": "À retravailler : quelques essais récents ont été difficiles.", + "adult.export_button": "Exporter ce profil", + "adult.export_done": "Sauvegarde exportée : %s", + "adult.export_failed": "L'export n'a pas pu être écrit dans le dossier.", + "adult.restore_title": "Restaurer une sauvegarde", + "adult.restore_empty": "Aucune sauvegarde exportée trouvée dans le dossier ci-dessous.", + "adult.restore_entry": "Restaurer %s (%s) — %s", + "adult.restore_invalid": "Fichier illisible : %s", + "adult.restore_done": "Sauvegarde restaurée comme nouveau profil. Aucun profil existant n'a été modifié.", + "adult.restore_failed": "Cette sauvegarde n'a pas pu être restaurée.", + "adult.folder_label": "Dossier des sauvegardes exportées : %s", + "adult.back_button": "Retour aux réglages", + "skill.accord_groupe_nominal": "Accords dans le groupe nominal", + "skill.homophones_grammaticaux": "Homophones grammaticaux", + "settings.animations_on": "Avec animations", + "settings.animations_off": "Sans animations" } diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index d171a12..48f10d0 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -304,3 +304,13 @@ Ajouts à l'arborescence proposée : **Animations** : `Motion` (apparition en rebond, fondu) respecte `SettingsManager.animations_enabled` (cahier des charges §11) ; l'écran de réglages qui le pilotera reste à faire (phase suivante). **Non vérifié** : tactile, clavier virtuel, rendu sur d'autres résolutions que 1152x648, animations en mouvement (les captures montrent l'état final), rendu sous Windows. + +## 32. Phase « Réglages et espace adulte » (version 0.4.0) + +**Réglages de l'appareil** (`SettingsManager`, `AppSettings` pur) : volume, taille du texte (Normal 1,0 / Grand 1,15 / Très grand 1,3) et animations. Ils sont communs à tous les profils et stockés à part, dans `user://settings.json` (écriture atomique, valeurs hors limites ou fichier abîmé ramenés aux défauts sans erreur) : **le format de sauvegarde des profils n'est pas modifié** (toujours `schema_version` 2). Le volume agit sur le bus principal ; la taille du texte met à l'échelle les tailles du thème global, sauf `HeaderTitle`, `CardTitle` et `Window` (déjà grands, ils feraient déborder les barres d'en-tête ; le titre de la carte est tronqué avec « … » au plus grand réglage). Écran « Réglages » accessible sans barrière depuis la carte et le choix du profil (l'enfant doit pouvoir baisser le son). + +**Espace adulte** (depuis les réglages, derrière une barrière) : barrière = un calcul (nombre de 12 à 19 × chiffre de 3 à 9, hasard injecté, `AdultGate`), simple frein à la curiosité et non une protection de sécurité. Contenu : tableau de progrès par profil (étoiles, missions réussies, objets, séances, et pour chaque notion : statut, réussites récentes, aides utilisées) via `ProgressReport` (pur). Une notion est « à retravailler » si son statut est « à réviser » ou si, avec au moins 3 essais récents, moins de 60 % sont réussis : c'est un signal pour l'adulte, formulé sans jugement sur l'enfant. + +**Export et restauration** : l'export écrit la sauvegarde du profil telle quelle (JSON versionné) dans `Documents/AventuriersDesMots/` (repli `user://exports` si la plateforme n'expose pas Documents), sans jamais écraser un export précédent (`_2`, `_3`...). La restauration liste ce dossier, valide chaque fichier (migration + schéma ; les illisibles sont signalés et non restaurables) et crée toujours un NOUVEAU profil avec un identifiant neuf : aucun profil existant n'est jamais modifié. Aucun accès réseau, aucun sélecteur de fichier système (limite assumée : l'adulte dépose le fichier dans le dossier indiqué). + +**Tests** : AppSettings, ProgressReport, AdultGate, SaveTransfer (export, non-écrasement, restauration non destructive, fichiers invalides, dossier absent), intitulés de notions présents dans la traduction, barrière de l'espace adulte. **Non vérifié** (sans humain) : curseur de volume à l'oreille (aucun son d'interface n'existe encore, phase suivante), tactile, export vers un vrai dossier Documents sous Windows, résolutions autres que 1152x648. diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md index 480ede4..2d8e7a5 100644 --- a/docs/testing/manual-checklist.md +++ b/docs/testing/manual-checklist.md @@ -32,6 +32,15 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a - 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é. +## Réglages et espace adulte (point 32 de `decisions.md`) + +- « Réglages » depuis la carte et depuis le choix du profil ; « Retour » revient à l'écran d'où l'on vient. +- Taille du texte : Normal / Grand / Très grand s'applique immédiatement, à tous les écrans, et est conservée après redémarrage. Au plus grand réglage, aucun bouton ne sort de la fenêtre. +- Animations : « Sans animations » supprime les rebonds et fondus (carte, récompense) ; le réglage est conservé après redémarrage. +- Volume : écouter une dictée en déplaçant le curseur (le son baisse, le silence est complet à 0). Aucun son d'interface n'existe encore. +- Espace adulte : une mauvaise réponse à la barrière n'ouvre rien ; la bonne ouvre le tableau de progrès. Après quelques missions, les notions et leurs réussites correspondent à ce qui a été joué. +- Export : « Exporter ce profil » crée un fichier dans `Documents/AventuriersDesMots/` (vérifier le chemin affiché). Restaurer ce fichier crée un nouveau profil identique ; le profil d'origine est intact. Un fichier quelconque déposé dans le dossier est signalé « illisible » et ne peut pas être restauré. + ## Audio - `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). Testé manuellement : voix claire et bien comprise. diff --git a/project.godot b/project.godot index ccb1f44..71aeab4 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Les Aventuriers des mots" -config/version="0.3.0" +config/version="0.4.0" run/main_scene="res://scenes/boot/boot.tscn" config/features=PackedStringArray("4.6", "Forward Plus") config/icon="res://icon.svg" diff --git a/scenes/exercises/multiple_choice.gd b/scenes/exercises/multiple_choice.gd index 6c53a1d..eb16499 100644 --- a/scenes/exercises/multiple_choice.gd +++ b/scenes/exercises/multiple_choice.gd @@ -22,7 +22,7 @@ func load_exercise(exercise: Dictionary) -> void: var button := Button.new() button.text = answers[i] button.custom_minimum_size = Vector2(0, 64) - button.add_theme_font_size_override("font_size", 28) + button.add_theme_font_size_override("font_size", SettingsManager.scaled_font_size(28)) button.pressed.connect(_on_answer_pressed.bind(i)) _answers_box.add_child(button) diff --git a/scenes/exercises/sentence_correction.gd b/scenes/exercises/sentence_correction.gd index 2ed80fb..da231be 100644 --- a/scenes/exercises/sentence_correction.gd +++ b/scenes/exercises/sentence_correction.gd @@ -35,7 +35,7 @@ func load_exercise(exercise: Dictionary) -> void: button.toggle_mode = true button.theme_type_variation = &"SecondaryButton" button.custom_minimum_size = Vector2(0, 60) - button.add_theme_font_size_override("font_size", 30) + button.add_theme_font_size_override("font_size", SettingsManager.scaled_font_size(30)) button.toggled.connect(_on_token_toggled.bind(i)) _tokens_box.add_child(button) diff --git a/scenes/profiles/profile_select.gd b/scenes/profiles/profile_select.gd index 82cb73d..0588ec2 100644 --- a/scenes/profiles/profile_select.gd +++ b/scenes/profiles/profile_select.gd @@ -8,6 +8,7 @@ var _pending_delete_profile_id: String = "" @onready var _profile_list: VBoxContainer = %ProfileList @onready var _empty_state_label: Label = %EmptyStateLabel @onready var _new_profile_button: Button = %NewProfileButton +@onready var _settings_button: Button = %SettingsButton @onready var _delete_confirm_dialog: ConfirmationDialog = %DeleteConfirmDialog @@ -22,6 +23,8 @@ func _ready() -> void: "profile_select.delete_confirm_no" ) + _settings_button.text = UIText.get_text("profile_select.settings_button") + _settings_button.pressed.connect(_on_settings_pressed) _new_profile_button.pressed.connect(_on_new_profile_pressed) _delete_confirm_dialog.confirmed.connect(_on_delete_confirmed) @@ -79,3 +82,8 @@ func _on_delete_confirmed() -> void: func _on_new_profile_pressed() -> void: GameManager.goto(GameManager.SCENE_PROFILE_CREATE) + + +func _on_settings_pressed() -> void: + GameManager.settings_return_scene = GameManager.SCENE_PROFILE_SELECT + GameManager.goto(GameManager.SCENE_SETTINGS) diff --git a/scenes/profiles/profile_select.tscn b/scenes/profiles/profile_select.tscn index cf6a0d4..71d0381 100644 --- a/scenes/profiles/profile_select.tscn +++ b/scenes/profiles/profile_select.tscn @@ -70,5 +70,11 @@ unique_name_in_owner = true layout_mode = 2 custom_minimum_size = Vector2(0, 56) +[node name="SettingsButton" type="Button" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"SecondaryButton" +custom_minimum_size = Vector2(0, 52) + [node name="DeleteConfirmDialog" type="ConfirmationDialog" parent="."] unique_name_in_owner = true diff --git a/scenes/ui/adult_space.gd b/scenes/ui/adult_space.gd new file mode 100644 index 0000000..ab807d4 --- /dev/null +++ b/scenes/ui/adult_space.gd @@ -0,0 +1,207 @@ +extends Control +## Espace adulte (cahier des charges §9 et §10) : tableau de progres par +## profil, notions a retravailler, export et restauration des sauvegardes. +## Derriere une petite barriere (AdultGate). Aucune action ici n'est +## destructive : la restauration cree un nouveau profil, l'export ecrit un +## nouveau fichier. + +var _challenge: Dictionary = {} +var _rng := RandomNumberGenerator.new() +var _selected_profile_id := "" +var _schema: Dictionary = {} + +@onready var _title_label: Label = %TitleLabel +@onready var _gate_box: VBoxContainer = %GateBox +@onready var _gate_question_label: Label = %GateQuestionLabel +@onready var _gate_input: LineEdit = %GateInput +@onready var _gate_error_label: Label = %GateErrorLabel +@onready var _gate_ok_button: Button = %GateOkButton +@onready var _content_box: VBoxContainer = %ContentBox +@onready var _profile_option: OptionButton = %ProfileOption +@onready var _summary_label: Label = %SummaryLabel +@onready var _skills_box: VBoxContainer = %SkillsBox +@onready var _export_button: Button = %ExportButton +@onready var _transfer_status_label: Label = %TransferStatusLabel +@onready var _restore_title_label: Label = %RestoreTitleLabel +@onready var _restore_list: VBoxContainer = %RestoreList +@onready var _folder_label: Label = %FolderLabel +@onready var _back_button: Button = %BackButton + + +func _ready() -> void: + _schema = DataLoader.load_schema("save") + _title_label.text = UIText.get_text("adult.title") + _set_gate_texts() + _export_button.text = UIText.get_text("adult.export_button") + _restore_title_label.text = UIText.get_text("adult.restore_title") + _folder_label.text = UIText.get_text("adult.folder_label") % SaveTransfer.default_export_dir() + _back_button.text = UIText.get_text("adult.back_button") + _gate_ok_button.pressed.connect(_on_gate_submitted) + _gate_input.text_submitted.connect(_on_gate_text_submitted) + _export_button.pressed.connect(_on_export_pressed) + _profile_option.item_selected.connect(_on_profile_option_selected) + _back_button.pressed.connect(_on_back_pressed) + + _rng.randomize() + _challenge = AdultGate.make_challenge(_rng) + _gate_question_label.text = ( + UIText.get_text("adult.gate_question") % [_challenge["a"], _challenge["b"]] + ) + _gate_box.visible = true + _content_box.visible = false + + +func _set_gate_texts() -> void: + _gate_ok_button.text = UIText.get_text("adult.gate_button") + _gate_error_label.text = UIText.get_text("adult.gate_error") + _gate_error_label.visible = false + + +func _on_gate_text_submitted(_text: String) -> void: + _on_gate_submitted() + + +func _on_gate_submitted() -> void: + if not AdultGate.is_correct(_challenge, _gate_input.text): + _gate_error_label.visible = true + _gate_input.clear() + return + _gate_box.visible = false + _content_box.visible = true + _refresh_profiles() + _refresh_restore_list() + + +func _refresh_profiles() -> void: + _profile_option.clear() + var summaries := ProfileManager.list_profile_summaries() + for summary: Dictionary in summaries: + _profile_option.add_item("%s (%s)" % [summary["display_name"], summary["school_level"]]) + _profile_option.set_item_metadata(_profile_option.item_count - 1, summary["profile_id"]) + var has_profiles := not summaries.is_empty() + _export_button.disabled = not has_profiles + if has_profiles: + _profile_option.select(0) + _show_profile(_profile_option.get_item_metadata(0)) + else: + _selected_profile_id = "" + _summary_label.text = UIText.get_text("adult.no_profile") + _clear_skills() + + +func _on_profile_option_selected(index: int) -> void: + _show_profile(_profile_option.get_item_metadata(index)) + + +func _show_profile(profile_id: String) -> void: + _selected_profile_id = profile_id + var save: Variant = SaveManager.load(profile_id) + _clear_skills() + if save == null: + _summary_label.text = UIText.get_text("adult.profile_unreadable") + return + var level: String = save["school_level"] + var report := ProgressReport.build( + save, ExerciseEngine.get_missions_for_level(level).size(), ExerciseEngine.get_reward_items().size() + ) + _summary_label.text = UIText.get_text("adult.summary") % [ + report["stars"], + report["missions_done"], + report["missions_total"], + report["items_owned"], + report["items_total"], + report["session_count"], + ] + var skills: Array = report["skills"] + if skills.is_empty(): + _skills_box.add_child(_skill_label(UIText.get_text("adult.no_skills_yet"), false)) + return + for row: Dictionary in skills: + _skills_box.add_child(_skill_label(_skill_text(row), row["needs_work"])) + + +func _skill_text(row: Dictionary) -> String: + var line := ( + UIText.get_text("adult.skill_line") + % [ + UIText.get_text("skill.%s" % row["skill_id"]), + UIText.get_text("world_map.status_%s" % row["status"]), + row["successes"], + row["attempts"], + row["hints_used"], + ] + ) + if row["needs_work"]: + line += "\n" + UIText.get_text("adult.needs_work") + return line + + +func _skill_label(text: String, highlight: bool) -> Control: + var card := PanelContainer.new() + card.theme_type_variation = &"SoftCard" + var label := Label.new() + label.text = text + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + if highlight: + label.theme_type_variation = &"Emphasis" + card.add_child(label) + return card + + +func _clear_skills() -> void: + for child in _skills_box.get_children(): + _skills_box.remove_child(child) + child.queue_free() + + +func _on_export_pressed() -> void: + if _selected_profile_id.is_empty(): + return + var path := SaveTransfer.export_profile( + _selected_profile_id, _schema, SaveTransfer.default_export_dir() + ) + if path.is_empty(): + _transfer_status_label.text = UIText.get_text("adult.export_failed") + else: + _transfer_status_label.text = UIText.get_text("adult.export_done") % path + _refresh_restore_list() + + +func _refresh_restore_list() -> void: + for child in _restore_list.get_children(): + _restore_list.remove_child(child) + child.queue_free() + var exports := SaveTransfer.list_exports(SaveTransfer.default_export_dir(), _schema) + if exports.is_empty(): + var empty := Label.new() + empty.text = UIText.get_text("adult.restore_empty") + empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _restore_list.add_child(empty) + return + for entry: Dictionary in exports: + var button := Button.new() + button.theme_type_variation = &"SecondaryButton" + button.custom_minimum_size = Vector2(0, 48) + if entry["valid"]: + button.text = ( + UIText.get_text("adult.restore_entry") + % [entry["display_name"], entry["school_level"], entry["path"].get_file()] + ) + button.pressed.connect(_on_restore_pressed.bind(entry["path"])) + else: + button.text = UIText.get_text("adult.restore_invalid") % entry["path"].get_file() + button.disabled = true + _restore_list.add_child(button) + + +func _on_restore_pressed(path: String) -> void: + var new_id := ProfileManager.new_profile_id() + if SaveTransfer.import_as_new_profile(path, _schema, new_id): + _transfer_status_label.text = UIText.get_text("adult.restore_done") + _refresh_profiles() + else: + _transfer_status_label.text = UIText.get_text("adult.restore_failed") + + +func _on_back_pressed() -> void: + GameManager.goto(GameManager.SCENE_SETTINGS) diff --git a/scenes/ui/adult_space.gd.uid b/scenes/ui/adult_space.gd.uid new file mode 100644 index 0000000..1e6a0ab --- /dev/null +++ b/scenes/ui/adult_space.gd.uid @@ -0,0 +1 @@ +uid://xselbc7st0o8 diff --git a/scenes/ui/adult_space.tscn b/scenes/ui/adult_space.tscn new file mode 100644 index 0000000..914a117 --- /dev/null +++ b/scenes/ui/adult_space.tscn @@ -0,0 +1,139 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/ui/adult_space.gd" id="1"] +[ext_resource type="PackedScene" path="res://scenes/ui/backdrop.tscn" id="backdrop"] + +[node name="AdultSpace" 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="Backdrop" parent="." instance=ExtResource("backdrop")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[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 = 40 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 40 +theme_override_constants/margin_bottom = 24 + +[node name="Card" type="PanelContainer" parent="Margin"] +layout_mode = 2 + +[node name="VBox" type="VBoxContainer" parent="Margin/Card"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="Margin/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"CardTitle" +horizontal_alignment = 1 + +[node name="GateBox" type="VBoxContainer" parent="Margin/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="GateQuestionLabel" type="Label" parent="Margin/Card/VBox/GateBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" +horizontal_alignment = 1 + +[node name="GateInput" type="LineEdit" parent="Margin/Card/VBox/GateBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) +alignment = 1 + +[node name="GateErrorLabel" type="Label" parent="Margin/Card/VBox/GateBox"] +unique_name_in_owner = true +layout_mode = 2 +horizontal_alignment = 1 + +[node name="GateOkButton" type="Button" parent="Margin/Card/VBox/GateBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) + +[node name="ContentBox" type="VBoxContainer" parent="Margin/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 10 + +[node name="ProfileOption" type="OptionButton" parent="Margin/Card/VBox/ContentBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 48) + +[node name="Scroll" type="ScrollContainer" parent="Margin/Card/VBox/ContentBox"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="Inner" type="VBoxContainer" parent="Margin/Card/VBox/ContentBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="SummaryLabel" type="Label" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" +autowrap_mode = 2 + +[node name="SkillsBox" type="VBoxContainer" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="ExportButton" type="Button" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 52) + +[node name="TransferStatusLabel" type="Label" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 + +[node name="RestoreTitleLabel" type="Label" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" + +[node name="RestoreList" type="VBoxContainer" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="FolderLabel" type="Label" parent="Margin/Card/VBox/ContentBox/Scroll/Inner"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Small" +autowrap_mode = 2 + +[node name="BackButton" type="Button" parent="Margin/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"SecondaryButton" +custom_minimum_size = Vector2(0, 52) diff --git a/scenes/ui/settings.gd b/scenes/ui/settings.gd new file mode 100644 index 0000000..77120b0 --- /dev/null +++ b/scenes/ui/settings.gd @@ -0,0 +1,85 @@ +extends Control +## Reglages de l'appareil : volume, taille du texte, animations. Accessible +## depuis la carte et la selection de profil sans barriere (l'enfant peut +## baisser le son lui-meme). L'espace adulte est un bouton separe, derriere +## une petite barriere. + +@onready var _title_label: Label = %TitleLabel +@onready var _volume_label: Label = %VolumeLabel +@onready var _volume_slider: HSlider = %VolumeSlider +@onready var _text_size_label: Label = %TextSizeLabel +@onready var _text_size_box: HBoxContainer = %TextSizeBox +@onready var _animations_label: Label = %AnimationsLabel +@onready var _animations_box: HBoxContainer = %AnimationsBox +@onready var _adult_button: Button = %AdultButton +@onready var _back_button: Button = %BackButton + + +func _ready() -> void: + _title_label.text = UIText.get_text("settings.title") + _volume_label.text = UIText.get_text("settings.volume_label") + _text_size_label.text = UIText.get_text("settings.text_size_label") + _animations_label.text = UIText.get_text("settings.animations_label") + _adult_button.text = UIText.get_text("settings.adult_button") + _back_button.text = UIText.get_text("settings.back_button") + + _volume_slider.min_value = 0.0 + _volume_slider.max_value = 1.0 + _volume_slider.step = 1.0 / AppSettings.VOLUME_STEPS + _volume_slider.value = SettingsManager.volume + _volume_slider.value_changed.connect(_on_volume_changed) + + _build_text_size_buttons() + + _build_animation_buttons() + + _adult_button.pressed.connect(_on_adult_pressed) + _back_button.pressed.connect(_on_back_pressed) + + +func _build_text_size_buttons() -> void: + var group := ButtonGroup.new() + for index in range(AppSettings.TEXT_SCALES.size()): + var button := Button.new() + button.text = UIText.get_text("settings.text_size_%d" % index) + button.toggle_mode = true + button.button_group = group + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2(0, 52) + button.button_pressed = index == SettingsManager.text_scale_index + button.pressed.connect(_on_text_size_pressed.bind(index)) + _text_size_box.add_child(button) + + +func _build_animation_buttons() -> void: + var group := ButtonGroup.new() + for enabled: bool in [true, false]: + var button := Button.new() + button.text = UIText.get_text("settings.animations_on" if enabled else "settings.animations_off") + button.toggle_mode = true + button.button_group = group + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2(0, 52) + button.button_pressed = enabled == SettingsManager.animations_enabled + button.pressed.connect(_on_animations_chosen.bind(enabled)) + _animations_box.add_child(button) + + +func _on_volume_changed(value: float) -> void: + SettingsManager.set_volume(value) + + +func _on_text_size_pressed(index: int) -> void: + SettingsManager.set_text_scale_index(index) + + +func _on_animations_chosen(enabled: bool) -> void: + SettingsManager.set_animations_enabled(enabled) + + +func _on_adult_pressed() -> void: + GameManager.goto(GameManager.SCENE_ADULT_SPACE) + + +func _on_back_pressed() -> void: + GameManager.goto(GameManager.settings_return_scene) diff --git a/scenes/ui/settings.gd.uid b/scenes/ui/settings.gd.uid new file mode 100644 index 0000000..b3cb7df --- /dev/null +++ b/scenes/ui/settings.gd.uid @@ -0,0 +1 @@ +uid://dyg8223otmtcm diff --git a/scenes/ui/settings.tscn b/scenes/ui/settings.tscn new file mode 100644 index 0000000..7a582c4 --- /dev/null +++ b/scenes/ui/settings.tscn @@ -0,0 +1,91 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/ui/settings.gd" id="1"] +[ext_resource type="PackedScene" path="res://scenes/ui/backdrop.tscn" id="backdrop"] + +[node name="Settings" 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="Backdrop" parent="." instance=ExtResource("backdrop")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[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 = 24 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 24 + +[node name="Center" type="CenterContainer" parent="Margin"] +layout_mode = 2 + +[node name="Card" type="PanelContainer" parent="Margin/Center"] +layout_mode = 2 +custom_minimum_size = Vector2(640, 0) + +[node name="VBox" type="VBoxContainer" parent="Margin/Center/Card"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="TitleLabel" type="Label" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"CardTitle" +horizontal_alignment = 1 + +[node name="VolumeLabel" type="Label" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" + +[node name="VolumeSlider" type="HSlider" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) + +[node name="TextSizeLabel" type="Label" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" + +[node name="TextSizeBox" type="HBoxContainer" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="AnimationsLabel" type="Label" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"Emphasis" + +[node name="AnimationsBox" type="HBoxContainer" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="AdultButton" type="Button" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"SecondaryButton" +custom_minimum_size = Vector2(0, 52) + +[node name="BackButton" type="Button" parent="Margin/Center/Card/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 56) diff --git a/scenes/world/world_map.gd b/scenes/world/world_map.gd index 4a9d6b0..cf23295 100644 --- a/scenes/world/world_map.gd +++ b/scenes/world/world_map.gd @@ -19,6 +19,7 @@ var _viewed_level := "CE1" @onready var _collection_button: Button = %CollectionButton @onready var _profile_button: Button = %ProfileButton @onready var _guide: Control = %Guide +@onready var _settings_button: Button = %SettingsButton func _ready() -> void: @@ -29,6 +30,8 @@ func _ready() -> void: _collection_button.pressed.connect(_on_collection_pressed) _profile_button.text = UIText.get_text("world_map.change_profile_button") _profile_button.pressed.connect(_on_change_profile_pressed) + _settings_button.text = UIText.get_text("world_map.settings_button") + _settings_button.pressed.connect(_on_settings_pressed) _map_area.resized.connect(_layout_markers) _guide.say(UIText.get_text("world_map.guide_hint")) _refresh() @@ -129,3 +132,8 @@ func _on_level_toggle_pressed() -> void: func _on_mission_pressed(mission_id: String) -> void: GameManager.pending_mission_id = mission_id GameManager.goto(GameManager.SCENE_MISSION_INTRO) + + +func _on_settings_pressed() -> void: + GameManager.settings_return_scene = GameManager.SCENE_WORLD_MAP + GameManager.goto(GameManager.SCENE_SETTINGS) diff --git a/scenes/world/world_map.tscn b/scenes/world/world_map.tscn index 7e14e66..c5fa4cf 100644 --- a/scenes/world/world_map.tscn +++ b/scenes/world/world_map.tscn @@ -51,6 +51,8 @@ custom_minimum_size = Vector2(0, 52) [node name="TitleLabel" type="Label" parent="Margin/VBox/TopBar"] unique_name_in_owner = true +text_overrun_behavior = 3 +clip_text = true theme_type_variation = &"HeaderTitle" layout_mode = 2 size_flags_horizontal = 3 @@ -100,3 +102,10 @@ theme_type_variation = &"SecondaryButton" layout_mode = 2 size_flags_vertical = 4 custom_minimum_size = Vector2(0, 52) + +[node name="SettingsButton" type="Button" parent="Margin/VBox/BottomBar"] +unique_name_in_owner = true +theme_type_variation = &"SecondaryButton" +layout_mode = 2 +size_flags_vertical = 4 +custom_minimum_size = Vector2(0, 52) diff --git a/scripts/pedagogy/progress_report.gd b/scripts/pedagogy/progress_report.gd new file mode 100644 index 0000000..e6dbb8e --- /dev/null +++ b/scripts/pedagogy/progress_report.gd @@ -0,0 +1,63 @@ +class_name ProgressReport +extends RefCounted +## Synthese de progression destinee a l'adulte, calculee a partir d'une +## sauvegarde. Code pur : aucun acces a l'arbre de scenes ni au disque. Les +## intitules viennent de l'appelant (UIText) : ici seulement des identifiants +## et des nombres. Une notion « a retravailler » n'est jamais un jugement sur +## l'enfant : c'est un signal pour l'adulte (cahier des charges §9). + +## Nombre minimal de tentatives recentes avant de parler de difficulte. +const MIN_ATTEMPTS_FOR_DIFFICULTY := 3 +## Taux de reussite recent en dessous duquel la notion est signalee. +const STRUGGLE_RATE := 0.6 + + +## `missions_total` : nombre de missions du niveau de l'enfant ; +## `items_total` : taille du catalogue d'objets. +static func build(save: Dictionary, missions_total: int, items_total: int) -> Dictionary: + var results: Dictionary = save.get("mission_results", {}) + var rewards: Dictionary = save.get("rewards", {"stars": 0, "items": []}) + var skills: Array[Dictionary] = [] + var skill_states: Dictionary = save.get("skills", {}) + var skill_ids: Array = skill_states.keys() + skill_ids.sort() + for skill_id: String in skill_ids: + skills.append(_skill_row(skill_id, skill_states[skill_id])) + return { + "display_name": save.get("display_name", ""), + "school_level": save.get("school_level", ""), + "session_count": int(save.get("session_count", 0)), + "stars": int(rewards.get("stars", 0)), + "missions_done": results.size(), + "missions_total": missions_total, + "items_owned": rewards.get("items", []).size(), + "items_total": items_total, + "skills": skills, + } + + +static func _skill_row(skill_id: String, state: Dictionary) -> Dictionary: + var recent: Array = state.get("recent_results", []) + var hints: Array = state.get("recent_hint_used", []) + var successes := 0 + for result: bool in recent: + if result: + successes += 1 + var hint_count := 0 + for used: bool in hints: + if used: + hint_count += 1 + var attempts := recent.size() + var status: String = state.get("status", "decouverte") + var struggling := ( + attempts >= MIN_ATTEMPTS_FOR_DIFFICULTY + and float(successes) / float(attempts) < STRUGGLE_RATE + ) + return { + "skill_id": skill_id, + "status": status, + "attempts": attempts, + "successes": successes, + "hints_used": hint_count, + "needs_work": status == "a_reviser" or struggling, + } diff --git a/scripts/pedagogy/progress_report.gd.uid b/scripts/pedagogy/progress_report.gd.uid new file mode 100644 index 0000000..de94e9c --- /dev/null +++ b/scripts/pedagogy/progress_report.gd.uid @@ -0,0 +1 @@ +uid://bx2badhy4sfb4 diff --git a/scripts/profiles/adult_gate.gd b/scripts/profiles/adult_gate.gd new file mode 100644 index 0000000..7f3997f --- /dev/null +++ b/scripts/profiles/adult_gate.gd @@ -0,0 +1,24 @@ +class_name AdultGate +extends RefCounted +## Petite barriere devant l'espace adulte : un calcul qu'un enfant de CE1-CE2 +## ne fait pas facilement de tete (produit d'un nombre a deux chiffres par un +## chiffre). Ce n'est pas une protection de securite, seulement un frein +## contre un clic par curiosite. Le hasard est injecte, jamais lu ici. + +const MIN_FACTOR_A := 12 +const MAX_FACTOR_A := 19 +const MIN_FACTOR_B := 3 +const MAX_FACTOR_B := 9 + + +static func make_challenge(rng: RandomNumberGenerator) -> Dictionary: + var a := rng.randi_range(MIN_FACTOR_A, MAX_FACTOR_A) + var b := rng.randi_range(MIN_FACTOR_B, MAX_FACTOR_B) + return {"a": a, "b": b, "answer": a * b} + + +static func is_correct(challenge: Dictionary, typed: String) -> bool: + var cleaned := typed.strip_edges() + if not cleaned.is_valid_int(): + return false + return int(cleaned) == int(challenge["answer"]) diff --git a/scripts/profiles/adult_gate.gd.uid b/scripts/profiles/adult_gate.gd.uid new file mode 100644 index 0000000..1a13c3c --- /dev/null +++ b/scripts/profiles/adult_gate.gd.uid @@ -0,0 +1 @@ +uid://dhu25ypxg0yld diff --git a/scripts/profiles/save_transfer.gd b/scripts/profiles/save_transfer.gd new file mode 100644 index 0000000..a86f889 --- /dev/null +++ b/scripts/profiles/save_transfer.gd @@ -0,0 +1,131 @@ +class_name SaveTransfer +extends RefCounted +## Export et restauration d'une sauvegarde de profil vers un dossier choisi par +## l'adulte (cahier des charges §10). Le fichier exporte est exactement la +## sauvegarde versionnee du profil : le format n'est pas modifie. La +## restauration est non destructive : elle cree toujours un NOUVEAU profil +## (identifiant neuf) et n'ecrase jamais un profil existant. Pas pur (touche le +## systeme de fichiers) mais sans arbre de scenes : testable en headless. + +const EXPORT_FOLDER_NAME := "AventuriersDesMots" + + +## Dossier d'export par defaut : Documents de l'utilisateur, ou user://exports +## si la plateforme n'en expose pas. +static func default_export_dir() -> String: + var documents := OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) + if documents.is_empty(): + return "user://exports" + return documents.path_join(EXPORT_FOLDER_NAME) + + +## Exporte le profil `profile_id` dans `dest_dir`. Renvoie le chemin du fichier +## cree, ou "" en cas d'echec (profil illisible, dossier non inscriptible). +static func export_profile(profile_id: String, schema: Dictionary, dest_dir: String) -> String: + var save: Variant = SaveIO.read(profile_id, schema) + if save == null: + return "" + if ( + DirAccess.make_dir_recursive_absolute(dest_dir) != OK + and not DirAccess.dir_exists_absolute(dest_dir) + ): + return "" + var base := _safe_file_stem(save["display_name"], save["school_level"]) + var path := dest_dir.path_join(base + ".json") + var counter := 2 + while FileAccess.file_exists(path): + path = dest_dir.path_join("%s_%d.json" % [base, counter]) + counter += 1 + var tmp_path := path + ".tmp" + var file := FileAccess.open(tmp_path, FileAccess.WRITE) + if file == null: + return "" + file.store_string(JSON.stringify(save, " ")) + file.close() + var dir := DirAccess.open(dest_dir) + if dir == null or dir.rename(tmp_path, path) != OK: + return "" + return path + + +## Fichiers de sauvegarde exportes trouves dans `dir`, tries par nom : chemin, +## nom d'affichage et niveau, ou valid=false pour un fichier illisible. +static func list_exports(dir_path: String, schema: Dictionary) -> Array[Dictionary]: + var found: Array[Dictionary] = [] + var dir := DirAccess.open(dir_path) + if dir == null: + return found + var names: Array[String] = [] + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if not dir.current_is_dir() and entry.ends_with(".json"): + names.append(entry) + entry = dir.get_next() + names.sort() + for file_name in names: + var path := dir_path.path_join(file_name) + var save: Variant = read_export(path, schema) + if save == null: + found.append({"path": path, "valid": false, "display_name": "", "school_level": ""}) + else: + ( + found + . append( + { + "path": path, + "valid": true, + "display_name": save["display_name"], + "school_level": save["school_level"], + } + ) + ) + return found + + +## Lit, migre et valide un fichier exporte. Null s'il n'est pas une sauvegarde +## valable (JSON invalide, schema non respecte, version inconnue). +static func read_export(path: String, schema: Dictionary) -> Variant: + if not FileAccess.file_exists(path): + return null + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return null + # JSON.new().parse() renvoie un code d'erreur au lieu de journaliser une + # erreur moteur : un fichier choisi par l'adulte peut etre n'importe quoi. + var json := JSON.new() + if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY: + return null + var parsed: Dictionary = json.data + var migrated: Dictionary = SaveMigration.migrate(parsed) + if migrated.is_empty(): + return null + if not SchemaValidator.validate(migrated, schema).is_empty(): + return null + return migrated + + +## Restaure le fichier `path` comme nouveau profil `new_profile_id`. Renvoie +## true si la sauvegarde a ete ecrite. +static func import_as_new_profile(path: String, schema: Dictionary, new_profile_id: String) -> bool: + var save: Variant = read_export(path, schema) + if save == null: + return false + var restored: Dictionary = save + restored["profile_id"] = new_profile_id + return SaveIO.write(new_profile_id, restored) + + +static func _safe_file_stem(display_name: String, school_level: String) -> String: + # On retire seulement ce qu'un systeme de fichiers refuse ; les accents + # restent, pour que « Léa » reste « Léa ». + var forbidden := '/\\:*?"<>|.' + var cleaned := "" + for character in display_name.strip_edges(): + if character == " ": + cleaned += "_" + elif not forbidden.contains(character) and character.unicode_at(0) >= 32: + cleaned += character + if cleaned.is_empty(): + cleaned = "profil" + return "%s_%s" % [cleaned, school_level] diff --git a/scripts/profiles/save_transfer.gd.uid b/scripts/profiles/save_transfer.gd.uid new file mode 100644 index 0000000..95d380e --- /dev/null +++ b/scripts/profiles/save_transfer.gd.uid @@ -0,0 +1 @@ +uid://k3ebjlqrcnn8 diff --git a/scripts/settings/app_settings.gd b/scripts/settings/app_settings.gd new file mode 100644 index 0000000..8d53581 --- /dev/null +++ b/scripts/settings/app_settings.gd @@ -0,0 +1,55 @@ +class_name AppSettings +extends RefCounted +## Preferences de l'appareil (volume, taille du texte, animations). Code pur : +## valeurs par defaut, nettoyage d'un fichier lu sur disque (jamais de valeur +## hors limites) et conversions. Les reglages sont communs a tous les profils : +## ils ne font pas partie de la sauvegarde d'un enfant. + +const FORMAT_VERSION := 1 + +## Facteurs de taille du texte proposes (index = choix dans l'ecran de reglages). +const TEXT_SCALES: Array[float] = [1.0, 1.15, 1.3] + +## Types de theme jamais agrandis : les grands titres sont deja grands, et +## les agrandir ferait deborder les barres d'en-tete de la fenetre. +const UNSCALED_THEME_TYPES: Array[String] = ["HeaderTitle", "CardTitle", "Window"] +const VOLUME_STEPS := 5 + + +static func defaults() -> Dictionary: + return { + "format_version": FORMAT_VERSION, + "volume": 0.8, + "text_scale_index": 0, + "animations_enabled": true, + } + + +## Complete et borne les valeurs d'un dictionnaire quelconque (fichier absent, +## tronque ou modifie a la main) : le resultat est toujours utilisable. +static func sanitize(raw: Variant) -> Dictionary: + var result := defaults() + if typeof(raw) != TYPE_DICTIONARY: + return result + var source: Dictionary = raw + var volume: Variant = source.get("volume") + if typeof(volume) == TYPE_FLOAT or typeof(volume) == TYPE_INT: + result["volume"] = clampf(float(volume), 0.0, 1.0) + var index: Variant = source.get("text_scale_index") + if typeof(index) == TYPE_FLOAT or typeof(index) == TYPE_INT: + result["text_scale_index"] = clampi(int(index), 0, TEXT_SCALES.size() - 1) + var animations: Variant = source.get("animations_enabled") + if typeof(animations) == TYPE_BOOL: + result["animations_enabled"] = animations + return result + + +static func text_scale(settings: Dictionary) -> float: + return TEXT_SCALES[clampi(int(settings["text_scale_index"]), 0, TEXT_SCALES.size() - 1)] + + +## Volume de 0 a 1 vers des decibels ; le silence complet est un vrai silence. +static func volume_db(volume: float) -> float: + if volume <= 0.0: + return -80.0 + return linear_to_db(clampf(volume, 0.0, 1.0)) diff --git a/scripts/settings/app_settings.gd.uid b/scripts/settings/app_settings.gd.uid new file mode 100644 index 0000000..67bfe14 --- /dev/null +++ b/scripts/settings/app_settings.gd.uid @@ -0,0 +1 @@ +uid://cwgk0t4potw0m diff --git a/tests/data/test_skill_labels.gd b/tests/data/test_skill_labels.gd new file mode 100644 index 0000000..1561a44 --- /dev/null +++ b/tests/data/test_skill_labels.gd @@ -0,0 +1,14 @@ +extends GutTest +## Chaque notion (skill) presente dans les donnees doit avoir un intitule +## affichable dans l'espace adulte. + + +func test_every_skill_has_a_label() -> void: + var file := FileAccess.open(UIText.LOCALIZATION_PATH, FileAccess.READ) + var labels: Dictionary = JSON.parse_string(file.get_as_text()) + var seen := {} + for rule: Dictionary in ExerciseEngine._rules.values(): + seen[rule["skill"]] = true + assert_gt(seen.size(), 0) + for skill_id: String in seen: + assert_true(labels.has("skill.%s" % skill_id), "Intitule manquant : skill.%s" % skill_id) diff --git a/tests/data/test_skill_labels.gd.uid b/tests/data/test_skill_labels.gd.uid new file mode 100644 index 0000000..78c371b --- /dev/null +++ b/tests/data/test_skill_labels.gd.uid @@ -0,0 +1 @@ +uid://dri3p6o43m5io diff --git a/tests/pedagogy/test_progress_report.gd b/tests/pedagogy/test_progress_report.gd new file mode 100644 index 0000000..1dc1bf4 --- /dev/null +++ b/tests/pedagogy/test_progress_report.gd @@ -0,0 +1,70 @@ +extends GutTest +## Tests de ProgressReport : synthese pour l'adulte et detection des notions a +## retravailler. + + +func _save_with_skill(state: Dictionary) -> Dictionary: + var save := SaveFactory.new_save("test_report", "Lea", "CE1") + save["skills"] = {"accord_groupe_nominal": state} + return save + + +func _state(status: String, results: Array, hints: Array) -> Dictionary: + return { + "status": status, + "recent_results": results, + "recent_hint_used": hints, + "sessions_succeeded": [], + "formats_succeeded": [], + "last_session_practiced": 0, + "review_interval_index": 0, + "next_review_session": 0, + } + + +func test_empty_profile_has_no_skills_and_zero_counts() -> void: + var report := ProgressReport.build(SaveFactory.new_save("test_report", "Lea", "CE1"), 9, 10) + assert_eq(report["stars"], 0) + assert_eq(report["missions_done"], 0) + assert_eq(report["missions_total"], 9) + assert_eq(report["items_total"], 10) + assert_eq(report["skills"].size(), 0) + + +func test_counts_missions_stars_and_items() -> void: + var save := SaveFactory.new_save("test_report", "Lea", "CE1") + save["mission_results"] = {"m1": {"best_stars": 3, "last_stars": 3, "completions": 1}} + save["rewards"] = {"stars": 3, "items": ["a", "b"]} + var report := ProgressReport.build(save, 9, 10) + assert_eq(report["missions_done"], 1) + assert_eq(report["stars"], 3) + assert_eq(report["items_owned"], 2) + + +func test_skill_with_low_recent_success_needs_work() -> void: + var save := _save_with_skill( + _state("en_acquisition", [false, false, true, false], [true, false, false, false]) + ) + var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0] + assert_eq(row["attempts"], 4) + assert_eq(row["successes"], 1) + assert_eq(row["hints_used"], 1) + assert_true(row["needs_work"]) + + +func test_skill_with_good_results_does_not_need_work() -> void: + var save := _save_with_skill(_state("en_consolidation", [true, true, false, true], [])) + var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0] + assert_false(row["needs_work"]) + + +func test_too_few_attempts_is_not_a_difficulty() -> void: + var save := _save_with_skill(_state("en_acquisition", [false, false], [])) + var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0] + assert_false(row["needs_work"]) + + +func test_status_a_reviser_always_needs_work() -> void: + var save := _save_with_skill(_state("a_reviser", [true], [])) + var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0] + assert_true(row["needs_work"]) diff --git a/tests/pedagogy/test_progress_report.gd.uid b/tests/pedagogy/test_progress_report.gd.uid new file mode 100644 index 0000000..ceb684b --- /dev/null +++ b/tests/pedagogy/test_progress_report.gd.uid @@ -0,0 +1 @@ +uid://cf77xbrvbjrf0 diff --git a/tests/profiles/test_adult_gate.gd b/tests/profiles/test_adult_gate.gd new file mode 100644 index 0000000..1328bf5 --- /dev/null +++ b/tests/profiles/test_adult_gate.gd @@ -0,0 +1,33 @@ +extends GutTest +## Tests d'AdultGate : le calcul est dans les bornes et la reponse est exacte. + + +func test_challenge_is_within_bounds_and_answer_matches() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 42 + for _i in range(50): + var challenge := AdultGate.make_challenge(rng) + assert_between(challenge["a"], AdultGate.MIN_FACTOR_A, AdultGate.MAX_FACTOR_A) + assert_between(challenge["b"], AdultGate.MIN_FACTOR_B, AdultGate.MAX_FACTOR_B) + assert_eq(challenge["answer"], challenge["a"] * challenge["b"]) + + +func test_same_seed_gives_same_challenge() -> void: + var first := RandomNumberGenerator.new() + first.seed = 7 + var second := RandomNumberGenerator.new() + second.seed = 7 + assert_eq(AdultGate.make_challenge(first), AdultGate.make_challenge(second)) + + +func test_is_correct_accepts_exact_answer_with_spaces() -> void: + var challenge := {"a": 13, "b": 7, "answer": 91} + assert_true(AdultGate.is_correct(challenge, "91")) + assert_true(AdultGate.is_correct(challenge, " 91 ")) + + +func test_is_correct_rejects_wrong_or_non_numeric() -> void: + var challenge := {"a": 13, "b": 7, "answer": 91} + assert_false(AdultGate.is_correct(challenge, "90")) + assert_false(AdultGate.is_correct(challenge, "")) + assert_false(AdultGate.is_correct(challenge, "quatre-vingt-onze")) diff --git a/tests/profiles/test_adult_gate.gd.uid b/tests/profiles/test_adult_gate.gd.uid new file mode 100644 index 0000000..640b69e --- /dev/null +++ b/tests/profiles/test_adult_gate.gd.uid @@ -0,0 +1 @@ +uid://cv8nfcak28jm8 diff --git a/tests/profiles/test_save_transfer.gd b/tests/profiles/test_save_transfer.gd new file mode 100644 index 0000000..7522049 --- /dev/null +++ b/tests/profiles/test_save_transfer.gd @@ -0,0 +1,91 @@ +extends GutTest +## Tests de SaveTransfer : export, liste, restauration non destructive et +## rejet des fichiers invalides. Le dossier d'export est un dossier de test +## sous user:// ; jamais le dossier Documents de l'utilisateur. + +const EXPORT_DIR := "user://test_save_transfer_exports" +const SOURCE_ID := "test_transfer_source" +const RESTORED_ID := "test_transfer_restored" + +var schema: Dictionary = {} + + +func before_each() -> void: + schema = DataLoader.load_schema("save") + _clean() + + +func after_all() -> void: + _clean() + + +func _clean() -> void: + SaveIO.delete(SOURCE_ID) + SaveIO.delete(RESTORED_ID) + var dir := DirAccess.open(EXPORT_DIR) + if dir != null: + for file_name in dir.get_files(): + dir.remove(file_name) + DirAccess.remove_absolute(EXPORT_DIR) + + +func _make_source() -> void: + var save := SaveFactory.new_save(SOURCE_ID, "Léa", "CE2") + save["rewards"] = {"stars": 4, "items": ["item_graine_magique"]} + save["mission_results"] = {"m": {"best_stars": 3, "last_stars": 3, "completions": 2}} + SaveIO.write(SOURCE_ID, save) + + +func test_export_writes_a_readable_file_named_after_the_profile() -> void: + _make_source() + var path := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR) + assert_ne(path, "") + assert_true(path.get_file().begins_with("Léa_CE2")) + var read_back: Variant = SaveTransfer.read_export(path, schema) + assert_not_null(read_back) + assert_eq(read_back["display_name"], "Léa") + + +func test_second_export_does_not_overwrite_the_first() -> void: + _make_source() + var first := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR) + var second := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR) + assert_ne(first, second) + assert_eq(SaveTransfer.list_exports(EXPORT_DIR, schema).size(), 2) + + +func test_export_of_missing_profile_fails_cleanly() -> void: + assert_eq(SaveTransfer.export_profile("test_transfer_nobody", schema, EXPORT_DIR), "") + + +func test_restore_creates_a_new_profile_and_keeps_the_original() -> void: + _make_source() + var path := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR) + assert_true(SaveTransfer.import_as_new_profile(path, schema, RESTORED_ID)) + + var restored: Variant = SaveIO.read(RESTORED_ID, schema) + assert_not_null(restored) + assert_eq(restored["profile_id"], RESTORED_ID) + assert_eq(restored["rewards"]["stars"], 4) + assert_eq(restored["mission_results"]["m"]["completions"], 2) + + var original: Variant = SaveIO.read(SOURCE_ID, schema) + assert_eq(original["profile_id"], SOURCE_ID) + + +func test_invalid_files_are_listed_as_invalid_and_cannot_be_restored() -> void: + DirAccess.make_dir_recursive_absolute(EXPORT_DIR) + var file := FileAccess.open(EXPORT_DIR + "/abime.json", FileAccess.WRITE) + file.store_string("{ pas du json") + file.close() + var entries := SaveTransfer.list_exports(EXPORT_DIR, schema) + assert_eq(entries.size(), 1) + assert_false(entries[0]["valid"]) + assert_false( + SaveTransfer.import_as_new_profile(EXPORT_DIR + "/abime.json", schema, RESTORED_ID) + ) + assert_null(SaveIO.read(RESTORED_ID, schema)) + + +func test_list_of_missing_directory_is_empty() -> void: + assert_eq(SaveTransfer.list_exports("user://test_transfer_absent_dir", schema).size(), 0) diff --git a/tests/profiles/test_save_transfer.gd.uid b/tests/profiles/test_save_transfer.gd.uid new file mode 100644 index 0000000..8e4b2a8 --- /dev/null +++ b/tests/profiles/test_save_transfer.gd.uid @@ -0,0 +1 @@ +uid://c2qnw22eh66lv diff --git a/tests/settings/test_app_settings.gd b/tests/settings/test_app_settings.gd new file mode 100644 index 0000000..0ad5bf0 --- /dev/null +++ b/tests/settings/test_app_settings.gd @@ -0,0 +1,37 @@ +extends GutTest +## Tests d'AppSettings : valeurs par defaut, nettoyage des fichiers abimes, +## conversions volume/echelle de texte. + + +func test_defaults_are_usable() -> void: + var settings := AppSettings.defaults() + assert_true(settings["animations_enabled"]) + assert_eq(AppSettings.text_scale(settings), 1.0) + + +func test_sanitize_of_non_dictionary_gives_defaults() -> void: + assert_eq(AppSettings.sanitize("n'importe quoi"), AppSettings.defaults()) + assert_eq(AppSettings.sanitize(null), AppSettings.defaults()) + + +func test_sanitize_clamps_out_of_range_values() -> void: + var clean := AppSettings.sanitize( + {"volume": 7.0, "text_scale_index": 99, "animations_enabled": false} + ) + assert_eq(clean["volume"], 1.0) + assert_eq(clean["text_scale_index"], AppSettings.TEXT_SCALES.size() - 1) + assert_false(clean["animations_enabled"]) + assert_eq(AppSettings.sanitize({"volume": -2})["volume"], 0.0) + + +func test_sanitize_ignores_wrong_types() -> void: + var clean := AppSettings.sanitize( + {"volume": "fort", "text_scale_index": "grand", "animations_enabled": "oui"} + ) + assert_eq(clean, AppSettings.defaults()) + + +func test_zero_volume_is_real_silence() -> void: + assert_eq(AppSettings.volume_db(0.0), -80.0) + assert_eq(AppSettings.volume_db(1.0), 0.0) + assert_lt(AppSettings.volume_db(0.5), 0.0) diff --git a/tests/settings/test_app_settings.gd.uid b/tests/settings/test_app_settings.gd.uid new file mode 100644 index 0000000..aee669a --- /dev/null +++ b/tests/settings/test_app_settings.gd.uid @@ -0,0 +1 @@ +uid://bln38n5jm0aku diff --git a/tests/world/test_settings_scenes.gd b/tests/world/test_settings_scenes.gd new file mode 100644 index 0000000..4f1d287 --- /dev/null +++ b/tests/world/test_settings_scenes.gd @@ -0,0 +1,31 @@ +extends GutTest +## Parcours des scenes de reglages et d'espace adulte : navigation et barriere. +## Ne modifie jamais les reglages reels ni les profils de l'utilisateur. + +const SETTINGS_SCENE := preload("res://scenes/ui/settings.tscn") +const ADULT_SCENE := preload("res://scenes/ui/adult_space.tscn") + + +func test_settings_scene_builds_one_button_per_text_size() -> void: + var scene: Control = add_child_autofree(SETTINGS_SCENE.instantiate()) + var box: HBoxContainer = scene.find_child("TextSizeBox", true, false) + assert_eq(box.get_child_count(), AppSettings.TEXT_SCALES.size()) + + +func test_adult_space_hides_content_until_gate_is_solved() -> void: + var scene: Control = add_child_autofree(ADULT_SCENE.instantiate()) + var gate: Control = scene.find_child("GateBox", true, false) + var content: Control = scene.find_child("ContentBox", true, false) + assert_true(gate.visible) + assert_false(content.visible) + + var input: LineEdit = scene.find_child("GateInput", true, false) + input.text = "0" + scene._on_gate_submitted() + assert_true(gate.visible, "une mauvaise reponse ne doit pas ouvrir l'espace") + assert_false(content.visible) + + input.text = str(scene._challenge["answer"]) + scene._on_gate_submitted() + assert_false(gate.visible) + assert_true(content.visible) diff --git a/tests/world/test_settings_scenes.gd.uid b/tests/world/test_settings_scenes.gd.uid new file mode 100644 index 0000000..bc2879d --- /dev/null +++ b/tests/world/test_settings_scenes.gd.uid @@ -0,0 +1 @@ +uid://bdv3h6fouqab6 diff --git a/tools/screenshot.gd b/tools/screenshot.gd index c54052b..f653165 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -27,15 +27,25 @@ func _initialize() -> void: typed.append(part) game.set("last_outcomes", typed) + # Les autoloads terminent leur _ready apres _initialize : on attend une + # image, sinon SettingsManager relirait ses reglages par-dessus les notres. + await process_frame + for arg in args: + if arg.begins_with("text_scale:"): + # Applique sans ecrire user://settings.json (reglages reels). + var settings: Node = root.get_node("SettingsManager") + settings.set("text_scale_index", int(arg.trim_prefix("text_scale:"))) + settings.call("apply") + var scene: Node = load(scene_path).instantiate() root.add_child(scene) for i in 5: await process_frame # Actions optionnelles sur exercise_host, a partir du 5e argument : # continue_rule | answer: | toggle: | type: | validate - # | continue_feedback + # | continue_feedback | solve_gate (espace adulte) for action in args.slice(4): - if action.begins_with("outcomes:"): + if action.begins_with("outcomes:") or action.begins_with("text_scale:"): continue await _run_action(scene, action) for i in 5: @@ -49,9 +59,18 @@ func _initialize() -> void: func _run_action(scene: Node, action: String) -> void: - var overlay: Node = scene.get_node("%OverlayContainer") - var exercise: Node = scene.get_node("%ExerciseContainer").get_child(0) if scene.get_node("%ExerciseContainer").get_child_count() > 0 else null var parts := action.split(":", true, 1) + if parts[0] == "solve_gate": + # Espace adulte : saisit la bonne reponse a la barriere. + var input: LineEdit = scene.find_child("GateInput", true, false) + input.text = str(scene.get("_challenge")["answer"]) + scene.call("_on_gate_submitted") + return + var overlay: Node = scene.get_node_or_null("%OverlayContainer") + var exercise_container: Node = scene.get_node_or_null("%ExerciseContainer") + var exercise: Node = null + if exercise_container != null and exercise_container.get_child_count() > 0: + exercise = exercise_container.get_child(0) match parts[0]: "continue_rule": overlay.get_child(0).emit_signal("continued")