diff --git a/ai/chat.py b/ai/chat.py index 6b02e01c..f72d486e 100644 --- a/ai/chat.py +++ b/ai/chat.py @@ -161,6 +161,33 @@ _SYSTEM_PROMPT = ( "de l'écran (ex. centré, ou en haut) via set_object_geometry après " "les avoir posés." "\n\n" + "QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit " + "un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), " + "utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler " + "le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : " + "jamais imposé par défaut — demande TOUJOURS si le créateur en veut un " + "avant d'en activer un, \"question\" redémarre à chaque question, " + "\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds " + "sa durée)." + "\n\n" + "MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : " + "dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — " + "ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne " + "s'affiche QUE si fullscreen=false. " + "page_template (modèles \"page de quiz\" : \"classique\" — sobre/" + "professionnel, couleurs reprises du modèle \"boîte de dialogue\" de " + "base — ou \"manga\", un thème dessiné/typographié entièrement à part) " + "ne s'affiche QUE si fullscreen=true — chacun un thème complet et " + "autonome (police, formes, couleurs ET structure entièrement propres à " + "ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux " + "réglages sont conservés " + "INDÉPENDAMMENT (jamais l'un n'écrase l'autre) : ne règle QUE celui qui " + "correspond au mode (plein écran ou non) réellement voulu par le " + "créateur, et demande le style souhaité s'il n'en a mentionné aucun " + "plutôt que d'en choisir un au hasard. En plein écran, le score choisi " + "via score_widget/reward_amount s'affiche automatiquement DANS la boîte " + "à quiz, sans réglage supplémentaire." + "\n\n" "Ton professionnel, adapté à une formation d'entreprise. Réponds " "toujours en français, de façon concise, en confirmant ce que tu as " "posé." diff --git a/ai/tools.py b/ai/tools.py index 56475e2e..794e3549 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -40,6 +40,9 @@ _CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS) _DATA_OPERATIONS = sorted(DATA_OPERATION_LABELS) _PERSONNAGE_ROLES = list(screens.PERSONNAGE_ROLES) _COLLISION_SHAPES = list(screens.COLLISION_SHAPES) +_QUIZ_BOX_TIMER_MODES = list(screens.TIMER_MODES) +_QUIZ_BOX_DIALOG_TEMPLATES = list(screens.QUIZ_BOX_DIALOG_TEMPLATES) +_QUIZ_BOX_PAGE_TEMPLATES = list(screens.QUIZ_BOX_PAGE_TEMPLATES) _GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES) # Catalogue COMPLET (public + admin-only, voir core/sprite_gate.py) — # Claude doit connaître les slugs valides pour ne jamais en deviner un @@ -210,6 +213,32 @@ TOOLS = [ "required": ["object_id"], }, }, + { + "name": "set_quiz_box_config", + "description": ( + "Règle les options d'une \"❓ Boîte à quiz\" pensées pour un quiz AUTONOME " + "(RH/formation) : plein écran, minuteur (jamais imposé — \"aucun\" par défaut, " + "au choix du créateur : \"question\" redémarre à chaque question, \"quiz\" est un " + "seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS " + "(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : " + "dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur " + "sur une structure proche de \"Classique\") et page_template (visible SEULEMENT en " + "plein écran — thème complet et autonome, structure HTML propre à chaque modèle). " + "En plein écran, le score se retrouve affiché directement dans la boîte." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "fullscreen": {"type": "boolean"}, + "timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES}, + "timer_seconds": {"type": "integer", "description": "Durée du minuteur (3 à 600s), ignorée si timer_mode=\"aucun\"."}, + "dialog_template": {"type": "string", "enum": _QUIZ_BOX_DIALOG_TEMPLATES, "description": "Modèle \"boîte de dialogue\" — ne s'affiche que si fullscreen=false."}, + "page_template": {"type": "string", "enum": _QUIZ_BOX_PAGE_TEMPLATES, "description": "Modèle \"page de quiz\" — ne s'affiche que si fullscreen=true."}, + }, + "required": ["object_id"], + }, + }, { "name": "create_global_variable", "description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.", @@ -502,6 +531,27 @@ def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled= return {"ok": True} +def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscreen=False, + timer_mode="aucun", timer_seconds=20, + dialog_template="defaut", page_template="classique"): + # Même forme que screens.set_scene_object_quiz_config (remplacement + # complet des réglages, jamais un merge partiel) — screens.py sanitize + # de toute façon toute valeur hors énumération, ce garde-fou ici sert + # juste à ne jamais transmettre None à sanitize_quiz_box_config. + # dialog_template/page_template sont conservés INDÉPENDAMMENT (voir + # screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul + # sans jamais écraser l'autre. + config = { + "fullscreen": bool(fullscreen), + "timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun", + "timer_seconds": timer_seconds or 20, + "dialog_template": dialog_template if dialog_template in _QUIZ_BOX_DIALOG_TEMPLATES else "defaut", + "page_template": page_template if page_template in _QUIZ_BOX_PAGE_TEMPLATES else "classique", + } + screens.set_scene_object_quiz_config(slug, object_id, config) + return {"ok": True} + + def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True): variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player) return {"variable_id": variable_id} @@ -571,6 +621,7 @@ _DISPATCH = { "set_object_name": _dispatch_set_object_name, "set_object_role": _dispatch_set_object_role, "set_object_collision": _dispatch_set_object_collision, + "set_quiz_box_config": _dispatch_set_quiz_box_config, "create_global_variable": _dispatch_create_global_variable, "set_collision_rules": _dispatch_set_collision_rules, "append_action_to_trigger": _dispatch_append_action_to_trigger, diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index decff8e4..b1518dbc 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -8,5 +8,6 @@ from . import ( scene_object_collision, scene_object_name, scene_object_dialogue_box_style, + scene_object_quiz_config, scene_object_names_list, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index ef73c0bb..53a84154 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -22,6 +22,18 @@ def render_scene_edit(slug, screen_id, game): objects = screens.list_scene_objects(slug, screen_id) for o in objects: o["rendered_html"] = screens.render_scene_object(o) + # "Plein écran" (demande explicite : la boîte à quiz doit occuper + # tout le canevas de CET éditeur, pas seulement au jeu, dès que ce + # réglage est coché) — calculé pour TOUS les objets (pas + # seulement le sélectionné) : plusieurs quiz plein écran sur le + # même écran resteraient rares/déconseillés, mais rien n'empêche + # techniquement d'en poser plusieurs. + o["quiz_fullscreen"] = o["kind"] == "quiz_box" and screens.resolve_quiz_box_config(o)["fullscreen"] + # La "notion de caméra disparaît" (demande explicite) : le cadre "🎥 + # Champ de la caméra" n'a plus aucun sens dès qu'un quiz plein écran + # est posé sur cet écran, caméra ou pas — masqué dans ce cas (voir + # templates/scene_edit.html). + any_quiz_box_fullscreen = any(o["quiz_fullscreen"] for o in objects) # "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par # la miniature de "Mes écrans", routes/games/game_dashboard.py) : un @@ -41,6 +53,7 @@ def render_scene_edit(slug, screen_id, game): personnage_role = None collision_settings = None dialogue_box_style = None + quiz_box_config = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None _UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget") if selected_obj: @@ -52,6 +65,12 @@ def render_scene_edit(slug, screen_id, game): personnage_role = screens.resolve_personnage_role(selected_obj) elif selected_obj["kind"] in _UI_WIDGET_KINDS: dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj) + if selected_obj["kind"] == "quiz_box": + # Plein écran/minuteur/modèle (demande explicite : quiz RH + # autonome) — voir screens/rendering/quiz_box_config.py, + # PANNEAU distinct de "🎨 Style" ci-dessus (couleurs/police, + # partagé avec dialogue_box/score_widget). + quiz_box_config = screens.resolve_quiz_box_config(selected_obj) _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} object_labels = { @@ -117,6 +136,7 @@ def render_scene_edit(slug, screen_id, game): "scene_edit.html", game=game, screen=screen, all_screens=all_screens, world_width=world_width, world_height=world_height, + any_quiz_box_fullscreen=any_quiz_box_fullscreen, objects=objects, selected_id=selected_id, personnage_data=personnage_data, personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS, personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE, @@ -124,6 +144,12 @@ def render_scene_edit(slug, screen_id, game): collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS, dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE, dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES, + quiz_box_config=quiz_box_config or screens.DEFAULT_QUIZ_BOX_CONFIG, + quiz_box_timer_modes=screens.TIMER_MODES, + quiz_box_dialog_templates=screens.QUIZ_BOX_DIALOG_TEMPLATES, + quiz_box_dialog_template_labels=screens.QUIZ_BOX_DIALOG_TEMPLATE_LABELS, + quiz_box_page_templates=screens.QUIZ_BOX_PAGE_TEMPLATES, + quiz_box_page_template_labels=screens.QUIZ_BOX_PAGE_TEMPLATE_LABELS, sprite_library=sprite_library, sprite_library_families=sprite_library_families, sprite_library_json=json.dumps(sprite_library), background_library=background_library, diff --git a/routes/scenes/scene_object_quiz_config.py b/routes/scenes/scene_object_quiz_config.py new file mode 100644 index 00000000..8fdd183e --- /dev/null +++ b/routes/scenes/scene_object_quiz_config.py @@ -0,0 +1,23 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//quiz-config", methods=["POST"]) +def scene_object_quiz_config(slug, object_id): + """Réglages "❓ Boîte à quiz" (plein écran/minuteur/modèle, voir + screens/rendering/quiz_box_config.py) — mirror exact de + routes/scenes/scene_object_dialogue_box_style.py.""" + body = request.get_json(silent=True) or {} + config = screens.set_scene_object_quiz_config(slug, object_id, body.get("config")) + if config is None: + return jsonify({"error": "objet introuvable"}), 404 + # Un modèle change la STRUCTURE du balisage, pas juste une classe CSS + # (voir screens/rendering/quiz_box_templates.py) — renvoyer le HTML + # déjà rendu permet à scene-editor.js de remplacer l'aperçu en un + # coup, plutôt que de deviner quelle balise ajouter/retirer côté + # client. + obj = screens.get_scene_object(slug, object_id) + return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)}) diff --git a/screens/__init__.py b/screens/__init__.py index 9342bdeb..cafe563b 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -63,6 +63,11 @@ from .labels.video_modes import VIDEO_MODES, VIDEO_MODE_LABELS from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .rendering.scene_object_names import list_named_scene_object_names from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES +from .rendering.quiz_box_config import ( + resolve_quiz_box_config, sanitize_quiz_box_config, DEFAULT_QUIZ_BOX_CONFIG, + TIMER_MODES, QUIZ_BOX_DIALOG_TEMPLATES, QUIZ_BOX_DIALOG_TEMPLATE_LABELS, + QUIZ_BOX_PAGE_TEMPLATES, QUIZ_BOX_PAGE_TEMPLATE_LABELS, +) from .screens_repo.ensure_schema import ensure_schema from .screens_repo.list_screens import list_screens @@ -93,6 +98,7 @@ from .scenes.set_scene_object_collision import set_scene_object_collision from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules from .scenes.set_scene_object_name import set_scene_object_name from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style +from .scenes.set_scene_object_quiz_config import set_scene_object_quiz_config from .scenes.render_scene_object import render_scene_object from .scenes.build_collision_rule_objects import build_collision_rule_objects from .scenes.append_action_to_trigger import append_action_to_trigger diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 8f779e93..d028ed61 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -11,6 +11,7 @@ from ..rendering.collision_settings import resolve_collision_settings from ..rendering.collision_rules import resolve_collision_rules from ..rendering.screen_triggers import resolve_screen_triggers from ..rendering.dialogue_box_style import resolve_dialogue_box_style +from ..rendering.quiz_box_config import resolve_quiz_box_config from ..rendering.trigger_graph import list_completable_dialogue_ids from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -62,6 +63,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # collision, ni cible/source de règle — juste leur # style, lu par static/js/play/dialogue-box-controller.js. o["dialogue_box_style"] = resolve_dialogue_box_style(o) + if o["kind"] == "quiz_box": + # Plein écran/minuteur/modèle (voir screens/rendering/ + # quiz_box_config.py) — même convention que + # dialogue_box_style ci-dessus (cohérence de contrat), + # même si le JS runtime lit en pratique les data-* + # déjà posés sur rendered_html. + o["quiz_config"] = resolve_quiz_box_config(o) continue # Boîte de collision (voir static/js/play/conditions.js:: # elementsOverlap) : posée sur TOUT objet de scène, pas @@ -83,6 +91,19 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # colonne brute _screen_triggers (laissée telle quelle par ailleurs # dans `s`, comme le reste des colonnes _screens). s["screen_triggers"] = resolve_screen_triggers(s) + # "La notion de caméra disparaît" quand un quiz plein écran existe + # sur cet écran (demande explicite) — pas seulement le widget lui- + # même : ".playScreen.playScene" (templates/play.html), dont la + # taille EST la "caméra" (scene_width/scene_height) et qui reste + # centré via un `transform`, casse `position:fixed` pour tout + # descendant (voir static/js/play/dialogue-box-controller.js:: + # forgeEscapeCameraForFullscreen). Exposé ici pour que play.html + # rende directement CET écran en plein viewport, sans transform ni + # taille caméra — le réglage "Caméra (px)" ne s'applique alors plus + # du tout tant que ce quiz reste plein écran. + s["any_quiz_box_fullscreen"] = any( + o["kind"] == "quiz_box" and o.get("quiz_config", {}).get("fullscreen") for o in objects + ) payload_screens.append({**s, "elements": objects}) # Le graphe de logique et les animations sont, eux, lus pour TOUS les diff --git a/screens/rendering/quiz_box_config.py b/screens/rendering/quiz_box_config.py new file mode 100644 index 00000000..f6462ecf --- /dev/null +++ b/screens/rendering/quiz_box_config.py @@ -0,0 +1,97 @@ +import json + +# Réglages "❓ Boîte à quiz" propres au quiz AUTONOME/plein écran (demande +# explicite : "étendre les fonctionnalités de la boîte à quiz pour les RH +# qui veulent juste faire un quiz") — DISTINCT de "🎨 Style" +# (dialogue_box_style.py, partagé avec dialogue_box/score_widget) : ce +# fichier ne gère QUE plein écran/minuteur/modèle, jamais police/couleur. +# +# DEUX catégories de modèles bien distinctes (demande explicite, précisée +# après coup — les 5 premiers modèles construits avant cette précision +# étaient tous de la première catégorie) : +# - "dialog_template" (modèles "boîte de dialogue", visibles SEULEMENT +# quand fullscreen=False) : variantes de forme/couleur/interaction sur +# une structure HTML qui reste globalement la même que "Classique". +# - "page_template" (modèles "page de quiz", visibles SEULEMENT quand +# fullscreen=True) : thème complet et autonome — police, formes, +# couleurs ET structure HTML totalement propres à CHAQUE modèle, +# aucune structure partagée entre eux ni avec les modèles "boîte". +# Les deux réglages sont conservés INDÉPENDAMMENT (jamais écrasés l'un par +# l'autre) : activer/désactiver le plein écran change juste lequel des +# deux s'applique, sans jamais perdre le choix de l'autre. +DEFAULT_QUIZ_BOX_CONFIG = { + "fullscreen": False, + "timer_mode": "aucun", + "timer_seconds": 20, + "dialog_template": "defaut", + "page_template": "classique", +} + +# "aucun" : pas de minuteur (réglage par défaut, jamais imposé — demande +# explicite du créateur). "question" : redémarre à chaque question. +# "quiz" : un seul compte à rebours pour l'ensemble du quiz. +TIMER_MODES = ["aucun", "question", "quiz"] + +# "defaut" (Classique) = le CSS/HTML déjà existant, AUCUN changement +# visuel pour un quiz non reconfiguré (voir quiz_box_templates.py) — +# "Verre dépoli"/"Néon"/"Minimal"/"Ludique" ont été retirés (demande +# explicite : "supprime tous les modele de boite sauf le base"). +# "manga_dialogue" (Manga) : pendant, en petite carte flottante (jamais +# plein écran), du modèle "page de quiz" "Manga" — validé par le créateur +# via une maquette interactive avant intégration (voir +# screens/rendering/quiz_box_templates.py::render_quiz_box_manga_dialogue). +# Clé DIFFÉRENTE du modèle "page" "manga" (registre d'effets JS partagé +# entre les deux catégories, voir static/js/play/quiz-box-templates.js) +# — reste proche de la structure de "Classique" (demande explicite : +# les modèles "boîte" ne changent que forme/couleur/interaction). +QUIZ_BOX_DIALOG_TEMPLATES = ["defaut", "manga_dialogue"] +QUIZ_BOX_DIALOG_TEMPLATE_LABELS = { + "defaut": "Classique", + "manga_dialogue": "Manga", +} + +# "classique" (Classique) : modèle de PAGE DE QUIZ de base — sobre/ +# professionnel, reprend les couleurs par défaut du modèle "boîte de +# dialogue" de base (demande explicite : "respecte les couleurs du modele +# de boite de base pour le modele de page de base") plutôt que sa propre +# palette, contrairement à "manga" qui est un thème pleinement autonome. +# "manga" : thème/typographie/structure entièrement dédiés, validé par le +# créateur via une maquette interactive avant intégration (voir +# screens/rendering/quiz_box_templates.py::render_quiz_box_page_manga). +QUIZ_BOX_PAGE_TEMPLATES = ["classique", "manga"] +QUIZ_BOX_PAGE_TEMPLATE_LABELS = { + "classique": "Classique", + "manga": "Manga", +} + +_MIN_TIMER_SECONDS = 3 +_MAX_TIMER_SECONDS = 600 + + +def sanitize_quiz_box_config(raw_config): + config = dict(DEFAULT_QUIZ_BOX_CONFIG) + if not isinstance(raw_config, dict): + return config + config["fullscreen"] = bool(raw_config.get("fullscreen")) + if raw_config.get("timer_mode") in TIMER_MODES: + config["timer_mode"] = raw_config["timer_mode"] + try: + config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_config.get("timer_seconds")))) + except (TypeError, ValueError): + pass + if raw_config.get("dialog_template") in QUIZ_BOX_DIALOG_TEMPLATES: + config["dialog_template"] = raw_config["dialog_template"] + if raw_config.get("page_template") in QUIZ_BOX_PAGE_TEMPLATES: + config["page_template"] = raw_config["page_template"] + return config + + +def resolve_quiz_box_config(obj): + raw = (obj.get("attributes") or {}).get("_quiz_box_config") + if not raw: + return dict(DEFAULT_QUIZ_BOX_CONFIG) + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return dict(DEFAULT_QUIZ_BOX_CONFIG) + return sanitize_quiz_box_config(parsed) diff --git a/screens/rendering/quiz_box_templates.py b/screens/rendering/quiz_box_templates.py new file mode 100644 index 00000000..054e0d2e --- /dev/null +++ b/screens/rendering/quiz_box_templates.py @@ -0,0 +1,337 @@ +import html as html_lib + +# ---------- "❓ Boîte à quiz" — DEUX catégories de modèles bien distinctes +# (voir screens/rendering/quiz_box_config.py) : +# - "boîte de dialogue" (dialog_template, visibles SEULEMENT hors plein +# écran) : "defaut" (Classique) + "manga_dialogue" (Manga), voir +# render_quiz_box_manga_dialogue plus bas — "Verre dépoli"/"Néon"/ +# "Minimal"/"Ludique" ont été retirés (demande explicite : "supprime +# tous les modele de boite sauf le base") ; +# - "page de quiz" (page_template, visibles SEULEMENT en plein écran, +# voir render_quiz_box_page_classique/render_quiz_box_page_manga plus +# bas) : thème complet et autonome, structure HTML totalement propre +# à CHAQUE modèle, aucune structure partagée avec les modèles +# "boîte" ni entre eux. +# Chaque modèle a son propre bloc CSS (static/style.css) + éventuellement +# son propre effet JS (static/js/play/quiz-box-templates.js), mais TOUS +# doivent respecter le même "contrat" de points d'ancrage pour que le +# moteur PARTAGÉ (static/js/play/dialogue-box-controller.js) fonctionne +# quel que soit le modèle choisi : +# - le conteneur racine porte class="quizBoxWidget[ quizBoxWidget--X]", +# data-object-id/data-element-id, data-quiz-template, data-fullscreen, +# data-timer-mode, data-timer-seconds (voir _quiz_data_attrs) ; +# - [data-quiz-role="header"] / "question" / "choices" — JS y écrit +# TOUJOURS via .textContent ou remplace .innerHTML en entier (jamais +# un ajout) : ne JAMAIS poser de décoration (icône, badge, score...) +# comme ENFANT de ces éléments précis, elle serait effacée au premier +# rafraîchissement — un ::before CSS est la seule façon sûre d'ajouter +# une icône à ces zones-là. C'est pourquoi le score/minuteur (voir +# juste en dessous) vit dans un élément FRÈRE du header (jamais son +# enfant), même si visuellement il doit apparaître à l'intérieur du +# même bandeau (demande explicite : "le score doit être à l'intérieur +# de la boîte à quiz, pas à l'extérieur") — voir _quiz_header_row_html, +# qui les pose côte à côte dans un même bandeau partageant le même +# fond, plutôt qu'en deux bandes empilées de couleurs différentes ; +# - [data-quiz-role="score"]/[data-quiz-role="timer"] — un modèle +# "boîte" les regroupe dans un [data-quiz-role="topbar"] (masqué hors +# plein écran par static/style.css) ; un modèle "page" n'a pas besoin +# de ce regroupement/masquage (il n'existe QUE plein écran) et peut +# les placer où il veut (voir render_quiz_box_page_manga) — le moteur +# JS ne cherche jamais "topbar" lui-même, seulement score/timer. +# "defaut" (Classique) reste visuellement identique au style déjà en +# place avant cette extension (pas de suffixe de classe) — voir +# render_quiz_box_defaut. + + +def _quiz_data_attrs(obj, config, active_template): + return ( + f'data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" ' + f'data-quiz-template="{active_template}" ' + f'data-fullscreen="{"1" if config["fullscreen"] else "0"}" ' + f'data-timer-mode="{config["timer_mode"]}" data-timer-seconds="{config["timer_seconds"]}"' + ) + + +def _quiz_box_style(obj, style): + return ( + f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " + f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; " + f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; " + f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};" + ) + + +def _quiz_topbar_html(): + return ( + '
' + 'Score : 0' + '' + '
' + ) + + +def _quiz_header_row_html(header_text, header_bg=None): + # Le score/minuteur (topbar) est un FRÈRE du header, jamais son enfant + # (voir le commentaire d'en-tête) — les deux partagent ici le MÊME + # bandeau (fond commun posé sur ".quizBoxHeaderRow", jamais sur le + # header seul) pour que le score, une fois visible en plein écran, + # apparaisse comme faisant partie du même bloc plutôt qu'une bande à + # part au-dessus (demande explicite). `header_bg=None` : aucun fond + # posé, le bandeau reste transparent (repli disponible pour un futur + # modèle qui n'en aurait pas besoin). + bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else '' + return ( + f'
' + f'
{header_text}
' + + _quiz_topbar_html() + + '
' + ) + + +def render_quiz_box_defaut(obj, config, style): + return ( + f'' + ) + + +def render_quiz_box_manga_dialogue(obj, config, style): + # "Manga" (boîte de dialogue) — pendant, en petite carte flottante + # JAMAIS plein écran, du modèle "page de quiz" "Manga" (voir + # render_quiz_box_page_manga), validé par le créateur via une + # maquette interactive avant intégration. Réutilise TELLE QUELLE la + # structure partagée des modèles "boîte" (quizBoxHeaderRow/ + # quizBoxHeader/quizBoxTopBar/quizBoxBody/quizBoxQuestionText/ + # quizBoxChoices/quizBoxChoiceBtn) — seule la DÉCORATION change (voir + # CSS), jamais la structure (demande explicite : les modèles "boîte" + # "s'éloignent peu du modèle basic", contrairement aux modèles + # "page" qui ont chacun leur structure propre). + # .quizMangaDialogueFrame : enveloppe portant la forme dentelée/le + # cadre (voir CSS) SANS l'appliquer au conteneur racine (qui doit + # rester positionnable/redimensionnable normalement) — même principe + # que les anciens modèles "Verre dépoli"/"Néon". + # "Titre de la quête" seul ici (pas "Quête : Titre de la quête") : le + # préfixe "Quête : " est posé en CSS pur (::before sur .quizBoxHeader, + # voir static/style.css) pour survivre à l'écrasement JS du header + # (voir le commentaire de classe ci-dessus) — l'écrire aussi ici le + # doublerait dans l'aperçu éditeur (jamais réécrit par le JS avant le + # premier vrai jeu). + return ( + f'' + ) + + +def render_quiz_box_page_manga(obj, config, style): + # "Manga" — premier modèle "PAGE DE QUIZ" (voir screens/rendering/ + # quiz_box_config.py::QUIZ_BOX_PAGE_TEMPLATES) : thème complet et + # autonome, validé par le créateur via une maquette interactive avant + # intégration — structure HTML totalement propre à ce modèle, AUCUNE + # réutilisation de _quiz_header_row_html/quizBoxHeader/quizBoxBody des + # modèles "boîte" ci-dessus (demande explicite : "la structure du html + # qui change totalement"). Respecte quand même le contrat minimal + # (data-quiz-role="header"/"question"/"choices"/"score"/"timer") pour + # que le moteur JS partagé fonctionne sans modification. + # + # Score/minuteur : PAS de "topbar" à masquer/afficher ici (ce modèle + # n'existe QUE plein écran, il n'y a jamais lieu de les cacher) — deux + # plaques séparées de part et d'autre d'un cartouche de titre, comme + # dans la maquette validée. Les lettres A/B/C/D + le tampon "正解"/ + # "不正解" à la révélation sont posés en CSS pur (::before/::after, + # voir static/style.css) : les boutons de choix injectés par le + # moteur JS (static/js/play/dialogue-box-controller.js) restent de + # simples ' + '' + '' + # Écran de résultat de fin de quiz — voir forgeQuizMangaOnQuizComplete + # (static/js/play/quiz-box-templates.js), même mécanique que + # "classique" (render_quiz_box_page_classique) mais dans le thème + # manga propre à ce modèle. Pas de conteneur "content" à part ici + # (contrairement à .classiqueContent) : c'est la RACINE du widget + # elle-même qui bascule en classe "is-showing-result" (voir + # static/style.css) pour masquer question/choix, en gardant le + # bandeau topbar/score + la barre d'étapes visibles. data-manga- + # role (pas data-quiz-role) : décoration propre à ce modèle. + '
' + '' + ) + + +def render_quiz_box_page_classique(obj, config, style): + # "Classique" — modèle de PAGE DE QUIZ DE BASE (sobre/professionnel), + # validé par le créateur via une maquette interactive (formation + # sécurité incendie comme exemple). Reprend VOLONTAIREMENT les + # couleurs par défaut du modèle "boîte de dialogue" de base + # (header_bg/body_bg/text_color, voir dialogue_box_style.py) — demande + # explicite : "respecte les couleurs du modele de boite de base pour + # le modele de page de base" — plutôt que d'inventer sa propre + # palette comme "manga". + # + # Titre = le champ "Nom" de CET objet (déjà existant aujourd'hui, + # aucun nouveau champ "titre" nécessaire, demande explicite) — rendu + # ICI, une bonne fois pour toutes côté serveur, jamais réécrit par le + # moteur JS (à l'inverse de [data-quiz-role="header"] plus bas, qui + # sert ici à autre chose : voir le commentaire dessus). + title = html_lib.escape(obj.get("name") or "Quiz") + timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else "" + return ( + # `--classique-footer-bg` (custom property posée sur la racine) : + # lue par .classiqueChoices .quizBoxContinueBtn (voir plus bas) — + # le bouton "Continuer →" du moteur partagé (injecté APRÈS coup + # dans [data-quiz-role="choices"]) se pose en bandeau bas façon + # pied de page, dans la couleur configurée via "🎨 Style" + # (footer_bg), jamais une couleur figée en dur. + f'' + ) + + +QUIZ_BOX_DIALOG_RENDERERS = { + "defaut": render_quiz_box_defaut, + "manga_dialogue": render_quiz_box_manga_dialogue, +} + +QUIZ_BOX_PAGE_RENDERERS = { + "classique": render_quiz_box_page_classique, + "manga": render_quiz_box_page_manga, +} + + +def render_quiz_box(obj, config, style): + """Point d'entrée UNIQUE (voir screens/scenes/render_scene_object.py) + — bascule entre les deux catégories de modèles selon `fullscreen` + (demande explicite : ces modèles ne se voient JAMAIS dans l'autre + mode) plutôt que de laisser l'appelant deviner quelle liste + consulter.""" + if config["fullscreen"]: + renderer = QUIZ_BOX_PAGE_RENDERERS.get(config["page_template"], render_quiz_box_page_classique) + else: + renderer = QUIZ_BOX_DIALOG_RENDERERS.get(config["dialog_template"], render_quiz_box_defaut) + return renderer(obj, config, style) diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index d86da49d..86624dad 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -2,6 +2,8 @@ import html as html_lib from ..rendering.personnage_data import resolve_personnage_idle_frame from ..rendering.dialogue_box_style import resolve_dialogue_box_style +from ..rendering.quiz_box_config import resolve_quiz_box_config +from ..rendering.quiz_box_templates import render_quiz_box def render_scene_object(obj): @@ -72,31 +74,20 @@ def render_scene_object(obj): ) if obj["kind"] == "quiz_box": # Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le - # MÊME style que "💬 Boîte de dialogue" (resolve_dialogue_box_style/ - # set_scene_object_dialogue_box_style, une seule "🎨 Style" pour - # les deux) : header = "Quête : " (comme demandé), corps = - # la question ET ses choix de réponse ensemble (remplis EN JEU par - # static/js/play/dialogue-box-controller.js, data-quiz-role sert de - # sélecteur) — pas de pied (une question se résout au clic sur un - # choix, pas de "Suivant"). Masqué par défaut au jeu, comme la - # boîte de dialogue. + # MÊME style couleur/police que "💬 Boîte de dialogue" + # (resolve_dialogue_box_style/set_scene_object_dialogue_box_style, + # une seule "🎨 Style" pour les deux, appliquée à TOUS les + # modèles ci-dessous). Le rendu HTML lui-même dépend du modèle + # choisi (plein écran/minuteur/modèle, voir + # screens/rendering/quiz_box_config.py) — voir + # screens/rendering/quiz_box_templates.py pour le détail de + # chacun (thèmes complets HTML+CSS+JS, demande explicite) ; tous + # respectent le même contrat de data-quiz-role, lu EN JEU par + # static/js/play/dialogue-box-controller.js. "defaut" (Classique) + # reste pixel-identique au rendu d'avant cette extension. style = resolve_dialogue_box_style(obj) - box_style = ( - f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " - f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; " - f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; " - f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};" - ) - return ( - f'' - ) + config = resolve_quiz_box_config(obj) + return render_quiz_box(obj, config, style) if obj["kind"] == "score_widget": # Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une # fois posé (pas de collision/déclencheur, contrairement aux deux diff --git a/screens/scenes/set_scene_object_quiz_config.py b/screens/scenes/set_scene_object_quiz_config.py new file mode 100644 index 00000000..54535430 --- /dev/null +++ b/screens/scenes/set_scene_object_quiz_config.py @@ -0,0 +1,22 @@ +import json + +import db + +from ..rendering.quiz_box_config import sanitize_quiz_box_config +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_quiz_config(slug, object_id, raw_config): + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + if not row: + conn.close() + return None + attributes = json.loads(row["attributes"] or "{}") + config = sanitize_quiz_box_config(raw_config) + attributes["_quiz_box_config"] = json.dumps(config) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() + return config diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index 75916194..0a781ada 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -14,9 +14,10 @@ global.gameData = {}; const { forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue, - forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets, + forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm, - forgeComputeFinalScormStatus, + forgeComputeFinalScormStatus, forgeEscapeCameraForFullscreen, + forgeQuizResultQuit, } = require('../dialogue-box-controller.js'); function fakeWidget(objectId) { @@ -28,7 +29,10 @@ function fakeWidget(objectId) { const classes = new Set(); return { getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null), - classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + classList: { + add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c), + toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; }, + }, querySelector: sel => { if (sel === '[data-dialogue-role="header"]') return roles.header; if (sel === '[data-dialogue-role="body"]') return roles.body; @@ -39,6 +43,45 @@ function fakeWidget(objectId) { }; } +// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est +// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene" +// (templates/play.html) est transformé (centré via translate), ce qui +// casse position:fixed pour tout descendant : un plein écran ne peut +// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme +// static/js/play/video-overlay.js le fait déjà pour son propre overlay. +test('forgeEscapeCameraForFullscreen déplace le widget en enfant de et retient l\'écran d\'origine', () => { + const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) }; + const attrs = {}; + const classes = new Set(); + const bodyChildren = []; + const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } }; + const widget = { + parentNode: null, + closest: sel => (sel === '.playScreen' ? screenEl : null), + setAttribute: (k, v) => { attrs[k] = v; }, + classList: { add: c => classes.add(c) }, + }; + const previousDoc = global.document; + global.document = { body }; + try { + forgeEscapeCameraForFullscreen(widget); + assert.equal(bodyChildren[0], widget); + assert.equal(widget.parentNode, body); + assert.equal(attrs['data-owner-screen-id'], '7'); + assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true); + } finally { global.document = previousDoc; } +}); + +test('forgeEscapeCameraForFullscreen ne fait rien si déjà enfant de (jamais déplacé deux fois)', () => { + const body = { appendChild: () => { throw new Error('ne doit pas être rappelé'); } }; + const widget = { parentNode: body }; + const previousDoc = global.document; + global.document = { body }; + try { + assert.doesNotThrow(() => forgeEscapeCameraForFullscreen(widget)); + } finally { global.document = previousDoc; } +}); + test.beforeEach(() => { global.window.currentScreenId = 1; // Compteurs de réussite (voir forgeComputeFinalScormStatus) : remis à @@ -228,27 +271,58 @@ function fakeQuizChoiceBtn() { return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes }; } -function fakeQuizWidget(objectId, choiceCount) { - const roles = { header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' } }; +function fakeQuizWidget(objectId, choiceCount, attrs) { + attrs = attrs || {}; + const roles = { + header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' }, + score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } }, + }; + // Posé désactivé par forgeShowQuizBox dès la question (jamais recréé + // par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir + // les tests dédiés plus bas. Un objet à part (pas dérivé de + // roles.choices.innerHTML, une simple chaîne dans ce mock) pour + // pouvoir observer .disabled/.textContent comme un vrai élément DOM. + const continueBtn = { disabled: true, textContent: 'Continuer →' }; const classes = new Set(); // querySelectorAll('.quizBoxChoiceBtn') n'a pas besoin de refléter le // HTML réellement injecté dans roles.choices.innerHTML (voir // forgeShowQuizBox) pour ces tests — juste le bon NOMBRE de boutons, // marqués correct/wrong par forgeQuizBoxAnswer via ces mêmes objets. const buttons = Array.from({ length: choiceCount }, () => fakeQuizChoiceBtn()); - return { - getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null), - classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + const widget = { + parentNode: null, + // Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) : + // un widget "posé sur l'écran courant" est toujours, au départ, + // l'enfant d'un ".playScreen" fictif portant le même id d'écran. + closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null), + getAttribute: attr => { + if (attr === 'data-object-id') return String(objectId); + if (attr in attrs) return attrs[attr]; + return null; + }, + setAttribute: (k, v) => { attrs[k] = v; }, + classList: { + // Variadique (comme le vrai DOMTokenList.remove/add) : le moteur + // appelle parfois .remove('is-active', 'is-fullscreen') d'un coup + // (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen). + add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)), + contains: c => classes.has(c), + toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; }, + }, querySelector: sel => { if (sel === '[data-quiz-role="header"]') return roles.header; if (sel === '[data-quiz-role="question"]') return roles.question; if (sel === '[data-quiz-role="choices"]') return roles.choices; + if (sel === '[data-quiz-role="score"]') return roles.score; + if (sel === '[data-quiz-role="timer"]') return roles.timer; + if (sel === '.quizBoxContinueBtn') return continueBtn; return null; }, querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []), offsetWidth: 100, - _roles: roles, _classes: classes, _buttons: buttons, + _roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn, }; + return widget; } function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { @@ -260,7 +334,18 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById return null; }, - querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests + querySelectorAll(sel) { + // aucun widget "🏆 Score" dans ces tests — seul le score AFFICHÉ + // DANS la boîte à quiz elle-même (voir forgeUpdateQuizBoxScoreDisplays) + // a besoin d'être résolu ici, via le rôle déjà exposé par + // fakeQuizWidget ci-dessus. + if (sel === '.quizBoxWidget [data-quiz-role="score"]') return quizWidget ? [quizWidget._roles.score] : []; + return []; + }, + // Nécessaire pour un quiz PLEIN ÉCRAN (voir forgeEscapeCameraForFullscreen) : + // pas besoin de simuler un vrai retrait/réinsertion DOM pour ces + // tests, juste que l'appel ne plante pas. + body: { appendChild: () => {} }, }; } @@ -274,6 +359,197 @@ test.beforeEach(() => { window.forgeQuizScore = 0; }); +// Bug signalé : "j'ai créé un quiz à l'affichage de l'écran avec l'ia, +// mais quand je fais jouer rien ne s'affiche" — un quiz RH AUTONOME (que +// des questions, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de +// dialogue" posée, seulement d'une "❓ Boîte à quiz" ; forgeShowDialogueBox +// sortait pourtant en silence dès le départ faute de boîte de dialogue, +// empêchant même la première question de s'afficher. +function docWithQuizOnlyNoDialogueBox(quizWidget) { + return { + querySelector(sel) { + if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null; + if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null; + return null; // jamais de ".dialogueBoxWidget" sur cet écran + }, + querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []), + body: { appendChild: () => {} }, + }; +} + +test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) affiche bien la première question', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' }); + const previousDoc = global.document; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + try { + forgeShowDialogueBox('d_30', 'Quiz', [ + { type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + assert.equal(quizWidget._classes.has('is-active'), true); + assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?'); + } finally { global.document = previousDoc; } +}); + +test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (markCompleted inclus)', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' }); + const previousDoc = global.document; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + try { + forgeShowDialogueBox('d_31', 'Quiz', [ + { type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ], true); // markCompleted + forgeQuizBoxAnswer(99, 0); // bonne réponse + assert.equal(window.forgeQuizScore, 1); + forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé + assert.equal(window.forgeCompletedDialogueIds['d_31'], true); + assert.equal(quizWidget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +// ---------- Écran de résultat de fin de quiz ("classique" uniquement, +// voir static/js/play/quiz-box-templates.js::forgeQuizClassiqueOnQuizComplete) +// demande explicite : "la page de resultat est cassee... elle affiche un +// conteneur qui reaffiche le contenu du quiz au lieu du resultat" — le +// vrai moteur n'en avait aucun jusqu'ici. `forgeQuizTemplateEffects` est +// une variable GLOBALE (voir quiz-box-templates.js, jamais requise ici) : +// simulée au cas par cas, jamais définie par défaut — donc TOUS les +// tests ci-dessus (aucun modèle enregistré) continuent de suivre +// l'ancien chemin (masquage simple), inchangé. ---------- + +function withFakeTemplateEffects(effects, fn) { + const previous = global.forgeQuizTemplateEffects; + global.forgeQuizTemplateEffects = effects; + try { fn(); } finally { global.forgeQuizTemplateEffects = previous; } +} + +test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de résultat : reste actif/plein écran, appelle onQuizComplete avec le score DE CE QUIZ', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' }); + const previousDoc = global.document; + let completeCalledWith = null; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => { + try { + forgeShowDialogueBox('d_50', 'Quiz', [ + { type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question + forgeQuizBoxContinue(99); + assert.equal(quizWidget._classes.has('is-active'), true, 'reste visible pour montrer le résultat, pas masqué'); + assert.equal(quizWidget._classes.has('is-fullscreen'), true); + assert.deepEqual(completeCalledWith, { correctCount: 1, totalCount: 1 }); + } finally { global.document = previousDoc; } + }); +}); + +test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de résultat, même avec un modèle qui en propose un', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' }); + const previousDoc = global.document; + let completeCalled = false; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => { + try { + forgeShowDialogueBox('d_51', 'Quiz', [ + { type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + { type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + forgeQuizBoxAnswer(99, 0); + forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin + assert.equal(completeCalled, false); + assert.equal(quizWidget._roles.question.textContent, 'Question 2 ?'); + } finally { global.document = previousDoc; } + }); +}); + +test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PUIS tente de fermer l\'onglet — demande explicite : quitter carrément, jamais recommencer', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' }); + const previousDoc = global.document; + const previousClose = window.close; + let closeCalled = false; + window.close = () => { closeCalled = true; }; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => { + try { + forgeShowDialogueBox('d_52', 'Quiz', [ + { type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + forgeQuizBoxAnswer(99, 0); + forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif + forgeQuizResultQuit(99); + assert.equal(quizWidget._classes.has('is-active'), false); + assert.equal(quizWidget._classes.has('is-fullscreen'), false); + // window.close() ne ferme RÉELLEMENT que les onglets ouverts par + // script (restriction navigateur, voir le commentaire dans + // dialogue-box-controller.js) — on ne peut donc tester ici que + // "l'appel a bien été tenté", jamais qu'un onglet a vraiment fermé. + assert.equal(closeCalled, true); + } finally { global.document = previousDoc; window.close = previousClose; } + }); +}); + +test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (navigateur qui ne l\'expose pas)', () => { + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' }); + const previousDoc = global.document; + const previousClose = window.close; + delete window.close; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => { + try { + forgeShowDialogueBox('d_54', 'Quiz', [ + { type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + forgeQuizBoxAnswer(99, 0); + forgeQuizBoxContinue(99); + assert.doesNotThrow(() => forgeQuizResultQuit(99)); + } finally { global.document = previousDoc; window.close = previousClose; } + }); +}); + +test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => { + const previousDoc = global.document; + global.document = { querySelector: () => null }; + try { + assert.doesNotThrow(() => forgeQuizResultQuit(999999)); + } finally { global.document = previousDoc; } +}); + +test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' }); + const previousDoc = global.document; + let completeCalledWith = null; + global.document = docWithQuizOnlyNoDialogueBox(quizWidget); + withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => { + try { + forgeShowDialogueBox('d_53', 'Quiz', [ + { type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + { type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 }, + ]); + forgeQuizBoxTimeout(99, true); // encore la question 2 à venir + t.mock.timers.tick(1500); + assert.deepEqual(completeCalledWith, { correctCount: 0, totalCount: 1 }); + assert.equal(quizWidget._classes.has('is-active'), true); + } finally { global.document = previousDoc; } + }); +}); + +test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jamais recréé — juste activé par une réponse (demande explicite, comme la maquette manga)', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_60', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); // -> question + // Dès l'affichage de la question, AVANT toute réponse : présent + // dans le balisage des choix ET désactivé. + assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxContinue\(99\)/); + assert.equal(quizWidget._continueBtn.disabled, true); + forgeQuizBoxAnswer(99, 1); // bonne réponse (correct_index=1) + // Le MÊME objet, juste activé — jamais un nouveau bouton recréé. + assert.equal(quizWidget._continueBtn.disabled, false); + } finally { global.document = previousDoc; } +}); + test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => { const dialogueWidget = fakeWidget(42); dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); @@ -372,11 +648,120 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la } finally { global.document = previousDoc; } }); +// ---------- Plein écran + score affiché (demande explicite) ---------- + +test('forgeShowQuizBox pose ".is-fullscreen" quand data-fullscreen="1", jamais sinon', () => { + const dialogueWidget = fakeWidget(42); + const quizWidgetOn = fakeQuizWidget(99, 2, { 'data-fullscreen': '1', 'data-timer-mode': 'aucun' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidgetOn); + try { + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); + assert.equal(quizWidgetOn._classes.has('is-fullscreen'), true); + } finally { global.document = previousDoc; } + + const quizWidgetOff = fakeQuizWidget(99, 2, { 'data-fullscreen': '0', 'data-timer-mode': 'aucun' }); + global.document = docWithDialogueAndQuiz(fakeWidget(42), quizWidgetOff); + try { + forgeShowDialogueBox('d_11', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); + assert.equal(quizWidgetOff._classes.has('is-fullscreen'), false); + } finally { global.document = previousDoc; } +}); + +test('le score affiché DANS la boîte à quiz (data-quiz-role="score") se met à jour à chaque question et bonne réponse', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); + assert.equal(quizWidget._roles.score.textContent, 'Score : 0'); + forgeQuizBoxAnswer(99, 1); // bonne réponse, +5 + assert.equal(quizWidget._roles.score.textContent, 'Score : 5'); + } finally { global.document = previousDoc; } +}); + +// ---------- Minuteur (optionnel, "aucun" par défaut — demande explicite : +// "pouvoir paramétrer un timer pour répondre aux questions") ---------- + +test('minuteur "par question" expiré : révèle la bonne réponse (aucun bouton marqué faux), puis "Continuer →" fonctionne normalement', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur (10s) + t.mock.timers.tick(10000); // le minuteur expire tout seul + assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la bonne réponse (index 1) révélée + assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), false); // aucun clic -> aucun bouton "faux" + assert.equal(window.forgeQuizScore, 0); // comptée fausse, aucun point + assert.equal(window.forgeQuizTotalAnswered, 1); + forgeQuizBoxContinue(99); // "Continuer →" fonctionne normalement après un minuteur expiré + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); + } finally { global.document = previousDoc; } +}); + +test('un minuteur "par question" toujours en cours est arrêté dès qu\'on répond (jamais de double expiration)', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); + forgeQuizBoxAnswer(99, 1); // répond avant l'expiration + assert.equal(window.forgeQuizScore, 5); + t.mock.timers.tick(10000); // le minuteur, s'il tournait encore, exploserait ici + assert.equal(window.forgeQuizTotalAnswered, 1); // toujours une seule réponse comptée + } finally { global.document = previousDoc; } +}); + +test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS termine le quiz tout seul (markCompleted/thenAction inclus)', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_20', 'Le sorcier', QUIZ_LINES, true); // markCompleted=true + forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s) + t.mock.timers.tick(5000); // expire : révèle la bonne réponse + assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); + assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne + t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz + assert.equal(window.forgeCompletedDialogueIds['d_20'], true); + assert.equal(quizWidget._classes.has('is-active'), false); + assert.equal(quizWidget._classes.has('is-fullscreen'), false); + } finally { global.document = previousDoc; } +}); + +test('forgeQuizBoxTimeout n\'a aucun effet si la question est déjà résolue (pas de double révélation)', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' }); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); + forgeDialogueBoxAdvance(42); + forgeQuizBoxAnswer(99, 1); // déjà répondu + assert.doesNotThrow(() => forgeQuizBoxTimeout(99, false)); + assert.equal(window.forgeQuizTotalAnswered, 1); // pas compté deux fois + } finally { global.document = previousDoc; } +}); + test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => { const values = [{ textContent: '' }, { textContent: '' }]; const previousDoc = global.document; window.forgeQuizScore = 42; - global.document = { querySelectorAll: () => values }; + global.document = { + querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []), + }; try { forgeUpdateAllScoreWidgets(); assert.equal(values[0].textContent, 42); @@ -384,6 +769,22 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a } finally { global.document = previousDoc; } }); +test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS une boîte à quiz plein écran', () => { + // Demande explicite : "lorsque la boîte à quiz prend tout l'écran, le + // score s'affiche dans la boîte à quiz" — voir + // forgeUpdateQuizBoxScoreDisplays, appelée par forgeUpdateAllScoreWidgets. + const quizScoreEl = { textContent: '' }; + const previousDoc = global.document; + window.forgeQuizScore = 7; + global.document = { + querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []), + }; + try { + forgeUpdateAllScoreWidgets(); + assert.equal(quizScoreEl.textContent, 'Score : 7'); + } finally { global.document = previousDoc; } +}); + // ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ---------- // Bug signalé : "les indicateurs qu'on utilise pour les dialogues // devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — @@ -500,7 +901,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et const dialogueWidget = fakeWidget(42); const quizWidget = { getAttribute: attr => (attr === 'data-object-id' ? '99' : null), - classList: { add: () => {}, remove: () => {} }, + classList: { add: () => {}, remove: () => {}, toggle: () => {} }, querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], }; @@ -527,7 +928,7 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n. const dialogueWidget = fakeWidget(42); const quizWidget = { getAttribute: attr => (attr === 'data-object-id' ? '99' : null), - classList: { add: () => {}, remove: () => {} }, + classList: { add: () => {}, remove: () => {}, toggle: () => {} }, querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], }; diff --git a/static/js/play/__tests__/quiz-box-templates.test.js b/static/js/play/__tests__/quiz-box-templates.test.js new file mode 100644 index 00000000..73128c31 --- /dev/null +++ b/static/js/play/__tests__/quiz-box-templates.test.js @@ -0,0 +1,225 @@ +// Effets visuels par modèle de "❓ Boîte à quiz" (voir static/js/play/ +// quiz-box-templates.js, appelé par dialogue-box-controller.js aux +// points d'extension du moteur partagé). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +global.document = global.document || { + createElement() { return { style: {}, className: '', appendChild() {} }; }, +}; + +const { + forgeQuizTemplateEffects, + forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete, + forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete, +} = require('../quiz-box-templates.js'); + +test('les 4 modèles sont bien enregistrés ("defaut"/"manga_dialogue" boîte de dialogue + "classique"/"manga" page de quiz)', () => { + assert.deepEqual( + Object.keys(forgeQuizTemplateEffects).sort(), + ['classique', 'defaut', 'manga', 'manga_dialogue'], + ); +}); + +test('le registre ne branche aucun effet pour les modèles "boîte de dialogue" ("defaut"/"manga_dialogue" restent proches du modèle de base, décoration CSS uniquement)', () => { + assert.deepEqual(forgeQuizTemplateEffects.defaut, {}); + assert.deepEqual(forgeQuizTemplateEffects.manga_dialogue, {}); +}); + +function fakeMangaWidget() { + const header = { textContent: 'PNJ' }; + const progress = { innerHTML: '' }; + const resultZone = { innerHTML: '' }; + const classes = new Set(); + return { + getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + querySelector(sel) { + if (sel === '[data-quiz-role="header"]') return header; + if (sel === '[data-manga-role="progress"]') return progress; + if (sel === '[data-manga-role="result"]') return resultZone; + return null; + }, + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + _header: header, _progress: progress, _resultZone: resultZone, _classes: classes, + }; +} + +test('forgeQuizMangaOnShow ("manga", onShow) affiche "Question N/M" (uniquement les lignes de type question) et une barre d\'étapes', () => { + global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } }; + global.forgeDialogueBoxState = { + 7: { + index: 3, // 0:dialogue 1:question 2:dialogue 3:question(courante) 4:question + lines: [ + { type: 'dialogue' }, + { type: 'question' }, + { type: 'dialogue' }, + { type: 'question' }, + { type: 'question' }, + ], + }, + }; + const widget = fakeMangaWidget(); + forgeQuizMangaOnShow(widget); + assert.equal(widget._header.textContent, 'Question 2/3'); + assert.equal(widget._progress.innerHTML, ''); + delete global.forgeQuizBoxState; + delete global.forgeDialogueBoxState; +}); + +test('forgeQuizMangaOnShow ne plante jamais sans widget/état', () => { + assert.doesNotThrow(() => forgeQuizMangaOnShow(null)); + delete global.forgeQuizBoxState; + delete global.forgeDialogueBoxState; + assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget())); + global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } }; + assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget())); // pas de forgeDialogueBoxState + delete global.forgeQuizBoxState; +}); + +test('forgeQuizMangaOnShow ne touche à rien si le dialogue ne contient aucune question (total === 0)', () => { + global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } }; + global.forgeDialogueBoxState = { 7: { index: 0, lines: [{ type: 'dialogue' }] } }; + const widget = fakeMangaWidget(); + forgeQuizMangaOnShow(widget); + assert.equal(widget._header.textContent, 'PNJ'); + assert.equal(widget._progress.innerHTML, ''); + delete global.forgeQuizBoxState; + delete global.forgeDialogueBoxState; +}); + +test('forgeQuizMangaOnShow retire "is-showing-result" (défensif — widget réutilisé pour un quiz déclenché à nouveau)', () => { + const widget = fakeMangaWidget(); + widget.classList.add('is-showing-result'); + forgeQuizMangaOnShow(widget); + assert.equal(widget._classes.has('is-showing-result'), false); +}); + +test('forgeQuizMangaOnQuizComplete remplit l\'écran de résultat manga (badge 合格/不合格), remplit la barre d\'étapes à 100% et bascule la RACINE du widget en "is-showing-result"', () => { + const widget = fakeMangaWidget(); + const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT; + window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70; + try { + forgeQuizMangaOnQuizComplete(widget, { correctCount: 4, totalCount: 5 }); + assert.equal(widget._classes.has('is-showing-result'), true); + assert.match(widget._resultZone.innerHTML, /Score final : 4 \/ 5/); + assert.match(widget._resultZone.innerHTML, /mangaResultBadge pass/); + assert.match(widget._resultZone.innerHTML, /合格/); + assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/); + assert.equal(widget._progress.innerHTML, ''.repeat(5)); + } finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; } +}); + +test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous le seuil de réussite', () => { + const widget = fakeMangaWidget(); + const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT; + window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70; + try { + forgeQuizMangaOnQuizComplete(widget, { correctCount: 1, totalCount: 5 }); + assert.match(widget._resultZone.innerHTML, /mangaResultBadge fail/); + assert.match(widget._resultZone.innerHTML, /不合格/); + } finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; } +}); + +test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => { + assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 })); + assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 })); +}); + +// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow) +// que "manga", juste avec son propre sélecteur de barre d'étapes +// (data-classique-role, jamais data-manga-role). +function fakeClassiqueWidget() { + const header = { textContent: 'PNJ' }; + const progress = { innerHTML: '' }; + return { + getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + querySelector(sel) { + if (sel === '[data-quiz-role="header"]') return header; + if (sel === '[data-classique-role="progress"]') return progress; + return null; + }, + _header: header, _progress: progress, + }; +} + +test('forgeQuizClassiqueOnShow ("classique", onShow) affiche "Question N/M" et une barre d\'étapes, comme "manga"', () => { + global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } }; + global.forgeDialogueBoxState = { + 7: { index: 1, lines: [{ type: 'question' }, { type: 'question' }] }, + }; + const widget = fakeClassiqueWidget(); + forgeQuizClassiqueOnShow(widget); + assert.equal(widget._header.textContent, 'Question 2/2'); + assert.equal(widget._progress.innerHTML, ''); + delete global.forgeQuizBoxState; + delete global.forgeDialogueBoxState; +}); + +test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => { + assert.doesNotThrow(() => forgeQuizClassiqueOnShow(null)); + assert.doesNotThrow(() => forgeQuizClassiqueOnShow(fakeClassiqueWidget())); +}); + +// ---------- Écran de résultat ("classique" uniquement) ---------- + +function fakeClassiqueWidgetForResult() { + const contentClasses = new Set(); + const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } }; + const resultZone = { innerHTML: '' }; + const headerTitle = { textContent: 'Formation Sécurité Incendie' }; + return { + getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + querySelector(sel) { + if (sel === '.classiqueContent') return content; + if (sel === '[data-classique-role="result"]') return resultZone; + if (sel === '.classiqueHeaderTitle') return headerTitle; + return null; + }, + _content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle, + }; +} + +test('forgeQuizClassiqueOnQuizComplete remplit l\'écran de résultat (score, badge réussi) et bascule .classiqueContent en "is-showing-result"', () => { + const widget = fakeClassiqueWidgetForResult(); + const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT; + window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70; + try { + forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 3, totalCount: 4 }); + assert.equal(widget._contentClasses.has('is-showing-result'), true); + assert.match(widget._resultZone.innerHTML, /75%/); + assert.match(widget._resultZone.innerHTML, /3 bonnes réponses sur 4/); + assert.match(widget._resultZone.innerHTML, /Formation Sécurité Incendie/); + assert.match(widget._resultZone.innerHTML, /classiqueResultBadge pass/); + assert.match(widget._resultZone.innerHTML, /Module validé/); + assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/); + assert.match(widget._resultZone.innerHTML, /Quitter le quiz/); + } finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; } +}); + +test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil de réussite', () => { + const widget = fakeClassiqueWidgetForResult(); + const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT; + window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70; + try { + forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 2, totalCount: 4 }); + assert.match(widget._resultZone.innerHTML, /50%/); + assert.match(widget._resultZone.innerHTML, /classiqueResultBadge fail/); + assert.match(widget._resultZone.innerHTML, /Module non validé — seuil 70%/); + } finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; } +}); + +test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => { + assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 })); + assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 })); +}); + +test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => { + const widget = fakeClassiqueWidgetForResult(); + widget._content.classList.add('is-showing-result'); + // fakeClassiqueWidgetForResult n'a pas les rôles attendus par + // forgeQuizProgressOnShow (header/progress) : seul le retrait de la + // classe nous intéresse ici, le reste no-op sans état global posé. + forgeQuizClassiqueOnShow(widget); + assert.equal(widget._contentClasses.has('is-showing-result'), false); +}); diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index af7e6ba8..35a2ca5f 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -110,16 +110,93 @@ function forgeResetDialogueBoxFooter(widget, objectId) { // ---------- Boîte à quiz ---------- function forgeQuizBoxWidgetOnCurrentScreen() { - return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget'); + var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget'); + if (scoped) return scoped; + // Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir + // forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus + // une fois la première question affichée — retrouvé via l'attribut + // posé au déplacement plutôt que par sa position dans le DOM. + return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]'); } function forgeQuizBoxWidgetById(objectId) { return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]'); } +// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est +// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene" +// (templates/play.html) est centré via `transform:translate(-50%,-50%)`, +// ce qui en fait la référence de positionnement ("containing block") de +// tout descendant `position:fixed` : le plein écran de la boîte à quiz +// ne couvrait donc que CE conteneur (toujours à la taille scene_width/ +// scene_height, la "caméra"), jamais le VRAI viewport du navigateur. +// Seul un déplacement hors de ce conteneur (comme static/js/play/ +// video-overlay.js le fait déjà pour son overlay) permet à +// `position:fixed` de couvrir tout l'écran pour de vrai. `data-owner- +// screen-id` (posé ici) permet à forgeQuizBoxWidgetOnCurrentScreen ci- +// dessus de le retrouver malgré le déplacement. +function forgeEscapeCameraForFullscreen(widget) { + if (widget.parentNode === document.body) return; + var screenEl = widget.closest('.playScreen'); + if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id')); + widget.classList.add('quizBoxWidget--detachedForFullscreen'); + document.body.appendChild(widget); +} + +// Point de sortie UNIQUE de la boîte à quiz (fin d'une question réussie +// vers la ligne suivante, OU fin de tout le dialogue) — y arrêter aussi +// le minuteur/retirer le plein écran ici garantit qu'aucun des deux +// n'est jamais laissé actif par erreur, quel que soit le chemin de +// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation). function forgeHideQuizBoxOnCurrentScreen() { var widget = forgeQuizBoxWidgetOnCurrentScreen(); - if (widget) widget.classList.remove('is-active'); + if (!widget) return; + widget.classList.remove('is-active', 'is-fullscreen'); + var objectId = widget.getAttribute('data-object-id'); + forgeStopQuizTimer(objectId); +} + +// ---------- Minuteur (plein écran, demande explicite : "un timer pour +// répondre aux questions") ---------- +// Optionnel — "aucun" (screens/rendering/quiz_box_config.py) est le +// réglage par défaut, jamais imposé. Porté par data-timer-mode/ +// data-timer-seconds sur le widget lui-même (voir screens/rendering/ +// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de +// payload séparé — cohérent avec le reste de ce moteur (data-* posés au +// rendu, jamais un aller-retour serveur pour du pur affichage). +var forgeQuizTimerState = {}; + +function forgeStopQuizTimer(objectId) { + var t = forgeQuizTimerState[objectId]; + if (t && t.intervalId) clearInterval(t.intervalId); + delete forgeQuizTimerState[objectId]; +} + +// mode "question" : redémarre à CHAQUE question (reset=true, appelé +// depuis forgeShowQuizBox). mode "quiz" : démarré UNE SEULE FOIS pour +// tout le quiz (reset=false une fois déjà en cours), continue de tourner +// d'une question à l'autre sans se réinitialiser. +function forgeStartQuizTimer(objectId, widget, seconds, onExpire) { + forgeStopQuizTimer(objectId); + var state = { remaining: seconds }; + forgeQuizTimerState[objectId] = state; + var timerEl = widget.querySelector('[data-quiz-role="timer"]'); + function render() { + if (!timerEl) return; + var remaining = Math.max(0, state.remaining); + var m = Math.floor(remaining / 60), s = remaining % 60; + timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s; + timerEl.classList.toggle('is-urgent', remaining <= 5); + } + render(); + state.intervalId = setInterval(function () { + state.remaining -= 1; + render(); + if (state.remaining <= 0) { + forgeStopQuizTimer(objectId); + onExpire(); + } + }, 1000); } // ---------- Score ---------- @@ -127,6 +204,17 @@ function forgeHideQuizBoxOnCurrentScreen() { function forgeUpdateAllScoreWidgets() { var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]'); for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore; + forgeUpdateQuizBoxScoreDisplays(); +} + +// Score affiché DANS la boîte à quiz elle-même (demande explicite : +// "lorsque la boîte à quiz prend tout l'écran, le score s'affiche dans +// la boîte à quiz") — masqué par CSS hors plein écran (voir +// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz +// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien). +function forgeUpdateQuizBoxScoreDisplays() { + var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]'); + for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore; } // Alimente aussi gameData.scoring (voir static/js/play/offline/ @@ -237,9 +325,19 @@ function forgeShowCurrentConversationLine(dialogueObjectId) { function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) { if (!dialogueId) return; lines = lines || []; + // Bug corrigé : "j'ai créé un quiz à l'affichage de l'écran, rien ne + // s'affiche" — un dialogue fait UNIQUEMENT de questions (quiz RH + // autonome, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de dialogue" + // posée sur l'écran, seulement d'une "❓ Boîte à quiz" — cette fonction + // sortait pourtant en silence dès le départ s'il n'y avait aucune boîte + // de dialogue, empêchant même la première question de s'afficher. Sans + // widget réel pour porter la clé d'état, `dialogueId` sert de repli + // (unique par action "Déclencher ce dialogue") — une ligne "réplique" + // sans boîte de dialogue posée reste ignorée plus loin (voir + // forgeShowCurrentConversationLine), même logique symétrique qu'une + // question sans boîte à quiz posée (voir forgeShowQuizBox). var widget = forgeDialogueBoxWidgetOnCurrentScreen(); - if (!widget) return; - var objectId = widget.getAttribute('data-object-id'); + var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId); forgeSyncDialogueStartedToScorm(); if (!lines.length) { // Rien à jouer : applique quand même markCompleted PUIS thenAction @@ -248,11 +346,11 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName }); return; } - forgeDialogueBoxState[objectId] = { + forgeDialogueBoxState[stateKey] = { dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0, markCompleted: !!markCompleted, thenAction: thenAction, }; - forgeShowCurrentConversationLine(objectId); + forgeShowCurrentConversationLine(stateKey); } // Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté — @@ -308,9 +406,22 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi var widget = forgeQuizBoxWidgetOnCurrentScreen(); if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; } var quizObjectId = widget.getAttribute('data-object-id'); + var previousState = forgeQuizBoxState[quizObjectId]; forgeQuizBoxState[quizObjectId] = { dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false, dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text, + // Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) : + // ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite + // sans se réinitialiser — ce drapeau survit d'une question à l'autre + // via l'état précédent de CE MÊME quiz. + quizTimerStarted: previousState ? previousState.quizTimerStarted : false, + // Score DE CE QUIZ (jamais window.forgeQuizScore/forgeQuizCorrectAnswered, + // cumulés sur TOUTE la partie, voir plus haut) — survit d'une + // question à l'autre comme quizTimerStarted : sert à afficher "X + // bonnes réponses sur Y" à la fin (voir forgeQuizBoxContinue/ + // forgeQuizPrepareResultScreen plus bas). + correctCount: previousState ? previousState.correctCount : 0, + answeredCount: previousState ? previousState.answeredCount : 0, }; var header = widget.querySelector('[data-quiz-role="header"]'); var questionEl = widget.querySelector('[data-quiz-role="question"]'); @@ -321,6 +432,33 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi choicesEl.innerHTML = line.choices.map(function (choice, i) { return ''; }).join(''); + // "Continuer →" TOUJOURS présent dès la question (demande explicite : + // "le bouton continuer est toujours visible mais activé seulement + // quand un choix est fait", comme la maquette manga validée) — + // désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais + // recréé à ce moment-là, juste .disabled=false, voir plus bas). + choicesEl.innerHTML += ''; + } + // "Plein écran" (demande explicite) — reposé à CHAQUE question, + // sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen, + // static/style.css). Échappe d'abord au conteneur de scène transformé + // (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste + // borné à sa taille "caméra" au lieu du vrai écran du joueur. + var isFullscreen = widget.getAttribute('data-fullscreen') === '1'; + if (isFullscreen) forgeEscapeCameraForFullscreen(widget); + widget.classList.toggle('is-fullscreen', isFullscreen); + forgeUpdateQuizBoxScoreDisplays(); + // Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais + // imposé, voir screens/rendering/quiz_box_config.py). + var timerMode = widget.getAttribute('data-timer-mode'); + var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0; + if (timerMode === 'question') { + forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); }); + } else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) { + forgeQuizBoxState[quizObjectId].quizTimerStarted = true; + forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); }); + } else if (timerMode !== 'quiz') { + forgeStopQuizTimer(quizObjectId); } // Rejoue l'animation d'entrée (@keyframes quizPopIn) même si la boîte // était déjà "is-active" juste avant (2 questions à la suite) : sans @@ -329,6 +467,18 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi widget.classList.remove('is-active'); void widget.offsetWidth; widget.classList.add('is-active'); + // Effet d'entrée propre au modèle (voir static/js/play/ + // quiz-box-templates.js) — optionnel, "defaut" n'en a aucun. + var showFx = forgeQuizTemplateEffectsFor(widget).onShow; + if (showFx) showFx(widget); +} + +// Registre d'effets par modèle (voir static/js/play/quiz-box- +// templates.js, chargé AVANT ce fichier) — absent en environnement de +// test qui ne charge pas ce script, d'où la garde `typeof`. +function forgeQuizTemplateEffectsFor(widget) { + var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut'; + return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {}; } // Appelée par le clic sur un choix (les boutons sont injectés EN JEU par @@ -346,6 +496,12 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { state.resolved = true; var widget = forgeQuizBoxWidgetById(quizObjectId); var correct = choiceIndex === state.correctIndex; + // Minuteur "par question" : cette question est résolue (bonne réponse, + // mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui + // appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur + // "pour tout le quiz", qui doit continuer à tourner sans interruption + // d'une question à l'autre. + if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId); if (widget) { var buttons = widget.querySelectorAll('.quizBoxChoiceBtn'); for (var i = 0; i < buttons.length; i++) { @@ -355,9 +511,15 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { } } window.forgeQuizTotalAnswered += 1; + state.answeredCount += 1; + var fx = forgeQuizTemplateEffectsFor(widget); if (correct) { window.forgeQuizCorrectAnswered += 1; + state.correctCount += 1; forgeAddQuizScore(state.rewardAmount); + if (fx.onCorrect) fx.onCorrect(widget); + } else if (fx.onWrong) { + fx.onWrong(widget); } if (typeof forgeXapiNotifyQuestionAnswered === "function") { forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0); @@ -371,26 +533,133 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { // après un délai fixe (700ms/1400ms), jamais assez pour un lecteur // lent ou un utilisateur de lecteur d'écran — l'avancée dépend // maintenant uniquement d'un clic sur "Continuer →", jamais d'un - // minuteur. Le bouton est ajouté APRÈS les choix (jamais en remplaçant - // choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé). + // minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox + // ci-dessus, TOUJOURS visible — demande explicite, comme la maquette + // manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça + // effacerait le surlignage vert/rouge qu'on vient de poser sur les + // choix juste au-dessus). if (widget) { - var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); - if (choicesEl) { - choicesEl.innerHTML += ''; - } + var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn'); + if (continueBtnToEnable) continueBtnToEnable.disabled = false; } } +// Prépare l'écran de résultat de fin de quiz (voir static/js/play/ +// quiz-box-templates.js::onQuizComplete, ex. forgeQuizClassiqueOnQuizComplete) +// et renvoie une fonction à appeler APRÈS forgeAdvanceConversation (qui +// masque sinon le widget, voir forgeHideQuizBoxOnCurrentScreen) — null +// si ce n'est pas la fin du quiz (forceEnd=false : encore des lignes +// après) ou si le modèle actif n'a aucun écran de résultat (seul +// "classique" en a un pour l'instant, "manga"/les modèles "boîte" +// gardent leur comportement d'avant, inchangé : la boîte se masque +// simplement). `forceEnd` (true) : le minuteur du quiz ENTIER vient +// d'expirer (forgeQuizBoxTimeout) — le quiz se termine MÊME s'il +// restait des questions, jamais une simple question de position dans +// les lignes. +function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) { + if (!widget) return null; + var dialogueState = forgeDialogueBoxState[state.dialogueObjectId]; + if (!dialogueState) return null; + var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length); + if (!isEnding) return null; + var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete; + if (!completeFx) return null; + var correctCount = state.correctCount; + var answeredCount = state.answeredCount; + return function () { + widget.classList.add('is-active'); + if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen'); + completeFx(widget, { correctCount: correctCount, totalCount: answeredCount }); + }; +} + +// Bouton de l'écran de résultat (voir forgeQuizPrepareResultScreen +// ci-dessus) — demande explicite : QUITTER carrément (fermer l'onglet/ +// la page du navigateur), jamais recommencer le quiz. Le dialogue est +// déjà épuisé (forgeAdvanceConversation l'a fait avant d'afficher ce +// résultat) — masque quand même le widget D'ABORD (repli si la fermeture +// est refusée, voir ci-dessous), avant de tenter de fermer. "is-showing- +// result" est retirée des DEUX emplacements possibles ("manga" la pose +// sur la racine du widget, "classique" sur .classiqueContent, voir +// static/js/play/quiz-box-templates.js) — l'un des deux est toujours un +// no-op inoffensif. +// +// window.close() : un navigateur ne ferme RÉELLEMENT que les onglets +// ouverts PAR SCRIPT (window.open) ou la page auto-générée d'un +// téléchargement — jamais un onglet atteint par une navigation normale +// (tapée, cliquée, ou un lien) : restriction de sécurité du navigateur +// lui-même, aucun moyen de la contourner en JS. window.close() échoue +// alors silencieusement (aucune erreur, aucun retour) : le repli +// ci-dessus (widget déjà masqué) est donc le résultat visible le plus +// courant en pratique. +function forgeQuizResultQuit(quizObjectId) { + var widget = forgeQuizBoxWidgetById(quizObjectId); + if (widget) { + widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result'); + var content = widget.querySelector('.classiqueContent'); + if (content) content.classList.remove('is-showing-result'); + } + if (typeof window !== 'undefined' && typeof window.close === 'function') window.close(); +} + // Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer -// ci-dessus — masque la boîte à quiz et avance la conversation, jamais +// ci-dessus — masque la boîte à quiz et avance la conversation (SAUF à +// la toute dernière question d'un modèle qui sait afficher un écran de +// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais // automatique (voir le commentaire ci-dessus). function forgeQuizBoxContinue(quizObjectId) { var state = forgeQuizBoxState[quizObjectId]; if (!state) return; var widget = forgeQuizBoxWidgetById(quizObjectId); - if (widget) widget.classList.remove('is-active'); + var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false); + if (widget && !showResultScreen) widget.classList.remove('is-active'); delete forgeQuizBoxState[quizObjectId]; forgeAdvanceConversation(state.dialogueObjectId); + if (showResultScreen) showResultScreen(); +} + +// Minuteur expiré (demande explicite : "pouvoir paramétrer un timer pour +// répondre aux questions", jamais imposé — "aucun" reste le réglage par +// défaut) : mode "question" — cette question est comptée fausse (0 +// point), la bonne réponse s'affiche, puis "Continuer →" fonctionne +// normalement. Mode "quiz" (wholeQuizExpired) — même révélation, mais +// plus AUCUN temps pour la suite : le quiz se termine tout seul après un +// court délai de lecture plutôt que d'attendre un clic. Réutilise +// EXACTEMENT le chemin d'une mauvaise réponse (forgeQuizBoxAnswer) avec +// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse +// sans en marquer une "fausse"), jamais de code dupliqué. +function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) { + var state = forgeQuizBoxState[quizObjectId]; + if (!state || state.resolved) return; + forgeQuizBoxAnswer(quizObjectId, -1); + if (!wholeQuizExpired) return; + var widget = forgeQuizBoxWidgetById(quizObjectId); + // Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER + // est écoulé — désactivé/relibellé pour éviter toute course avec la + // fin automatique programmée juste en dessous. + if (widget) { + var continueBtn = widget.querySelector('.quizBoxContinueBtn'); + if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; } + } + setTimeout(function () { + var s = forgeQuizBoxState[quizObjectId]; + if (!s) return; // déjà fermé entre-temps + // forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non + // des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT + // que forgeAdvanceConversation ne supprime forgeDialogueBoxState. + var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true); + if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen'); + delete forgeQuizBoxState[quizObjectId]; + // Force l'épuisement des lignes restantes — même sortie que la fin + // naturelle d'un dialogue (markCompleted/thenAction inclus, voir + // forgeAdvanceConversation), sans dupliquer cette logique. + var dialogueState = forgeDialogueBoxState[s.dialogueObjectId]; + if (dialogueState) { + dialogueState.index = dialogueState.lines.length; + forgeAdvanceConversation(s.dialogueObjectId); + } + if (showResultScreen) showResultScreen(); + }, 1500); } // static/js/play/__tests__/ (node:test) — même garde que les autres @@ -400,9 +669,14 @@ if (typeof module !== 'undefined' && module.exports) { forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, forgeResetDialogueBoxFooter, - forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, - forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, + forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout, + forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen, + forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore, + forgeAdvanceConversation, forgeShowCurrentConversationLine, forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm, forgeComputeFinalScormStatus, + forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState, + forgeEscapeCameraForFullscreen, + forgeQuizPrepareResultScreen, forgeQuizResultQuit, }; } diff --git a/static/js/play/quiz-box-templates.js b/static/js/play/quiz-box-templates.js new file mode 100644 index 00000000..c2f2e293 --- /dev/null +++ b/static/js/play/quiz-box-templates.js @@ -0,0 +1,165 @@ +// ---------- Effets visuels par modèle de "❓ Boîte à quiz" ---------- +// Registre appelé par static/js/play/dialogue-box-controller.js (JAMAIS +// l'inverse) aux points d'extension du moteur PARTAGÉ (forgeShowQuizBox, +// forgeQuizBoxAnswer) — voir screens/rendering/quiz_box_templates.py +// pour le HTML/CSS de chaque modèle. Ajouter un futur modèle n'oblige +// donc jamais à toucher dialogue-box-controller.js : juste une nouvelle +// entrée ici (optionnelle) + son CSS/HTML. + +// Effet PARTAGÉ entre plusieurs modèles "page de quiz" (Manga, Classique, +// et tout futur modèle qui voudrait la même mécanique) — demande +// explicite : au-dessus de la question, "Question N/M" (nombre de +// QUESTIONS de ce dialogue, pas la position brute dans lines qui mélange +// aussi les répliques) plutôt que le nom de l'objet déclencheur — et une +// barre d'étapes juste en dessous du bandeau. `forgeQuizBoxState`/ +// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js, +// chargé à côté de ce fichier) sont des variables GLOBALES de script +// classique (pas de module), lisibles ici telles quelles. +// `progressSelector` : chaque modèle a son propre conteneur de barre +// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role — +// décoration propre à CHAQUE modèle, pas du contrat partagé). +function forgeQuizProgressOnShow(widget, progressSelector) { + if (!widget) return; + var quizObjectId = widget.getAttribute('data-object-id'); + var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null; + if (!quizState) return; + var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null; + if (!dialogueState) return; + function isQuestion(line) { return line && line.type === 'question'; } + var total = dialogueState.lines.filter(isQuestion).length; + if (total === 0) return; + var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length; + var header = widget.querySelector('[data-quiz-role="header"]'); + if (header) header.textContent = 'Question ' + current + '/' + total; + var progressEl = widget.querySelector(progressSelector); + if (progressEl) { + var bars = []; + for (var i = 1; i <= total; i++) { + bars.push(''); + } + progressEl.innerHTML = bars.join(''); + } +} + +function forgeQuizMangaOnShow(widget) { + // Défensif : si CE widget est réutilisé pour un quiz déclenché à + // nouveau plus tard dans la partie (voir forgeQuizResultQuit, + // dialogue-box-controller.js — le widget lui-même n'est jamais + // reconstruit, juste masqué), une question fraîchement affichée ne + // doit jamais rester sur l'écran de résultat de la fois précédente. + if (widget) widget.classList.remove('is-showing-result'); + forgeQuizProgressOnShow(widget, '[data-manga-role="progress"]'); +} + +function forgeQuizClassiqueOnShow(widget) { + // Même garde-fou que forgeQuizMangaOnShow ci-dessus, ici posée sur + // .classiqueContent (pas la racine du widget, voir + // render_quiz_box_page_classique) plutôt que sur le widget lui-même. + if (widget) { + var content = widget.querySelector('.classiqueContent'); + if (content) content.classList.remove('is-showing-result'); + } + forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]'); +} + +// Écrans de résultat de fin de quiz ("classique" ci-dessous, "manga" +// plus bas) — appelés par forgeQuizPrepareResultScreen +// (dialogue-box-controller.js) UNIQUEMENT à la toute dernière question +// (ou si le minuteur du quiz ENTIER expire). `result.correctCount`/ +// `result.totalCount` : comptés PAR CE QUIZ (voir forgeQuizBoxState dans +// dialogue-box-controller.js), jamais window.forgeQuizScore/ +// forgeQuizCorrectAnswered qui cumulent TOUTE la partie. Le seuil de +// réussite réutilise EXACTEMENT window.FORGE_SUCCESS_THRESHOLD_PERCENT +// (déjà utilisé pour le statut SCORM final, voir +// forgeComputeFinalScormStatus) plutôt qu'un réglage à part. Le bouton +// QUITTE le quiz (jamais ne le recommence, demande explicite) — voir +// forgeQuizResultQuit, dialogue-box-controller.js. +function forgeQuizClassiqueOnQuizComplete(widget, result) { + if (!widget) return; + var content = widget.querySelector('.classiqueContent'); + var zone = widget.querySelector('[data-classique-role="result"]'); + if (!content || !zone) return; + var total = (result && result.totalCount) || 0; + var correct = (result && result.correctCount) || 0; + var pct = total > 0 ? Math.round((correct / total) * 100) : 0; + var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number') + ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70; + var passed = pct >= threshold; + var titleEl = widget.querySelector('.classiqueHeaderTitle'); + var title = titleEl ? titleEl.textContent : 'Quiz'; + var quizObjectId = widget.getAttribute('data-object-id'); + zone.innerHTML = + '
' + pct + '%
' + + '

' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '

' + + '

« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »

' + + '
' + + (passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') + + '
' + + ''; + content.classList.add('is-showing-result'); +} + +// "Manga" — même mécanique que forgeQuizClassiqueOnQuizComplete +// ci-dessus, thème manga propre à ce modèle (voir static/style.css) : +// pas de conteneur "content" à part ici (contrairement à +// .classiqueContent), la classe "is-showing-result" bascule sur la +// RACINE du widget elle-même (voir render_quiz_box_page_manga). La +// barre d'étapes est reposée à 100% "terminé" (tous les segments +// is-done) : forgeQuizProgressOnShow ne le fait pas toute seule, sa +// dernière question reste "is-current" (jamais "is-done", il n'y a +// plus de question suivante vers laquelle avancer) — un quiz FINI doit +// visuellement se lire comme entièrement complété. +function forgeQuizMangaOnQuizComplete(widget, result) { + if (!widget) return; + var zone = widget.querySelector('[data-manga-role="result"]'); + if (!zone) return; + var total = (result && result.totalCount) || 0; + var correct = (result && result.correctCount) || 0; + var pct = total > 0 ? Math.round((correct / total) * 100) : 0; + var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number') + ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70; + var passed = pct >= threshold; + var quizObjectId = widget.getAttribute('data-object-id'); + zone.innerHTML = + '
' + + 'Fin du quiz' + + '

Score final : ' + correct + ' / ' + total + '

' + + '
' + (passed ? '合格' : '不合格') + '
' + + '
' + + '
'; + var progressEl = widget.querySelector('[data-manga-role="progress"]'); + if (progressEl && total > 0) { + var bars = []; + for (var i = 0; i < total; i++) bars.push(''); + progressEl.innerHTML = bars.join(''); + } + widget.classList.add('is-showing-result'); +} + +var forgeQuizTemplateEffects = { + // Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py + // ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/ + // "Ludique" ont été retirés (demande explicite : "supprime tous les + // modele de boite sauf le base"). "manga_dialogue" (clé DIFFÉRENTE du + // modèle "page" "manga" ci-dessous, même registre partagé) reste + // proche de "defaut" — aucun effet JS particulier, juste sa propre + // décoration CSS (voir render_quiz_box_manga_dialogue). + defaut: {}, + manga_dialogue: {}, + // Modèles "page de quiz" (QUIZ_BOX_PAGE_TEMPLATES) — le tampon "正解"/ + // "不正解" du modèle "manga" est posé en CSS pur (::after sur + // .is-correct/.is-wrong, voir static/style.css), seul "Question N/M" + + // la barre d'étapes ont besoin d'un vrai calcul JS (onShow ci-dessus). + manga: { onShow: forgeQuizMangaOnShow, onQuizComplete: forgeQuizMangaOnQuizComplete }, + classique: { onShow: forgeQuizClassiqueOnShow, onQuizComplete: forgeQuizClassiqueOnQuizComplete }, +}; + +// static/js/play/__tests__/ (node:test) — même garde que le reste de +// static/js/play/. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + forgeQuizTemplateEffects, + forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete, + forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete, + }; +} diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index 545268f6..860c5d1e 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -796,6 +796,80 @@ function saveDialogueBoxStyle(objectId) { }); } +// "🖼️ Modèles" (voir templates/scene_edit.html, "❓ Boîte à quiz") — DEUX +// galeries INDÉPENDANTES (demande explicite : "boîte de dialogue" hors +// plein écran, "page de quiz" en plein écran, jamais confondues) — une +// SEULE des deux est rendue à la fois (voir quiz_box_config.fullscreen +// côté serveur), chacune surligne la vignette cliquée sur sa propre +// grille (data-selected-template) puis sauvegarde tout de suite (même +// geste qu'un clic sur une tuile de personnage/décor). +function _selectQuizBoxTemplate(objectId, key, gridId) { + var grid = document.getElementById(gridId); + if (grid) { + grid.setAttribute('data-selected-template', key); + var tiles = grid.querySelectorAll('.quizTemplateTile'); + for (var i = 0; i < tiles.length; i++) { + tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key); + } + } + saveQuizBoxConfig(objectId); +} +function selectQuizBoxDialogTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizDialogTemplateGrid'); } +function selectQuizBoxPageTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizPageTemplateGrid'); } + +// "⚙️ Options du quiz" + "🖼️ Modèles" (demande explicite : quiz RH +// autonome — plein écran/minuteur/DEUX modèles indépendants) — mirror de +// saveDialogueBoxStyle, mais un objet de config à PART (voir +// screens/rendering/quiz_box_config.py), jamais mélangé à "🎨 Style" +// (couleurs/police, inchangé, voir plus haut). Remplace l'aperçu par le +// HTML fraîchement rendu une fois sauvegardé — un modèle change la +// STRUCTURE du balisage (voir screens/rendering/quiz_box_templates.py), +// pas juste une classe CSS : deviner quelle balise ajouter/retirer côté +// client casserait la plupart des modèles. +// +// dialog_template/page_template : SEULE une des deux grilles est rendue +// à la fois (voir templates/scene_edit.html) — celle qui est ABSENTE +// garde sa valeur via #quizBoxTemplateMeta (posé par le serveur), jamais +// écrasée par un défaut. +// +// "Plein écran" (demande explicite : "elle doit occuper tout l'écran", +// "être visible par défaut", "la notion de caméra disparaît" — DANS CET +// ÉDITEUR aussi, pas seulement au jeu) : rechargement complet dès que ce +// réglage CHANGE de valeur — le cadre "🎥 Champ de la caméra" dépend de +// TOUS les quiz de l'écran (pas seulement celui-ci, voir +// routes/scenes/scene_edit_view.py::any_quiz_box_fullscreen), ET la +// galerie de modèles à afficher change entièrement (boîte <-> page) : +// un simple patch DOM ne pourrait recalculer ni l'un ni l'autre ici. +function saveQuizBoxConfig(objectId) { + var meta = document.getElementById('quizBoxTemplateMeta'); + function currentTemplate(gridId, metaAttr) { + var grid = document.getElementById(gridId); + if (!grid) return meta ? meta.getAttribute(metaAttr) : null; + var selectedTile = grid.querySelector('.quizTemplateTile.is-selected'); + return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template')); + } + var fullscreenEl = document.getElementById('quiz-fullscreen'); + var fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1'); + var config = { + fullscreen: fullscreenEl.checked, + timer_mode: document.getElementById('quiz-timer-mode').value, + timer_seconds: Number(document.getElementById('quiz-timer-seconds').value), + dialog_template: currentTemplate('quizDialogTemplateGrid', 'data-dialog-template') || 'defaut', + page_template: currentTemplate('quizPageTemplateGrid', 'data-page-template') || 'manga', + }; + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/quiz-config', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: config }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (fullscreenChanged) { window.location.reload(); return; } + var el = document.getElementById('el-' + objectId); + var widget = el && el.querySelector('.quizBoxWidget'); + if (widget && data.rendered_html) widget.outerHTML = data.rendered_html; + }); +} + function initPersonnageCommandsPanel() { var body = document.getElementById('personnageCommandsBody'); if (!body) return; diff --git a/static/quiz-templates/classique.png b/static/quiz-templates/classique.png new file mode 100644 index 00000000..59f096cc Binary files /dev/null and b/static/quiz-templates/classique.png differ diff --git a/static/quiz-templates/defaut.png b/static/quiz-templates/defaut.png new file mode 100644 index 00000000..209f989e Binary files /dev/null and b/static/quiz-templates/defaut.png differ diff --git a/static/quiz-templates/manga.png b/static/quiz-templates/manga.png new file mode 100644 index 00000000..faec8a3a Binary files /dev/null and b/static/quiz-templates/manga.png differ diff --git a/static/quiz-templates/manga_dialogue.png b/static/quiz-templates/manga_dialogue.png new file mode 100644 index 00000000..d05e2c72 Binary files /dev/null and b/static/quiz-templates/manga_dialogue.png differ diff --git a/static/style.css b/static/style.css index 55736fcf..e5e89f37 100644 --- a/static/style.css +++ b/static/style.css @@ -609,6 +609,24 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .iconGallery.personnageGallery{ grid-template-columns:repeat(2, 1fr); gap:10px; max-height:420px; } .personnageThumb{ width:100%; height:100%; object-fit:contain; image-rendering:pixelated; } +/* "🖼️ Modèles" de la "❓ Boîte à quiz" (dialogue ET page, voir + templates/scene_edit.html, screens/rendering/quiz_box_templates.py) — + chaque modèle est un thème complet et autonome (structure/police/ + couleurs propres) : plutôt que reproduire sa structure/CSS en + miniature (fragile, vite désynchronisé d'un vrai thème complet), la + vignette est une vraie capture d'écran du modèle (static/ + quiz-templates/.png, une par modèle). */ +.quizTemplateGrid{ display:grid; grid-template-columns:repeat(2, 1fr); gap:10px; margin-top:6px; } +.quizTemplateTile{ + display:flex; flex-direction:column; gap:6px; padding:6px; background:var(--panel2); + border:2px solid var(--border); border-radius:8px; cursor:pointer; +} +.quizTemplateTile:hover{ border-color:var(--accent); } +.quizTemplateTile.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent) inset; } +.quizTemplateName{ font-size:11.5px; color:var(--text-dim); text-align:center; } +.quizTemplatePreviewImg{ display:block; width:100%; height:170px; overflow:hidden; border-radius:4px; } +.quizTemplatePreviewImg img{ width:100%; height:100%; object-fit:cover; object-position:top; display:block; } + /* "Mes assets" — sous-sections Images/Sons/Vidéos (demande explicite : "voir, utiliser ou supprimer des son, image et vidéo") : une tuile image garde son bouton de pose (clic/glisser-déposer, voir @@ -795,6 +813,27 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d } .playScreen .quizBoxWidget{ display:none; } .playScreen .quizBoxWidget.is-active{ display:flex; animation:quizPopIn .3s ease; } +/* Un quiz PLEIN ÉCRAN est déplacé en enfant direct de au premier + affichage (voir forgeEscapeCameraForFullscreen, dialogue-box-controller.js + — ".playScreen.playScene" est transformé/centré, ce qui casserait + position:fixed s'il y restait) : plus dans ".playScreen" ensuite, donc + plus couvert par les deux règles ci-dessus — cette classe reproduit le + même masquage par défaut/affichage sur ".is-active", posée au moment du + déplacement. */ +.quizBoxWidget--detachedForFullscreen{ display:none; } +.quizBoxWidget--detachedForFullscreen.is-active{ display:flex; animation:quizPopIn .3s ease; } +/* Bandeau partagé header + score/minuteur (demande explicite : "le score + doit être à l'intérieur de la boîte à quiz, pas à l'extérieur") — le + score (topbar) vit dans un élément FRÈRE du header (voir le + commentaire d'en-tête de screens/rendering/quiz_box_templates.py, JS + écrase les enfants du header via .textContent), posé ici CÔTE À CÔTE + avec lui dans le MÊME bandeau plutôt qu'empilé au-dessus dans une bande + à part — un seul fond commun (posé sur CE conteneur, jamais sur le + header seul, voir _quiz_header_row_html), pas deux bandes de couleurs + différentes. */ +.quizBoxHeaderRow{ display:flex; align-items:stretch; } +.quizBoxHeaderRow .quizBoxHeader{ flex:1 1 auto; min-width:0; } +.quizBoxHeaderRow .quizBoxTopBar{ width:auto; flex:0 0 auto; } .quizBoxHeader{ padding:8px 12px; font-weight:700; } .quizBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px; } .quizBoxQuestionText{ margin:0; font-weight:600; } @@ -818,12 +857,530 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; } /* Bouton "Continuer →" (voir forgeQuizBoxAnswer/forgeQuizBoxContinue, RGAA/WCAG 2.2.1) — remplace l'ancienne avancée automatique après un - délai fixe : l'apprenant décide lui-même quand passer à la suite. */ + délai fixe : l'apprenant décide lui-même quand passer à la suite. + TOUJOURS présent dès la question (posé désactivé par forgeShowQuizBox, + activé par forgeQuizBoxAnswer une fois répondu — demande explicite, + comme la maquette manga validée) : ":disabled" générique ici, commun + à TOUS les modèles, quelle que soit leur propre couleur d'action. */ .quizBoxContinueBtn{ margin-top:4px; background:#2f6fed; color:#fff; border:1px solid #2f6fed; border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; } -.quizBoxContinueBtn:hover{ background:#255bc4; } +.quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; } +.quizBoxContinueBtn:disabled{ opacity:.45; cursor:not-allowed; } + +/* "Plein écran" (demande explicite : quiz RH autonome, voir + screens/rendering/quiz_box_config.py) — la boîte à quiz elle-même + devient la surface plein écran (pas besoin d'un overlay séparé comme + static/js/play/video-overlay.js : son propre fond couvre déjà tout). + z-index 1001 : au-dessus de .forgeVideoOverlay (1000) par précaution, + au cas où une vidéo serait encore visible. Commun à tous les modèles. */ +.quizBoxWidget.is-fullscreen{ + position:fixed !important; inset:0 !important; left:0 !important; top:0 !important; + width:100vw !important; height:100vh !important; + z-index:1001 !important; display:flex !important; flex-direction:column !important; +} +/* Demande explicite : "pas de bordure visible, pas de bord arrondi" en + plein écran — une bordure/un coin arrondi/une ombre portée n'ont plus + de sens une fois que la boîte occupe TOUT l'écran (rien derrière/autour + d'elle à en distinguer), quel que soit le modèle. */ +.quizBoxWidget.is-fullscreen{ + border-radius:0 !important; box-shadow:none !important; border:none !important; +} +/* Bug corrigé : "rien ne s'affiche" — align-items:center/justify-content:center + posés ici auparavant retiraient l'étirement (stretch) par défaut d'un + conteneur flex en colonne, réduisant .quizBoxHeader/.quizBoxBody à la + largeur de LEUR PROPRE contenu au lieu de toute la largeur — dans + certains navigateurs, combiné à .quizBoxBody (lui-même un conteneur + flex en colonne, voir plus haut), ce rétrécissement en cascade + ramenait la zone de texte à une largeur quasi nulle, rendant le + header/la question/les choix invisibles bien qu'ils soient bien posés + dans le DOM avec leur texte. Un simple empilement plein largeur/pleine + hauteur (comportement déjà éprouvé hors plein écran) suffit : pas + besoin de centrer quoi que ce soit, la boîte occupe déjà tout l'écran.*/ +.quizBoxWidget.is-fullscreen .quizBoxBody{ min-height:0; } +/* Éditeur (voir templates/scene_edit.html) : occuper le VIEWPORT du + navigateur (comme au jeu) casserait l'édition — un fond opaque + couvrirait aussi le panneau de propriétés. "Plein écran" occupe ici + tout le CANEVAS (le "monde" de la scène) à la place, posé sur + .canvasElement (voir plus bas, jamais sur .quizBoxWidget lui-même — + déjà forcé à 100%/100% de .canvasElement par .canvasElementInner > + .quizBoxWidget, voir plus haut dans ce fichier) : suffisant pour que + le créateur voie tout de suite l'effet, sans avoir à lancer le jeu. + La "notion de caméra disparaît" (demande explicite) : le cadre "🎥 + Champ de la caméra" est masqué côté serveur dès qu'un quiz plein écran + existe sur l'écran (voir routes/scenes/scene_edit_view.py), puisqu'il + n'a plus aucun sens une fois que ce widget couvre tout, caméra ou pas.*/ +.canvasElement.isQuizFullscreenPreview{ + left:0 !important; top:0 !important; width:100% !important; height:100% !important; z-index:9000 !important; +} +/* Barre score/minuteur — visible SEULEMENT en plein écran (une boîte à + quiz "normale", posée dans le décor, n'a pas besoin d'afficher un + score/minuteur en permanence) : masquée par défaut, quel que soit le + modèle, pour ne rien changer à l'apparence d'un quiz existant. */ +.quizBoxTopBar{ + display:none; width:100%; padding:6px 10px; font-weight:700; font-size:13px; + align-items:center; justify-content:space-between; gap:10px; box-sizing:border-box; +} +.quizBoxWidget.is-fullscreen .quizBoxTopBar{ display:flex; } +.quizBoxTimerDisplay:empty{ display:none; } +.quizBoxTimerDisplay{ + font-variant-numeric:tabular-nums; padding:2px 10px; border-radius:999px; + background:rgba(255,255,255,.15); +} +.quizBoxTimerDisplay.is-urgent{ background:#e0524d; color:#fff; } + +/* ---------- Modèle "Manga" (boîte de dialogue) — voir + screens/rendering/quiz_box_templates.py::render_quiz_box_manga_dialogue, + validé par le créateur via une maquette interactive. Pendant, en + petite carte flottante (JAMAIS plein écran), du modèle "page de quiz" + "Manga" plus bas — réutilise la structure PARTAGÉE des modèles + "boîte" (quizBoxHeaderRow/quizBoxHeader/quizBoxTopBar/quizBoxBody/ + quizBoxQuestionText/quizBoxChoices/quizBoxChoiceBtn/ + quizBoxContinueBtn), seule la décoration change. .quizMangaDialogueFrame + porte la forme/le fond (jamais le conteneur racine, qui doit rester + positionnable/redimensionnable normalement) — même principe que les + anciens modèles "Verre dépoli"/"Néon". Police : Montserrat/Poppins + (déjà auto-hébergées, voir static/vendor/fonts/fonts.css) — jamais + Bangers/Noto Sans JP de la maquette (Google Fonts), même contrainte + SCORM hors-ligne déjà rencontrée pour le modèle "page" "Manga". */ +.quizBoxWidget--mangaDialogue{ + position:relative; background:#151316; border-radius:0 !important; + box-shadow:6px 6px 0 rgba(0,0,0,.4) !important; border:3px solid #000 !important; +} +.quizBoxWidget--mangaDialogue .quizMangaDialogueFrame{ + display:flex; flex-direction:column; flex:1; width:100%; height:100%; overflow:hidden; + font-family:"Poppins", system-ui, sans-serif; color:#f3ead6; background:#151316; +} +.quizBoxWidget--mangaDialogue .quizBoxHeaderRow{ + /* Identité de marque FIXE (rouge), jamais la couleur configurable du + panneau "🎨 Style" (header_bg, posée en style INLINE par + _quiz_header_row_html — !important nécessaire pour la dominer) — + même principe déjà appliqué par l'ancien modèle "Ludique" avant sa + suppression, conforme à la maquette validée. */ + background-color:#d81e3e !important; + clip-path:polygon(0 0, 100% 0, 97% 100%, 0 100%); padding:10px 20px 12px 18px; position:relative; + background-image:radial-gradient(rgba(255,255,255,.16) 1px, transparent 1.4px); background-size:7px 7px; + /* Un `border` classique ne suit pas un clip-path — le liseré noir le + long du bord dentelé (demande explicite : "des bords stylisés" sur + la maquette) vient d'un drop-shadow qui, lui, épouse la silhouette + RÉELLEMENT visible après découpe (contrairement à box-shadow, qui + resterait un simple rectangle). */ + filter:drop-shadow(2px 0 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(0 2px 0 #000); +} +.quizBoxWidget--mangaDialogue .quizBoxHeader{ + font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; letter-spacing:.02em; + text-transform:uppercase; font-size:15px; color:#fff2ec; text-shadow:2px 2px 0 #8f0f24; +} +/* "Quête : " devant le nom (JS n'écrit QUE le nom brut de l'objet + déclencheur dans [data-quiz-role="header"], voir forgeShowQuizBox, + dialogue-box-controller.js — contrat partagé par tous les modèles + "boîte") — un ::before CSS pur, jamais touché par ce .textContent, + pour reproduire le "Quête : X" de la maquette validée. */ +.quizBoxWidget--mangaDialogue .quizBoxHeader::before{ content:"Quête : "; } +.quizBoxWidget--mangaDialogue .quizBoxTopBar{ margin-top:6px; } +.quizBoxWidget--mangaDialogue .quizBoxScoreDisplay, +.quizBoxWidget--mangaDialogue .quizBoxTimerDisplay{ background:rgba(0,0,0,.28); color:#fff; border-radius:2px; } +/* Corps CRÈME (papier), comme la maquette — jamais la couleur + configurable du panneau "🎨 Style" (body_bg, posée en style INLINE) : + !important nécessaire pour la dominer, même raison que le bandeau + ci-dessus. padding-bottom réservé pour ne jamais passer SOUS le + bandeau de pied de page toujours visible (voir + .quizMangaDialogueFooterBand plus bas). + Coin dentelé du bas (demande explicite) SANS clip-path ni décor à + part : un `background` (dégradé quasi-plat, juste 3° d'inclinaison, + qui BASCULE net de crème à encre) — deux essais précédents (clip-path + sur .quizBoxBody, puis un décor z-index:-1 séparé) ont chacun rogné + ou fait disparaître le bouton "Continuer →" du moteur partagé + (DESCENDANT de cet élément, positionné en absolute par rapport au + widget) : un clip-path/overflow sur un ANCÊTRE le rogne TOUJOURS, + quel que soit son propre positionnement ; un contexte d'empilement + (position+z-index) posé ici l'emprisonne aussi, l'empêchant de + ressortir au-dessus du bandeau bas. Un simple `background` ne clippe + ni n'empile RIEN — le bouton reste entièrement libre. */ +.quizBoxWidget--mangaDialogue .quizBoxBody{ + /* Fond CRÈME plat (demande explicite : coins bas arrondis) — le + dégradé diagonal précédent aurait rendu l'arrondi invisible : ses + 18% du bas étaient DÉJÀ encre, un coin arrondi encre sur un fond + encre (le bandeau du bas) ne se voit tout simplement pas, aucun + contraste. Coin plat + arrondi net = le seul réglage qui reste + visible. */ + background:#f3ead6 !important; + color:#151316; padding:16px 18px 78px; overflow:visible; + /* Un `border-radius` seul, SANS toucher à overflow (reste visible), + ne clippe RIEN : contrairement à un clip-path ou un + overflow:hidden, il ne rogne le bouton "Continuer →" (descendant + qui doit rester libre de déborder, voir le commentaire au-dessus) + que si overflow est masqué — jamais le cas ici. */ + border-bottom-left-radius:16px; border-bottom-right-radius:16px; +} +.quizBoxWidget--mangaDialogue .quizBoxChoices{ overflow:visible; } +.quizBoxWidget--mangaDialogue .quizBoxQuestionText{ font-family:"Montserrat", sans-serif; font-weight:700; color:#151316; } +.quizBoxWidget--mangaDialogue .quizBoxChoices{ gap:8px; counter-reset:mangaDialogueChoice; } +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn{ + counter-increment:mangaDialogueChoice; position:relative; background:#fff; color:#151316; + border:2px solid #000; border-radius:0; padding:8px 10px 8px 38px; font-family:inherit; +} +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn::before{ + content:counter(mangaDialogueChoice, upper-alpha); position:absolute; left:8px; top:50%; transform:translateY(-50%); + width:22px; height:22px; border-radius:50%; background:#151316; color:#f3ead6; + display:flex; align-items:center; justify-content:center; + font-family:"Montserrat", sans-serif; font-weight:700; font-size:11px; +} +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn:hover:not(:disabled){ background:#fff7e6; } +/* border-color:#000 !important nécessaire : le contrat partagé + (static/style.css, règle générique .quizBoxChoiceBtn.is-correct/ + is-wrong) pose déjà un liseré vert/rouge "!important" — sans ce + repli, il resterait visible par-dessus le fond gold/rouge-foncé + propre à ce modèle (demande explicite : "il n'y a pas le liseré + vert" sur la maquette — bordure NOIRE partout, jamais colorée). */ +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; border-color:#000 !important; color:#151316; } +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#000 !important; color:#fff; text-decoration:line-through; } +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after, +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{ + position:absolute; right:10px; top:50%; transform:translateY(-50%); + font-family:"Montserrat", sans-serif; font-weight:700; font-size:10px; + padding:2px 6px; border:1.5px solid currentColor; border-radius:3px; +} +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after{ content:"正解"; } +.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; } +/* Bandeau de pied de page TOUJOURS visible (voir + quizMangaDialogueFooterBand, render_quiz_box_manga_dialogue) — comme + la maquette validée : une bande sombre distincte, jamais la même + couleur que le corps crème. */ +.quizBoxWidget--mangaDialogue .quizMangaDialogueFooterBand{ + /* 44px auparavant : trop bas pour le bouton (padding 8px×2 + texte + + bordure 2px×2 ≈ 40px) et son décalage bottom:8px — il débordait par + le HAUT, à moitié caché sous le panneau crème (demande explicite : + "ou dois etre le bouton"). 72px laisse une vraie marge de respir, + comme la maquette. */ + position:absolute; left:0; right:0; bottom:0; height:72px; + background:#151316; pointer-events:none; + /* Coin haut-gauche dentelé (demande explicite : "les bords du bas + doivent être retravaillés") — écho du bandeau titre découpé en + haut, plutôt qu'un bas de carte parfaitement rectangulaire. Même + technique drop-shadow que le bandeau titre (un `border` classique + ne suit pas un clip-path) pour un liseré qui épouse la VRAIE + silhouette découpée. */ + clip-path:polygon(4% 0, 100% 0, 100% 100%, 0 100%, 0 4%); + filter:drop-shadow(0 -2px 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(2px 0 0 #000) drop-shadow(0 2px 0 #000); +} +/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant + de .quizBoxChoices (jamais dans quizMangaDialogueFooterBand, que le + moteur ne remplit jamais) — posé PAR-DESSUS ce bandeau en + position:absolute (.quizBoxWidget--mangaDialogue est position:relative, + voir plus haut), petit et skewé aligné à droite comme la maquette + (sans ce repli, il s'étire à la largeur des choix — .quizBoxChoices + est un flex-column, stretch par défaut). */ +.quizBoxWidget--mangaDialogue .quizBoxContinueBtn{ + position:absolute; right:20px; bottom:20px; z-index:1; margin:0; + background:#d81e3e; border:2px solid #000; color:#fff2ec; border-radius:0; + padding:8px 20px; transform:skewX(-6deg); font-family:"Montserrat", sans-serif; font-weight:700; + box-shadow:3px 3px 0 #000; +} +.quizBoxWidget--mangaDialogue .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; } + +/* ---------- Modèle "Manga" — premier "PAGE DE QUIZ" (voir + screens/rendering/quiz_box_templates.py::render_quiz_box_page_manga), + validé par le créateur via une maquette interactive avant intégration. + Thème complet et autonome : structure/police/formes/couleurs propres, + AUCUNE règle partagée avec les modèles "boîte de dialogue" ci-dessus + (à part .quizBoxChoiceBtn/.quizBoxContinueBtn, injectés par le moteur + JS partagé, voir static/js/play/dialogue-box-controller.js). + Polices : Montserrat/Poppins (déjà auto-hébergées, voir + static/vendor/fonts/fonts.css) plutôt que Bangers/Noto Sans JP de la + maquette d'origine — un paquet SCORM exporté tourne SANS accès + internet dans le LMS du client, une police chargée depuis Google + Fonts y resterait invisible (repli silencieux sur la police système). */ +.quizBoxWidget--manga{ + background:#151316; color:#f3ead6; + font-family:"Poppins", "Montserrat", system-ui, sans-serif; + display:flex; flex-direction:column; align-items:center; gap:18px; padding:20px; + overflow-y:auto; + background-image: + radial-gradient(circle at 14% 8%, rgba(216,30,62,.16), transparent 42%), + repeating-linear-gradient(115deg, rgba(255,255,255,.03) 0 2px, transparent 2px 26px); +} +/* Contenu centré en colonne confortable (demande explicite, comme la + maquette validée) plutôt qu'étiré sur toute la largeur de l'écran. */ +.quizBoxWidget--manga .mangaTopbar, +.quizBoxWidget--manga .mangaProgress, +.quizBoxWidget--manga .mangaPanelQuestion, +.quizBoxWidget--manga .mangaChoices{ width:100%; max-width:860px; flex-shrink:0; } +/* Barre d'étapes (voir forgeQuizMangaOnShow, static/js/play/ + quiz-box-templates.js) — un par question de CE dialogue, remplie + EN JEU (le nombre total de questions n'est connu qu'à l'exécution). */ +.quizBoxWidget--manga .mangaProgress{ display:flex; gap:6px; } +.quizBoxWidget--manga .mangaProgress i{ flex:1; height:6px; background:rgba(243,234,214,.18); border-radius:1px; font-style:normal; } +.quizBoxWidget--manga .mangaProgress i.is-done{ background:#d81e3e; } +.quizBoxWidget--manga .mangaProgress i.is-current{ background:#e7b923; } +.quizBoxWidget--manga .mangaTopbar{ display:flex; align-items:stretch; gap:12px; flex-wrap:wrap; } +.quizBoxWidget--manga .mangaPlate{ + background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:4px 4px 0 #1d1a1f; + padding:8px 14px; display:flex; flex-direction:column; justify-content:center; gap:2px; min-width:118px; +} +.quizBoxWidget--manga .mangaPlateLabel{ font-size:10px; font-weight:600; letter-spacing:.14em; text-transform:uppercase; opacity:.65; } +.quizBoxWidget--manga .mangaPlateValue{ + font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px; line-height:1; + letter-spacing:.02em; font-variant-numeric:tabular-nums; +} +.quizBoxWidget--manga .mangaPlate--score .mangaPlateValue{ color:#8f0f24; } +.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent){ + background:#d81e3e; color:#f3ead6; animation:mangaTimerPulse .5s ease infinite; +} +.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent) .mangaPlateValue{ color:#f3ead6; } +@keyframes mangaTimerPulse{ 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.05); } } +.quizBoxWidget--manga .mangaTitlemark{ + flex:1 1 200px; min-width:180px; background:#1d1a1f; border:3px solid #151316; outline:3px solid #f3ead6; outline-offset:-8px; + display:flex; align-items:center; justify-content:center; padding:10px; + background-image:radial-gradient(rgba(243,234,214,.14) 1.3px, transparent 1.5px); background-size:8px 8px; +} +.quizBoxWidget--manga .mangaTitlemark span{ + font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase; + font-size:clamp(16px,3vw,24px); letter-spacing:.02em; color:#f3ead6; + -webkit-text-stroke:1px #f3ead6; text-shadow:2px 2px 0 #d81e3e; transform:skewX(-8deg); display:inline-block; +} +.quizBoxWidget--manga .mangaPanelQuestion{ + background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f; + clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%); + padding:22px 26px 30px; flex:0 0 auto; +} +.quizBoxWidget--manga .mangaKicker{ + display:inline-block; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; + background:#d81e3e; color:#f3ead6; padding:3px 10px; margin-bottom:12px; transform:rotate(-2deg); +} +.quizBoxWidget--manga .mangaQuestionText{ + margin:0; font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; line-height:1.2; + font-size:clamp(19px,2.6vw,28px); letter-spacing:-.01em; text-transform:uppercase; text-wrap:balance; +} +.quizBoxWidget--manga .mangaChoices{ + display:grid; grid-template-columns:1fr 1fr; gap:14px; flex:1 1 auto; align-content:start; +} +@media (max-width:620px){ .quizBoxWidget--manga .mangaChoices{ grid-template-columns:1fr; } } +.quizBoxWidget--manga .quizBoxChoiceBtn{ + counter-increment:mangaChoice; position:relative; display:flex; align-items:center; gap:14px; + background:#1d1a1f; color:#f3ead6; border:3px solid #151316; outline:2px solid rgba(243,234,214,.5); outline-offset:-7px; + padding:12px 16px 12px 12px; font-family:inherit; font-size:15px; font-weight:500; text-align:left; + border-radius:0; box-shadow:none; +} +.quizBoxWidget--manga .mangaChoices{ counter-reset:mangaChoice; } +.quizBoxWidget--manga .quizBoxChoiceBtn::before{ + content:counter(mangaChoice, upper-alpha); flex:0 0 auto; width:34px; height:34px; border-radius:50%; + background:#f3ead6; color:#151316; border:3px solid #151316; display:flex; align-items:center; justify-content:center; + font-family:"Montserrat", sans-serif; font-weight:700; font-size:15px; +} +.quizBoxWidget--manga .quizBoxChoiceBtn:hover:not(:disabled){ background:#26222a; transform:translate(-2px,-2px); } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; color:#151316; border-color:#151316 !important; } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::before{ background:#151316; color:#e7b923; border-color:#151316; } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#151316 !important; } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after, +.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{ + position:absolute; top:50%; right:14px; transform:translateY(-50%) rotate(-8deg); + font-family:"Montserrat", sans-serif; font-weight:700; font-size:13px; letter-spacing:.06em; + padding:2px 8px; border:2px solid currentColor; border-radius:4px; +} +.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after{ content:"正解"; color:#151316; } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; color:#f3ead6; } +/* Une réponse fausse est barrée (demande explicite, comme la maquette) — + jamais le badge lettré (::before), qui reste un simple repère A/B/C/D. */ +.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; text-decoration-thickness:2px; } +.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::before{ text-decoration:none; } +/* Posé DANS la grille des choix (voir forgeQuizBoxAnswer, moteur partagé) + mais sur sa propre ligne, aligné à droite en petit pavé (comme la + maquette) plutôt qu'étiré à la largeur d'une case de la grille — et + relabellisé en "Suite ▶" (texte "Continuer →" du moteur partagé + conservé pour les lecteurs d'écran, juste masqué visuellement). */ +.quizBoxWidget--manga .quizBoxContinueBtn{ + grid-column:1 / -1; justify-self:end; width:max-content; + font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; font-size:0; + background:#d81e3e; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #1d1a1f; + padding:9px 22px; transform:skewX(-6deg); border-radius:0; +} +.quizBoxWidget--manga .quizBoxContinueBtn::after{ content:"Suite ▶"; font-size:14px; } +.quizBoxWidget--manga .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; } + +/* Écran de résultat de fin de quiz (voir forgeQuizMangaOnQuizComplete, + static/js/play/quiz-box-templates.js) — demande explicite : "il faut + aussi adapter le modele manga pour avoir sa page de résultat", même + mécanique que "classique" mais dans le thème manga propre à ce + modèle. Pas de conteneur "content" à part ici : c'est la RACINE + (.quizBoxWidget--manga) qui bascule en "is-showing-result" — le + bandeau topbar/score et la barre d'étapes (déjà remplie à 100%, + voir le JS) restent visibles, seuls le panneau question et les choix + disparaissent. */ +.quizBoxWidget--manga.is-showing-result > .mangaPanelQuestion, +.quizBoxWidget--manga.is-showing-result > .mangaChoices{ display:none; } +.mangaResultZone{ + display:none; flex-direction:column; align-items:center; justify-content:center; + width:100%; max-width:860px; text-align:center; flex:1 1 auto; +} +.quizBoxWidget--manga.is-showing-result .mangaResultZone{ display:flex; } +.mangaResultCard{ + background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f; + clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%); + padding:32px 30px 40px; width:100%; +} +.mangaResultTitle{ + font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase; + font-size:clamp(26px,4vw,38px); text-shadow:3px 3px 0 #d81e3e; transform:skewX(-4deg); + display:inline-block; margin:0 0 10px; +} +.mangaResultScore{ font-family:"Montserrat", sans-serif; font-weight:700; font-size:19px; color:#8f0f24; margin:0 0 18px; } +.mangaResultBadge{ + display:inline-block; font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px; + letter-spacing:.05em; padding:6px 22px; border:3px solid #151316; margin-bottom:24px; transform:rotate(-2deg); +} +.mangaResultBadge.pass{ background:#e7b923; } +.mangaResultBadge.fail{ background:#8f0f24; color:#f3ead6; } +.mangaQuitBtn{ + font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; cursor:pointer; + background:#151316; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #d81e3e; + padding:10px 26px; transform:skewX(-6deg); border-radius:0; +} +.mangaQuitBtn:hover{ background:#26222a; } + +/* "Classique" (modèle "page de quiz" DE BASE) — maquette interactive + validée par le créateur (formation sécurité incendie comme exemple) : + couleurs VOLONTAIREMENT identiques à celles par défaut du modèle + "boîte de dialogue" de base (#2f6fed/#1a1d24/#11141b, voir + screens/rendering/dialogue_box_style.py::DEFAULT_DIALOGUE_BOX_STYLE) + plutôt qu'une palette propre — contrairement à "manga". */ +/* Police : "Poppins" (déjà auto-hébergée pour "manga", voir + static/vendor/fonts/fonts.css) — jamais "IBM Plex Sans/Mono" de la + maquette (Google Fonts), qui casserait l'export SCORM hors-ligne, même + contrainte déjà rencontrée pour "manga". */ +.quizBoxWidget--classique{ + position:relative; display:flex; flex-direction:column; font-family:"Poppins", system-ui, sans-serif; +} +.classiqueHeaderBar{ + display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap; + padding:14px 20px; color:#fff; +} +.classiqueHeaderTitleWrap{ display:flex; flex-direction:column; gap:1px; min-width:0; } +.classiqueHeaderEyebrow{ font-size:10px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:rgba(255,255,255,.7); } +.classiqueHeaderTitle{ font-size:16px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.classiqueHeaderChips{ display:flex; gap:8px; flex-shrink:0; } +.classiqueChip{ + display:flex; flex-direction:column; align-items:center; justify-content:center; + background:rgba(255,255,255,.14); border-radius:8px; padding:4px 12px; min-width:56px; +} +.classiqueChipLabel{ font-size:9px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:rgba(255,255,255,.65); } +.classiqueChipValue{ + font-family:"Roboto", monospace; font-size:13.5px; font-weight:600; color:#fff; + font-variant-numeric:tabular-nums; +} +/* Bug identifié (demande répétée : "le mot aucun a une background + color... un bord arrondi") : [data-quiz-role="timer"] porte AUSSI la + classe partagée .quizBoxTimerDisplay (voir plus haut dans ce fichier — + nécessaire pour le mécanisme commun ":empty"/".is-urgent") qui pose + SA PROPRE pastille (fond + border-radius:999px) — DEUX pastilles + s'empilaient donc : celle, correcte et voulue, du conteneur + ".classiqueChip" (même fond que "Score"), et celle, parasite, de + ".quizBoxTimerDisplay" collée UNIQUEMENT autour du mot "Aucun"/du + temps restant. On neutralise ICI cette seconde pastille (le + conteneur ".classiqueChip" reste seul responsable du fond, TOUJOURS + visible, identique que pour "Score", avec ou sans minuteur réglé). */ +.classiqueChipValue.quizBoxTimerDisplay{ background:none; padding:0; border-radius:0; } +.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent){ background:rgba(224,82,77,.35); } +.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipValue, +.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipLabel{ color:#ffb3b0; } + +.classiqueBody{ flex:1; display:flex; flex-direction:column; align-items:center; padding:24px 20px 96px; overflow-y:auto; } +.classiqueContent{ width:100%; max-width:640px; display:flex; flex-direction:column; flex:1; } + +/* Barre d'étapes — voir forgeQuizClassiqueOnShow (static/js/play/ + quiz-box-templates.js), même mécanique que "manga". */ +.classiqueProgress{ display:flex; gap:6px; margin-bottom:24px; } +.classiqueProgress i{ flex:1; height:4px; border-radius:2px; background:rgba(255,255,255,.14); font-style:normal; } +.classiqueProgress i.is-done{ background:#2ecc71; } +.classiqueProgress i.is-current{ background:#fff; } + +.classiqueKicker{ + display:inline-block; align-self:flex-start; background:rgba(255,255,255,.14); border-radius:999px; + padding:4px 12px; font-size:11.5px; font-weight:600; color:rgba(255,255,255,.75); margin-bottom:16px; +} +.classiqueQuestionText{ margin:0 0 22px; font-weight:600; line-height:1.35; font-size:clamp(18px,2.6vw,22px); text-wrap:balance; } + +.classiqueChoices{ display:flex; flex-direction:column; gap:10px; } +.classiqueChoices .quizBoxChoiceBtn{ + display:flex; align-items:center; gap:14px; border-radius:8px; padding:13px 16px; +} +.classiqueChoices .quizBoxChoiceBtn.is-correct{ color:#08210f; } +.classiqueChoices .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; opacity:.85; } +/* Bandeau de pied de page TOUJOURS visible (voir classiqueFooterBand, + render_quiz_box_page_classique) — couleur configurable "🎨 Style" + (footer_bg, voir --classique-footer-bg posée sur la racine du widget) + plutôt qu'une couleur figée en dur, demande explicite : "il manque le + footer foncé avec le bouton" (comme la maquette validée). */ +.classiqueFooterBand{ + position:absolute; left:0; right:0; bottom:0; height:66px; + background:var(--classique-footer-bg, #11141b); border-top:1px solid rgba(255,255,255,.08); + pointer-events:none; +} +/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant + de .classiqueChoices (jamais dans classiqueFooterBand, que le moteur + ne remplit jamais) — posé PAR-DESSUS ce bandeau en position:absolute + (.quizBoxWidget--classique est position:relative, voir plus haut). + Demande explicite : aligné à DROITE — dans la même colonne centrée de + 640px que le corps (.classiqueContent) : `max(20px, calc(50vw - 320px))` + recalcule le bord DROIT de CETTE colonne (640px = 2×320px, 20px = le + padding latéral de .classiqueBody) plutôt que le bord droit de TOUT le + widget. 50vw est fiable ici : ce modèle n'existe qu'en plein écran, où + `.is-fullscreen` force déjà `width:100vw`. */ +.classiqueChoices .quizBoxContinueBtn{ + position:absolute; right:max(20px, calc(50vw - 320px)); bottom:14px; z-index:1; margin:0; + background:#2f6fed; border:1px solid #2f6fed; border-radius:7px; padding:10px 22px; font-weight:600; +} +.classiqueChoices .quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; } + +/* Encart d'explication après réponse — RÉSERVÉ pour une fonctionnalité + FUTURE non encore implémentée (voir le commentaire dans + render_quiz_box_page_classique, screens/rendering/quiz_box_templates.py) + : reste toujours vide aujourd'hui, donc toujours caché — seul :not(:empty) + le fera apparaître le jour où un vrai contenu y sera écrit. */ +.classiqueExplain:not(:empty){ + margin-top:14px; padding:14px 16px; border-radius:8px; background:rgba(255,255,255,.06); + border:1px solid rgba(255,255,255,.14); font-size:13.5px; color:rgba(255,255,255,.75); line-height:1.5; +} + +/* Écran de résultat de fin de quiz (voir forgeQuizClassiqueOnQuizComplete/ + forgeQuizResultRestart, static/js/play/quiz-box-templates.js et + dialogue-box-controller.js) — demande explicite : "la page de resultat + est cassee... elle affiche un conteneur qui reaffiche le contenu du + quiz au lieu du resultat" (le vrai moteur n'avait jusqu'ici AUCUN écran + de fin, juste la maquette artifact). ".classiqueContent.is-showing- + result" masque tout le reste (progression/question/choix/explication) + pour ne laisser QUE ce bloc visible, sans reconstruire le DOM du + widget (le contrat data-quiz-role="header/question/choices/score/ + timer" reste intact pour un "Recommencer" qui relance normalement). */ +.classiqueContent.is-showing-result > *{ display:none; } +.classiqueContent.is-showing-result .classiqueResultZone{ display:flex; } +.classiqueResultZone{ + display:none; flex:1; flex-direction:column; align-items:center; justify-content:center; + text-align:center; padding:20px 0; +} +.classiqueResultRing{ + width:112px; height:112px; margin:0 0 20px; border-radius:50%; + display:flex; align-items:center; justify-content:center; position:relative; + background:conic-gradient(#2f6fed calc(var(--pct, 0) * 1%), rgba(255,255,255,.14) 0); +} +.classiqueResultRing::before{ + content:""; position:absolute; inset:8px; border-radius:50%; background:var(--classique-body-bg, #1a1d24); +} +.classiqueResultRing span{ position:relative; font-weight:700; font-size:22px; font-variant-numeric:tabular-nums; } +.classiqueResultTitle{ font-size:19px; font-weight:700; margin:0 0 6px; } +.classiqueResultSub{ font-size:13.5px; color:rgba(255,255,255,.62); margin:0 0 22px; } +.classiqueResultBadge{ + display:inline-flex; align-items:center; gap:6px; padding:6px 16px; border-radius:999px; + font-size:12.5px; font-weight:700; margin-bottom:26px; +} +.classiqueResultBadge.pass{ background:rgba(46,204,113,.16); color:#2ecc71; } +.classiqueResultBadge.fail{ background:rgba(224,82,77,.16); color:#e0524d; } +.classiqueQuitBtn{ + font:inherit; font-weight:600; font-size:13.5px; color:#fff; background:transparent; + border:1px solid rgba(255,255,255,.3); border-radius:9px; padding:10px 20px; cursor:pointer; +} +.classiqueQuitBtn:hover{ border-color:#2f6fed; background:rgba(255,255,255,.08); } /* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/ déclencheur, contrairement aux deux widgets ci-dessus), voir diff --git a/templates/play.html b/templates/play.html index 0590a416..2b146ecc 100644 --- a/templates/play.html +++ b/templates/play.html @@ -42,6 +42,18 @@ #playFrame.sceneMode{ width:100vw; height:100vh; } .playScreen{ position:absolute; inset:0; display:none; } .playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; } + /* "La notion de caméra disparaît" quand un quiz plein écran existe sur + cet écran (demande explicite, voir screens/payload/full_game_payload.py + ::any_quiz_box_fullscreen) — le réglage "Caméra (px)" (scene_width/ + scene_height, qui donne à .playScene ci-dessus sa taille ET son + `transform` de centrage) ne s'applique alors plus DU TOUT : cet écran + devient lui-même plein viewport, sans transform (qui casserait + position:fixed pour tout descendant, voir dialogue-box-controller.js + ::forgeEscapeCameraForFullscreen — plus nécessaire ici puisque + .playScene n'a elle-même plus de transform dans ce cas). */ + .playScreen.playScene.playScene--quizFullscreen{ + position:fixed; inset:0; top:0; left:0; width:100vw; height:100vh; transform:none; + } .playElement{ position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif; } @@ -89,7 +101,7 @@ -
+
+
- {% if world_width > screen.scene_width or world_height > screen.scene_height %} + {% if not any_quiz_box_fullscreen and (world_width > screen.scene_width or world_height > screen.scene_height) %} + gêne jamais la sélection/le glisser d'un objet dessous. + Masqué dès qu'un quiz PLEIN ÉCRAN existe sur cet écran + (demande explicite : "la notion de caméra disparaît") — + il n'a plus aucun sens une fois que ce widget couvre tout. -->
{% endif %} {% for o in objects %} -
{{ o.rendered_html|safe }}
@@ -562,6 +576,98 @@ {% endif %} + {% if obj.kind == 'quiz_box' %} + +
+ Options du quiz +
+ +
+ +
+ +
+
+
+ +
+ +
+
+

Le plein écran et le minuteur ne s'appliquent qu'au jeu (jamais dans cet éditeur).

+
+
+ + + + + {% if quiz_box_config.fullscreen %} +
+ Modèles — page de quiz +
+

Visibles seulement en plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).

+
+ {% for key in quiz_box_page_templates %} + + {% endfor %} +
+
+
+ {% else %} +
+ Modèles — boîte de dialogue +
+

Visibles seulement hors plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).

+
+ {% for key in quiz_box_dialog_templates %} + + {% endfor %} +
+
+
+ {% endif %} + {% endif %} + {% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}