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:
Denis Defolie
2026-10-11 22:49:01 +02:00
parent 97a065879c
commit dbf513e686
45 changed files with 1390 additions and 14 deletions
+63
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://bx2badhy4sfb4
+24
View File
@@ -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"])
+1
View File
@@ -0,0 +1 @@
uid://dhu25ypxg0yld
+131
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
uid://k3ebjlqrcnn8
+55
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://cwgk0t4potw0m