mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 10:21:58 +02:00
Add settings screen and adult space with progress report and save export/restore (0.4.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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.
|
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
|
## Tech stack
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Pile technique
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||||||
|
<circle cx="20" cy="20" r="17" fill="#FFC83D" stroke="#26324A" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 173 B |
@@ -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
|
||||||
@@ -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-Regular.ttf" id="font_regular"]
|
||||||
[ext_resource type="FontFile" path="res://assets/fonts/AtkinsonHyperlegible-Bold.ttf" id="font_bold"]
|
[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"]
|
[sub_resource type="StyleBoxFlat" id="btn_normal"]
|
||||||
bg_color = Color(0.184, 0.502, 0.251, 1)
|
bg_color = Color(0.184, 0.502, 0.251, 1)
|
||||||
corner_radius_top_left = 18
|
corner_radius_top_left = 18
|
||||||
@@ -334,6 +354,12 @@ SoftCard/base_type = &"PanelContainer"
|
|||||||
SoftCard/styles/panel = SubResource("card_soft")
|
SoftCard/styles/panel = SubResource("card_soft")
|
||||||
Pill/base_type = &"PanelContainer"
|
Pill/base_type = &"PanelContainer"
|
||||||
Pill/styles/panel = SubResource("pill")
|
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_color = Color(0.149, 0.196, 0.290, 1)
|
||||||
LineEdit/colors/font_placeholder_color = Color(0.45, 0.48, 0.56, 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)
|
LineEdit/colors/caret_color = Color(0.149, 0.196, 0.290, 1)
|
||||||
|
|||||||
@@ -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_EXERCISE_HOST := "res://scenes/exercises/exercise_host.tscn"
|
||||||
const SCENE_REWARD := "res://scenes/ui/reward_screen.tscn"
|
const SCENE_REWARD := "res://scenes/ui/reward_screen.tscn"
|
||||||
const SCENE_COLLECTION := "res://scenes/ui/collection.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
|
## 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.
|
## 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.
|
## (MissionScoring.OUTCOME_*), transmis par exercise_host a reward_screen.
|
||||||
var last_outcomes: Array[String] = []
|
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:
|
func goto(scene_path: String) -> void:
|
||||||
# Differe le changement de scene : l'appeler depuis le _ready() de la
|
# Differe le changement de scene : l'appeler depuis le _ready() de la
|
||||||
|
|||||||
@@ -48,5 +48,10 @@ func list_profile_summaries() -> Array[Dictionary]:
|
|||||||
return summaries
|
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:
|
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]
|
||||||
|
|||||||
@@ -1,6 +1,111 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Volume, taille du texte et autres preferences de l'enfant ou de l'adulte.
|
## Volume, taille du texte et animations. Les reglages sont ceux de l'appareil
|
||||||
## Pour l'instant : l'interrupteur des animations, lu par Motion. L'ecran de
|
## (user://settings.json), pas ceux d'un profil. Les valeurs sont nettoyees par
|
||||||
## reglages qui le pilotera vient avec l'espace adulte (docs/specs/decisions.md).
|
## 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 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)))
|
||||||
|
|||||||
@@ -71,5 +71,41 @@
|
|||||||
"world_map.total_stars_label": "Total d'étoiles :",
|
"world_map.total_stars_label": "Total d'étoiles :",
|
||||||
"world_map.mission_stars_label": "Étoiles :",
|
"world_map.mission_stars_label": "Étoiles :",
|
||||||
"world_map.collection_button": "Ma collection",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -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).
|
- 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é.
|
- 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
|
## 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.
|
- `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.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Les Aventuriers des mots"
|
config/name="Les Aventuriers des mots"
|
||||||
config/version="0.3.0"
|
config/version="0.4.0"
|
||||||
run/main_scene="res://scenes/boot/boot.tscn"
|
run/main_scene="res://scenes/boot/boot.tscn"
|
||||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func load_exercise(exercise: Dictionary) -> void:
|
|||||||
var button := Button.new()
|
var button := Button.new()
|
||||||
button.text = answers[i]
|
button.text = answers[i]
|
||||||
button.custom_minimum_size = Vector2(0, 64)
|
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))
|
button.pressed.connect(_on_answer_pressed.bind(i))
|
||||||
_answers_box.add_child(button)
|
_answers_box.add_child(button)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func load_exercise(exercise: Dictionary) -> void:
|
|||||||
button.toggle_mode = true
|
button.toggle_mode = true
|
||||||
button.theme_type_variation = &"SecondaryButton"
|
button.theme_type_variation = &"SecondaryButton"
|
||||||
button.custom_minimum_size = Vector2(0, 60)
|
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))
|
button.toggled.connect(_on_token_toggled.bind(i))
|
||||||
_tokens_box.add_child(button)
|
_tokens_box.add_child(button)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ var _pending_delete_profile_id: String = ""
|
|||||||
@onready var _profile_list: VBoxContainer = %ProfileList
|
@onready var _profile_list: VBoxContainer = %ProfileList
|
||||||
@onready var _empty_state_label: Label = %EmptyStateLabel
|
@onready var _empty_state_label: Label = %EmptyStateLabel
|
||||||
@onready var _new_profile_button: Button = %NewProfileButton
|
@onready var _new_profile_button: Button = %NewProfileButton
|
||||||
|
@onready var _settings_button: Button = %SettingsButton
|
||||||
@onready var _delete_confirm_dialog: ConfirmationDialog = %DeleteConfirmDialog
|
@onready var _delete_confirm_dialog: ConfirmationDialog = %DeleteConfirmDialog
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ func _ready() -> void:
|
|||||||
"profile_select.delete_confirm_no"
|
"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)
|
_new_profile_button.pressed.connect(_on_new_profile_pressed)
|
||||||
_delete_confirm_dialog.confirmed.connect(_on_delete_confirmed)
|
_delete_confirm_dialog.confirmed.connect(_on_delete_confirmed)
|
||||||
|
|
||||||
@@ -79,3 +82,8 @@ func _on_delete_confirmed() -> void:
|
|||||||
|
|
||||||
func _on_new_profile_pressed() -> void:
|
func _on_new_profile_pressed() -> void:
|
||||||
GameManager.goto(GameManager.SCENE_PROFILE_CREATE)
|
GameManager.goto(GameManager.SCENE_PROFILE_CREATE)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_settings_pressed() -> void:
|
||||||
|
GameManager.settings_return_scene = GameManager.SCENE_PROFILE_SELECT
|
||||||
|
GameManager.goto(GameManager.SCENE_SETTINGS)
|
||||||
|
|||||||
@@ -70,5 +70,11 @@ unique_name_in_owner = true
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
custom_minimum_size = Vector2(0, 56)
|
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="."]
|
[node name="DeleteConfirmDialog" type="ConfirmationDialog" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xselbc7st0o8
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dyg8223otmtcm
|
||||||
@@ -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)
|
||||||
@@ -19,6 +19,7 @@ var _viewed_level := "CE1"
|
|||||||
@onready var _collection_button: Button = %CollectionButton
|
@onready var _collection_button: Button = %CollectionButton
|
||||||
@onready var _profile_button: Button = %ProfileButton
|
@onready var _profile_button: Button = %ProfileButton
|
||||||
@onready var _guide: Control = %Guide
|
@onready var _guide: Control = %Guide
|
||||||
|
@onready var _settings_button: Button = %SettingsButton
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -29,6 +30,8 @@ func _ready() -> void:
|
|||||||
_collection_button.pressed.connect(_on_collection_pressed)
|
_collection_button.pressed.connect(_on_collection_pressed)
|
||||||
_profile_button.text = UIText.get_text("world_map.change_profile_button")
|
_profile_button.text = UIText.get_text("world_map.change_profile_button")
|
||||||
_profile_button.pressed.connect(_on_change_profile_pressed)
|
_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)
|
_map_area.resized.connect(_layout_markers)
|
||||||
_guide.say(UIText.get_text("world_map.guide_hint"))
|
_guide.say(UIText.get_text("world_map.guide_hint"))
|
||||||
_refresh()
|
_refresh()
|
||||||
@@ -129,3 +132,8 @@ func _on_level_toggle_pressed() -> void:
|
|||||||
func _on_mission_pressed(mission_id: String) -> void:
|
func _on_mission_pressed(mission_id: String) -> void:
|
||||||
GameManager.pending_mission_id = mission_id
|
GameManager.pending_mission_id = mission_id
|
||||||
GameManager.goto(GameManager.SCENE_MISSION_INTRO)
|
GameManager.goto(GameManager.SCENE_MISSION_INTRO)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_settings_pressed() -> void:
|
||||||
|
GameManager.settings_return_scene = GameManager.SCENE_WORLD_MAP
|
||||||
|
GameManager.goto(GameManager.SCENE_SETTINGS)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ custom_minimum_size = Vector2(0, 52)
|
|||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="Margin/VBox/TopBar"]
|
[node name="TitleLabel" type="Label" parent="Margin/VBox/TopBar"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
clip_text = true
|
||||||
theme_type_variation = &"HeaderTitle"
|
theme_type_variation = &"HeaderTitle"
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
@@ -100,3 +102,10 @@ theme_type_variation = &"SecondaryButton"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_vertical = 4
|
size_flags_vertical = 4
|
||||||
custom_minimum_size = Vector2(0, 52)
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bx2badhy4sfb4
|
||||||
@@ -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"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dhu25ypxg0yld
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://k3ebjlqrcnn8
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cwgk0t4potw0m
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dri3p6o43m5io
|
||||||
@@ -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"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cf77xbrvbjrf0
|
||||||
@@ -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"))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cv8nfcak28jm8
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c2qnw22eh66lv
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bln38n5jm0aku
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bdv3h6fouqab6
|
||||||
+23
-4
@@ -27,15 +27,25 @@ func _initialize() -> void:
|
|||||||
typed.append(part)
|
typed.append(part)
|
||||||
game.set("last_outcomes", typed)
|
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()
|
var scene: Node = load(scene_path).instantiate()
|
||||||
root.add_child(scene)
|
root.add_child(scene)
|
||||||
for i in 5:
|
for i in 5:
|
||||||
await process_frame
|
await process_frame
|
||||||
# Actions optionnelles sur exercise_host, a partir du 5e argument :
|
# Actions optionnelles sur exercise_host, a partir du 5e argument :
|
||||||
# continue_rule | answer:<index> | toggle:<index> | type:<texte> | validate
|
# continue_rule | answer:<index> | toggle:<index> | type:<texte> | validate
|
||||||
# | continue_feedback
|
# | continue_feedback | solve_gate (espace adulte)
|
||||||
for action in args.slice(4):
|
for action in args.slice(4):
|
||||||
if action.begins_with("outcomes:"):
|
if action.begins_with("outcomes:") or action.begins_with("text_scale:"):
|
||||||
continue
|
continue
|
||||||
await _run_action(scene, action)
|
await _run_action(scene, action)
|
||||||
for i in 5:
|
for i in 5:
|
||||||
@@ -49,9 +59,18 @@ func _initialize() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _run_action(scene: Node, action: String) -> 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)
|
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]:
|
match parts[0]:
|
||||||
"continue_rule":
|
"continue_rule":
|
||||||
overlay.get_child(0).emit_signal("continued")
|
overlay.get_child(0).emit_signal("continued")
|
||||||
|
|||||||
Reference in New Issue
Block a user