Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Build and deploy / test-python (push) Successful in 12m10s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Introduit la double categorie de modeles (boite de dialogue / page de
quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de
resultat pour les modeles page ; ajoute les modeles "Manga" (boite et
page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-13 15:07:23 +02:00
co-authored by Claude Sonnet 5
parent 559331f9cf
commit 7db4803b93
26 changed files with 3173 additions and 60 deletions
+27
View File
@@ -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é."
+51
View File
@@ -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,
+1
View File
@@ -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,
)
+26
View File
@@ -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,
+23
View File
@@ -0,0 +1,23 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/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)})
+6
View File
@@ -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
+21
View File
@@ -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
+97
View File
@@ -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)
+337
View File
@@ -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 (
'<div class="quizBoxTopBar" data-quiz-role="topbar">'
'<span class="quizBoxScoreDisplay" data-quiz-role="score">Score : 0</span>'
'<span class="quizBoxTimerDisplay" data-quiz-role="timer"></span>'
'</div>'
)
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'<div class="quizBoxHeaderRow"{bg_style}>'
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>'
+ _quiz_topbar_html() +
'</div>'
)
def render_quiz_box_defaut(obj, config, style):
return (
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
f'</div></div></div>'
)
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'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
'<div class="quizMangaDialogueFrame">'
+ _quiz_header_row_html("Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
# Repère visuel SEULEMENT (aperçu éditeur, jamais reconfiguré) —
# le vrai bouton est désormais TOUJOURS présent dès la question
# (voir forgeShowQuizBox, dialogue-box-controller.js — posé
# désactivé, activé par forgeQuizBoxAnswer une fois répondu,
# demande explicite), donc ce repère est maintenant fidèle au
# comportement réel, pas juste à l'aperçu éditeur.
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div></div>'
'</div>'
# Bandeau de pied de page TOUJOURS visible (comme la maquette
# validée) — purement décoratif (aria-hidden, jamais un
# data-quiz-role), le moteur JS partagé ne le touche JAMAIS : le
# bouton "Continuer →" qu'il injecte dans .quizBoxChoices se pose
# PAR-DESSUS en position:absolute (voir static/style.css), pas
# DANS ce bandeau — même principe que le modèle "page" "Classique".
# FRÈRE de .quizMangaDialogueFrame (jamais dedans) : .quizMangaDialogueFrame
# a overflow:hidden (pour la forme dentelée du corps), qui
# rognerait ce bandeau/le bouton s'ils étaient nichés à
# l'intérieur au lieu d'être positionnés par-dessus TOUT le
# widget (.quizBoxWidget--mangaDialogue est position:relative).
'<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>'
'</div>'
)
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 <button class="quizBoxChoiceBtn"> sans balisage
# supplémentaire à maintenir ici.
# "Temps" reste toujours visible, même sans minuteur configuré (demande
# explicite : "si il n'y a pas de chrono marque en dessous de temps
# aucun") — le texte initial ici ("Aucun") n'est JAMAIS écrasé par le
# moteur JS quand timer_mode="aucun" (voir forgeShowQuizBox,
# dialogue-box-controller.js : aucune de ses branches ne touche
# [data-quiz-role="timer"] dans ce cas), donc pas besoin d'un effet JS
# dédié pour le maintenir à jour question après question.
timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else ""
return (
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)}">'
'<div class="mangaTopbar">'
'<div class="mangaPlate mangaPlate--timer">'
'<span class="mangaPlateLabel">Temps</span>'
f'<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
'<div class="mangaTitlemark"><span>Manga Quiz</span></div>'
'<div class="mangaPlate mangaPlate--score">'
'<span class="mangaPlateLabel">Score</span>'
'<span class="mangaPlateValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
# Barre d'étapes (voir static/js/play/quiz-box-templates.js::
# forgeQuizMangaOnShow) — remplie EN JEU (nombre de questions total
# connu seulement à l'exécution, jamais à la pose de l'objet) ;
# data-manga-role (pas data-quiz-role) : décoration propre à CE
# modèle, jamais du contrat partagé.
'<div class="mangaProgress" data-manga-role="progress"></div>'
'<div class="mangaPanelQuestion">'
'<span class="mangaKicker" data-quiz-role="header">Titre de la quête</span>'
'<p class="mangaQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
'</div>'
'<div class="mangaChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
# É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.
'<div class="mangaResultZone" data-manga-role="result"></div>'
'</div>'
)
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'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)} --classique-footer-bg:{html_lib.escape(style["footer_bg"])}; --classique-body-bg:{html_lib.escape(style["body_bg"])};">'
f'<div class="classiqueHeaderBar" style="background:{html_lib.escape(style["header_bg"])}">'
'<div class="classiqueHeaderTitleWrap">'
'<span class="classiqueHeaderEyebrow">❓ Quiz</span>'
f'<span class="classiqueHeaderTitle">{title}</span>'
'</div>'
'<div class="classiqueHeaderChips">'
'<div class="classiqueChip classiqueChip--timer">'
'<span class="classiqueChipLabel">Temps</span>'
f'<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
'<div class="classiqueChip classiqueChip--score">'
'<span class="classiqueChipLabel">Score</span>'
'<span class="classiqueChipValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
'</div>'
f'<div class="classiqueBody" style="background:{html_lib.escape(style["body_bg"])}">'
'<div class="classiqueContent">'
# Barre d'étapes — même principe que "manga" (voir
# forgeQuizProgressOnShow, static/js/play/quiz-box-templates.js),
# remplie EN JEU (nombre de questions connu seulement à
# l'exécution) ; data-classique-role (pas data-quiz-role) :
# décoration propre à CE modèle, jamais du contrat partagé.
'<div class="classiqueProgress" data-classique-role="progress"></div>'
# [data-quiz-role="header"] sert ICI d'étiquette "Question N/M"
# (voir forgeQuizClassiqueOnShow) — JAMAIS le titre du quiz
# (classiqueHeaderTitle ci-dessus, statique).
'<span class="classiqueKicker" data-quiz-role="header">Question</span>'
'<p class="classiqueQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
'<div class="classiqueChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
# Encart d'explication après réponse — RÉSERVÉ pour une
# fonctionnalité FUTURE, PAS ENCORE implémentée (demande
# explicite : "tu peut le mettre dans le modele mais ne
# fonctionnera pas pour le moment il faudra attendre que la
# fonctionnalité sois implémenté") : aucune donnée d'explication
# n'existe aujourd'hui sur une question, donc AUCUN code JS
# n'écrit ici pour l'instant — reste vide et caché (voir
# static/style.css) jusqu'à ce que ce champ existe réellement.
'<div class="classiqueExplain" data-quiz-role="explain"></div>'
# Écran de résultat de fin de quiz — RESTE VIDE/CACHÉ tant que le
# quiz n'est pas terminé (voir forgeQuizClassiqueOnQuizComplete,
# static/js/play/quiz-box-templates.js, qui le remplit et bascule
# .classiqueContent en classe "is-showing-result" — masquant en
# CSS tout le reste : progression/question/choix). data-classique-
# role (pas data-quiz-role) : décoration propre à ce modèle.
'<div class="classiqueResultZone" data-classique-role="result"></div>'
'</div>'
'</div>'
# Bandeau de pied de page TOUJOURS visible (footer_bg, même
# esprit que header/body ci-dessus) — purement décoratif
# (aria-hidden, jamais un data-quiz-role), le moteur JS partagé
# ne le touche JAMAIS : le bouton "Continuer →" qu'il injecte
# dans .classiqueChoices se pose PAR-DESSUS en position:absolute
# (voir static/style.css), pas DANS ce bandeau — demande
# explicite : "il manque le footer foncé avec le bouton".
'<div class="classiqueFooterBand" aria-hidden="true"></div>'
'</div>'
)
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)
+15 -24
View File
@@ -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 : <titre>" (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'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
f'<div class="quizBoxHeader" data-quiz-role="header" style="background:{html_lib.escape(style["header_bg"])}">Quête : Titre de la quête</div>'
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
f'</div></div></div>'
)
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
@@ -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
@@ -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 <body> 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 <body> (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: () => [],
};
@@ -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, '<i class="is-done"></i><i class="is-current"></i><i class=""></i>');
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, '<i class="is-done"></i>'.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, '<i class="is-done"></i><i class="is-current"></i>');
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);
});
+290 -16
View File
@@ -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 '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
}).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 += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
}
// "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 += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')">Continuer →</button>';
}
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,
};
}
+165
View File
@@ -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('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
}
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 =
'<div class="classiqueResultRing" style="--pct:' + pct + '"><span>' + pct + '%</span></div>' +
'<p class="classiqueResultTitle">' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '</p>' +
'<p class="classiqueResultSub">« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »</p>' +
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
'</div>' +
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
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 =
'<div class="mangaResultCard">' +
'<span class="mangaResultTitle">Fin du quiz</span>' +
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
'</div>';
var progressEl = widget.querySelector('[data-manga-role="progress"]');
if (progressEl && total > 0) {
var bars = [];
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
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,
};
}
+74
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+559 -2
View File
@@ -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/<clé>.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 <body> 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 <i> 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
+14 -1
View File
@@ -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 @@
<!-- Un seul moteur d'écran désormais (jeu_2d) : objets déjà entièrement
positionnés en pixels absolus par render_scene_object.py (voir
screens/scenes/) — juste le HTML tel quel. -->
<div class="playScreen playScene" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
<div class="playScreen playScene {{ 'playScene--quizFullscreen' if s.any_quiz_box_fullscreen else '' }}" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="{{ '' if s.any_quiz_box_fullscreen else 'width:' + s.scene_width|string + 'px; height:' + s.scene_height|string + 'px;' }}">
<!-- .sceneWorld : le "monde" (voir static/style.css et
static/js/play/personnage-controller.js) — taille par défaut 100%
(identique au viewport ci-dessus), agrandie/décalée en JS
@@ -180,6 +192,7 @@
<script src="{{ asset_url('js/play/actions.js') }}"></script>
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
<script src="{{ asset_url('js/play/quiz-box-templates.js') }}"></script>
<script src="{{ asset_url('js/play/dialogue-box-controller.js') }}"></script>
<script src="{{ asset_url('js/play/video-overlay.js') }}"></script>
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
+110 -4
View File
@@ -274,9 +274,20 @@
<div class="builderTabPanel active" data-tab-panel="screen">
<div class="canvasFrame">
<div class="canvas" id="canvas" style="width:{{ world_width }}px; height:{{ world_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;"
<!-- Un quiz plein écran fait disparaître la notion de caméra
(demande explicite) — pas seulement le cadre "🎥 Champ de la
caméra" (déjà masqué plus bas), mais le CANEVAS lui-même :
sans ce cas particulier, il restait dimensionné en pixels
fixes (world_width/world_height, une notion de caméra/scène
nominale) — .canvasElement.isQuizFullscreenPreview a beau
remplir 100% de CE canevas, le canevas restait lui-même de la
taille de la caméra, laissant une bande vide visible autour.
width/height:100% (voir la règle .canvas déjà prévue pour ça
dans static/style.css) fait remplir tout .canvasFrame à la
place, indépendamment de toute taille de scène/caméra. -->
<div class="canvas" id="canvas" style="{{ 'width:100%; height:100%;' if any_quiz_box_fullscreen else 'width:' + world_width|string + 'px; height:' + world_height|string + 'px;' }} aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;"
ondragover="onCanvasDragOver(event)" ondrop="onCanvasDrop(event)">
{% 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) %}
<!-- "🎥 Champ de la caméra" : le monde (ce canevas, voir
world_width/world_height ci-dessus, routes/scenes/
scene_edit_view.py) peut être plus grand que la scène
@@ -286,11 +297,14 @@
controller.js::forgeUpdateSceneCamera), pour que le monde
agrandi reste lisible plutôt qu'une simple étendue grise
sans repère. Purement visuel (pointer-events:none) : ne
gêne jamais la sélection/le glisser d'un objet dessous. -->
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. -->
<div class="sceneCameraFrame" style="width:{{ screen.scene_width }}px; height:{{ screen.scene_height }}px;" title="Champ de la caméra en jeu ({{ screen.scene_width }}×{{ screen.scene_height }} px)"></div>
{% endif %}
{% for o in objects %}
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }}" id="el-{{ o.id }}"
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
@@ -562,6 +576,98 @@
</details>
{% endif %}
{% if obj.kind == 'quiz_box' %}
<!-- "⚙️ Options du quiz" (demande explicite : "étendre les
fonctionnalités de la boîte à quiz pour les RH qui veulent
juste faire un quiz") — voir screens/rendering/quiz_box_config.py,
routes/scenes/scene_object_quiz_config.py. Plein écran/minuteur,
DISTINCT de "🎨 Style" ci-dessus (couleurs/police, partagé avec
dialogue_box/score_widget). -->
<details class="propGroup" open>
<summary>Options du quiz</summary>
<div class="propGroupBody">
<label class="checkbox is-small">
<input type="checkbox" id="quiz-fullscreen" data-was-checked="{{ '1' if quiz_box_config.fullscreen else '0' }}" {{ 'checked' if quiz_box_config.fullscreen else '' }} onchange="saveQuizBoxConfig({{ obj.id }})">
Plein écran
</label>
<div class="field mt-2">
<label class="label is-small">Minuteur</label>
<div class="control">
<select class="input is-small" id="quiz-timer-mode" onchange="saveQuizBoxConfig({{ obj.id }})">
{% for mode in quiz_box_timer_modes %}
<option value="{{ mode }}" {{ 'selected' if quiz_box_config.timer_mode == mode else '' }}>
{{ {'aucun': 'Aucun', 'question': 'Par question', 'quiz': 'Pour tout le quiz'}[mode] }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Durée (secondes)</label>
<div class="control">
<input class="input is-small" type="number" id="quiz-timer-seconds" min="3" max="600" value="{{ quiz_box_config.timer_seconds }}" onchange="saveQuizBoxConfig({{ obj.id }})">
</div>
</div>
<p class="help">Le plein écran et le minuteur ne s'appliquent qu'au jeu (jamais dans cet éditeur).</p>
</div>
</details>
<!-- "🖼️ Modèles" — DEUX catégories bien distinctes (demande
explicite), une SEULE des deux visible/modifiable à la fois
selon "Plein écran" ci-dessus (les deux valeurs restent
conservées indépendamment, voir screens/rendering/
quiz_box_config.py) :
- "boîte de dialogue" (hors plein écran) : variantes de forme/
couleur sur une structure proche de "Classique" ;
- "page de quiz" (plein écran) : thème complet et autonome,
structure HTML propre à CHAQUE modèle (voir
screens/rendering/quiz_box_templates.py,
render_quiz_box_page_manga pour le premier).
Ce <div> caché retient la valeur de CELUI des deux réglages
qui n'a pas de galerie affichée ici, pour que saveQuizBoxConfig
(scene-editor.js) ne l'écrase jamais par erreur en l'absence de
sa grille. -->
<div id="quizBoxTemplateMeta" data-dialog-template="{{ quiz_box_config.dialog_template }}" data-page-template="{{ quiz_box_config.page_template }}" style="display:none;"></div>
{% if quiz_box_config.fullscreen %}
<details class="propGroup" open>
<summary>Modèles — page de quiz</summary>
<div class="propGroupBody">
<p class="help mb-2">Visibles seulement en plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).</p>
<div class="quizTemplateGrid" id="quizPageTemplateGrid" data-object-id="{{ obj.id }}">
{% for key in quiz_box_page_templates %}
<button type="button" class="quizTemplateTile {{ 'is-selected' if quiz_box_config.page_template == key else '' }}"
data-template="{{ key }}" onclick="selectQuizBoxPageTemplate({{ obj.id }}, '{{ key }}')">
<span class="quizTemplatePreviewImg">
<img src="{{ url_for('static', filename='quiz-templates/' + key + '.png') }}" alt="Aperçu du modèle « {{ quiz_box_page_template_labels[key] }} »" loading="lazy">
</span>
<span class="quizTemplateName">{{ quiz_box_page_template_labels[key] }}</span>
</button>
{% endfor %}
</div>
</div>
</details>
{% else %}
<details class="propGroup" open>
<summary>Modèles — boîte de dialogue</summary>
<div class="propGroupBody">
<p class="help mb-2">Visibles seulement hors plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).</p>
<div class="quizTemplateGrid" id="quizDialogTemplateGrid" data-object-id="{{ obj.id }}">
{% for key in quiz_box_dialog_templates %}
<button type="button" class="quizTemplateTile {{ 'is-selected' if quiz_box_config.dialog_template == key else '' }}"
data-template="{{ key }}" onclick="selectQuizBoxDialogTemplate({{ obj.id }}, '{{ key }}')">
<span class="quizTemplatePreviewImg">
<img src="{{ url_for('static', filename='quiz-templates/' + key + '.png') }}" alt="Aperçu du modèle « {{ quiz_box_dialog_template_labels[key] }} »" loading="lazy">
</span>
<span class="quizTemplateName">{{ quiz_box_dialog_template_labels[key] }}</span>
</button>
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% endif %}
{% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
+23
View File
@@ -182,6 +182,29 @@ def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_quiz_box_config_tool():
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
(plein écran/minuteur/modèle d'un quiz autonome) — pas seulement
l'éditeur manuel."""
prompt = ai.chat._SYSTEM_PROMPT
assert "set_quiz_box_config" in prompt
for keyword in ("fullscreen", "timer_mode", "dialog_template", "page_template"):
assert keyword in prompt, keyword
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it():
assert "jamais imposé" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_two_quiz_template_categories():
"""Demande explicite : "Ruby doit également comprendre cette
distinction" (modèles "boîte de dialogue" hors plein écran vs modèles
"page de quiz" en plein écran, structures HTML totalement séparées)."""
prompt = ai.chat._SYSTEM_PROMPT
assert "page de quiz" in prompt
assert "manga" in prompt
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="personnage")
+43
View File
@@ -168,6 +168,34 @@ def test_dispatch_set_object_collision_updates_the_settings(client, tmp_game_slu
assert settings["shape"] == "cercle"
def test_dispatch_set_quiz_box_config_updates_the_settings(client, tmp_game_slug_cleanup):
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
(plein écran/minuteur/modèle d'une "❓ Boîte à quiz", voir
screens/rendering/quiz_box_config.py) — pas seulement l'éditeur
manuel."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
dispatch_tool(slug, screen_id, 1, "set_quiz_box_config", {
"object_id": object_id, "fullscreen": True, "timer_mode": "question",
"timer_seconds": 15, "dialog_template": "defaut", "page_template": "manga",
})
obj = screens.get_scene_object(slug, object_id)
config = screens.resolve_quiz_box_config(obj)
assert config == {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
}
def test_dispatch_set_quiz_box_config_defaults_to_no_fullscreen_no_timer(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
dispatch_tool(slug, screen_id, 1, "set_quiz_box_config", {"object_id": object_id})
obj = screens.get_scene_object(slug, object_id)
config = screens.resolve_quiz_box_config(obj)
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
def test_dispatch_create_global_variable_calls_db_directly(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
result = dispatch_tool(slug, screen_id, 1, "create_global_variable", {"name": "score_ia", "var_type": "nombre_entier"})
@@ -417,3 +445,18 @@ def test_set_object_role_enum_matches_the_source_constant():
def test_set_object_collision_shape_enum_matches_the_source_constant():
schema = _tool("set_object_collision")["input_schema"]["properties"]["shape"]
assert schema["enum"] == list(screens.COLLISION_SHAPES)
def test_set_quiz_box_config_timer_mode_enum_matches_the_source_constant():
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["timer_mode"]
assert schema["enum"] == list(screens.TIMER_MODES)
def test_set_quiz_box_config_dialog_template_enum_matches_the_source_constant():
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["dialog_template"]
assert schema["enum"] == list(screens.QUIZ_BOX_DIALOG_TEMPLATES)
def test_set_quiz_box_config_page_template_enum_matches_the_source_constant():
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["page_template"]
assert schema["enum"] == list(screens.QUIZ_BOX_PAGE_TEMPLATES)
+630
View File
@@ -0,0 +1,630 @@
"""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") — voir screens/rendering/
quiz_box_config.py, screens/rendering/quiz_box_templates.py. DISTINCT de
"🎨 Style" (dialogue_box_style.py, partagé avec dialogue_box/score_widget,
voir test_dialogue_box.py) : ce fichier ne teste que plein écran/
minuteur/modèle."""
import db
import screens
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
# ---------- Sanitizer/résolution ----------
def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_template(tmp_game_slug_cleanup):
""""Aucun" minuteur/plein écran désactivé par défaut — demande
explicite : jamais imposé, seulement au choix du créateur."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
config = screens.resolve_quiz_box_config(obj)
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
assert config["fullscreen"] is False
assert config["timer_mode"] == "aucun"
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_clamps_timer_seconds(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999})
assert config["timer_seconds"] == 600
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 0})
assert config["timer_seconds"] == 3
def test_sanitize_quiz_box_config_rejects_unknown_timer_mode_and_templates(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {
"timer_mode": "toutes-les-secondes", "dialog_template": "arc-en-ciel", "page_template": "aquarelle",
})
assert config["timer_mode"] == "aucun"
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(tmp_game_slug_cleanup):
"""Demande explicite : les deux réglages sont conservés
INDÉPENDAMMENT — un modèle "boîte" invalide ne doit jamais affecter le
modèle "page" valide, et réciproquement ("verre" n'existe plus depuis
la suppression des modèles "boîte" non-base, sert ici justement à
vérifier qu'une valeur invalide retombe sur "defaut" SANS toucher au
page_template valide à côté)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "verre", "page_template": "manga"})
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "manga"
def test_sanitize_quiz_box_config_ignores_a_non_dict_input(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, "pas-un-dict")
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
def test_set_scene_object_quiz_config_persists_and_sanitizes(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
})
obj = screens.get_scene_object(slug, obj_id)
config = screens.resolve_quiz_box_config(obj)
assert config == {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
}
def test_set_scene_object_quiz_config_returns_none_for_unknown_object(tmp_game_slug_cleanup):
slug, _ = _create_jeu2d_game(tmp_game_slug_cleanup)
assert screens.set_scene_object_quiz_config(slug, 999999, {}) is None
# ---------- Route ----------
def test_quiz_config_route_persists_config_and_returns_rendered_html(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/quiz-config",
data='{"config": {"fullscreen": false, "timer_mode": "quiz", "timer_seconds": 30, "dialog_template": "defaut"}}',
content_type="application/json",
)
assert resp.status_code == 200
body = resp.get_json()
assert body["config"]["fullscreen"] is False
assert body["config"]["dialog_template"] == "defaut"
assert 'class="quizBoxWidget"' in body["rendered_html"]
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_quiz_box_config(obj)["dialog_template"] == "defaut"
def test_quiz_config_route_page_template_only_applies_when_fullscreen(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/quiz-config",
data='{"config": {"fullscreen": true, "page_template": "manga"}}',
content_type="application/json",
)
assert resp.status_code == 200
body = resp.get_json()
assert body["config"]["page_template"] == "manga"
assert "quizBoxWidget--manga" in body["rendered_html"]
def test_quiz_config_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/quiz-config", data='{"config": {}}', content_type="application/json")
assert resp.status_code == 404
# ---------- Rendu — "contrat" de points d'ancrage (voir §3 du plan) :
# chaque modèle doit exposer les mêmes data-quiz-role, quelle que soit sa
# présentation, pour que le moteur JS partagé fonctionne quel que soit le
# modèle choisi ----------
def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(tmp_game_slug_cleanup):
"""Non-régression explicite : un quiz existant (jamais reconfiguré)
doit rester visuellement identique à avant cette extension."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert "quizBoxWidget--" not in html
assert 'data-quiz-role="header"' in html
assert 'data-quiz-role="question"' in html
assert 'data-quiz-role="choices"' in html
assert "Quête : Titre de la quête" in html
assert html.count("quizBoxChoiceBtn") == 2
assert "quizBoxFooter" not in html
def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_slug_cleanup):
"""Demande explicite : "le score doit être à l'intérieur de la boîte
à quiz, pas à l'extérieur" — le score (topbar) et le titre (header)
doivent être frères DANS le même bandeau ("quizBoxHeaderRow"), jamais
deux bandes empilées de couleurs différentes."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
row_start = html.index("quizBoxHeaderRow")
row_end = html.index("quizBoxBody") # le bandeau se termine avant le corps
assert row_start < html.index('data-quiz-role="header"', row_start) < row_end, template
assert row_start < html.index('data-quiz-role="topbar"', row_start) < row_end, template
def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
"""Contrat commun (voir §3 du plan) — vérifié ici pour les modèles
"boîte de dialogue" (fullscreen=False, voir le test jumeau pour les
modèles "page de quiz")."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "topbar", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
assert f'data-quiz-template="{template}"' in html
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
"""Même contrat, pour les modèles "page de quiz" (fullscreen=True) —
"topbar" n'est PAS exigé ici (voir le contrat assoupli dans
screens/rendering/quiz_box_templates.py : un modèle "page" n'a pas
besoin de regrouper score/minuteur à masquer, il n'existe QUE plein
écran)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
for template in screens.QUIZ_BOX_PAGE_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": template})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
assert f'data-quiz-template="{template}"' in html
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_reflects_fullscreen_and_timer_data_attributes(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert 'data-fullscreen="1"' in html
assert 'data-timer-mode="question"' in html
assert 'data-timer-seconds="42"' in html
def test_render_quiz_box_manga_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
"""Demande explicite : "garde le temps mais si il n'y a pas de chrono
marque en dessous de temps aucun" — la plaque "Temps" reste TOUJOURS
visible (jamais masquée), avec "Aucun" en son absence."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_manga_leaves_the_timer_value_empty_when_a_timer_is_configured(tmp_game_slug_cleanup):
"""Un vrai minuteur est rempli par le moteur JS (compte à rebours), pas
par le rendu serveur — jamais "Aucun" affiché en même temps qu'un
minuteur réellement configuré."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer"></span>' in html
def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
"""Demande explicite : "il n'y a pas le steper" — le HTML doit exposer
un conteneur `data-manga-role="progress"` que le JS (forgeQuizMangaOnShow,
static/js/play/quiz-box-templates.js) remplit au jeu (nombre de questions
inconnu à la création de l'objet)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="mangaProgress" data-manga-role="progress"></div>' in html
def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
"""Demande explicite : "il faut aussi adapter le modele manga pour
avoir sa page de résultat" — voir forgeQuizMangaOnQuizComplete,
static/js/play/quiz-box-templates.js, qui remplit/affiche ce
conteneur EN JEU seulement (toujours vide côté rendu serveur)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="mangaResultZone" data-manga-role="result"></div>' in html
def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(tmp_game_slug_cleanup):
""""Manga" (boîte de dialogue) — demande explicite : "il faut créer un
modele de boite themé mangas qui va avec le mode de page mangas",
validé via une maquette interactive. Reste une petite carte flottante
(fullscreen=False), structure PARTAGÉE avec les autres modèles
"boîte" (contrat déjà vérifié génériquement, voir
test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--mangaDialogue"' in html
assert '<div class="quizMangaDialogueFrame">' in html
assert 'data-quiz-template="manga_dialogue"' in html
def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
"""Demande explicite : "le modele dois etre une copie conforme a la
maquete au pixel pret" — un bandeau de pied de page sombre reste
TOUJOURS visible (même principe que le modèle "page" "Classique"),
même avant toute réponse (le bouton "Continuer →" du moteur partagé,
lui, n'apparaît qu'après coup, posé par-dessus ce bandeau en CSS)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>' in html
# "Titre de la quête" seul (jamais "Quête : Titre de la quête") — le
# préfixe "Quête : " est posé en CSS (::before), pas dans le HTML,
# sinon il apparaîtrait EN DOUBLE dans l'aperçu éditeur.
assert '>Titre de la quête<' in html
assert '>Quête : Titre de la quête<' not in html
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(tmp_game_slug_cleanup):
"""Demande explicite (répétée : "il est ou le bouton continuer !!!!!",
puis "le bouton continuer est toujours visible mais activer seulement
quand un choix est fait") — le vrai bouton, comme ce repère d'aperçu
éditeur, est maintenant TOUJOURS présent dès la question (posé
désactivé par forgeShowQuizBox, activé par forgeQuizBoxAnswer une
fois répondu — voir static/js/play/dialogue-box-controller.js)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>' in html
def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_continue_button(tmp_game_slug_cleanup):
"""Demande explicite : "le contener qui contien les choix a les bord
du bas qui ne sont pas pointue" — le coin dentelé du bas est un
simple `background` (dégradé) sur .quizBoxBody, PAS un clip-path ni
un décor séparé à part (deux essais précédents, tous deux abandonnés :
l'un rognait, l'autre emprisonnait dans un contexte d'empilement, le
bouton "Continuer →" du moteur JS partagé — voir static/style.css).
Le HTML reste donc le plus simple possible : le bouton reste un
DESCENDANT DIRECT de .quizBoxChoices, sans wrapper intermédiaire."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert "quizMangaDialogueBodyBg" not in html
assert (
'<div class="quizBoxChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div>'
) in html
def test_render_quiz_box_classique_is_the_default_page_template(tmp_game_slug_cleanup):
""""Classique" — modèle de PAGE DE QUIZ DE BASE, devient le réglage
par défaut (au même titre que "defaut"/Classique côté "boîte de
dialogue") : un créateur qui active juste "Plein écran" sans jamais
toucher aux modèles obtient ce modèle sobre, jamais "manga"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
obj = screens.get_scene_object(slug, obj_id)
config = screens.resolve_quiz_box_config(obj)
assert config["page_template"] == "classique"
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--classique"' in html
def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(tmp_game_slug_cleanup):
"""Demande explicite : "aujourd'hui un quiz n'a pas de titre mais il y
a un champs nom qui peut servir de titre pour le modele" — pas de
nouveau champ, le champ "Nom" (déjà existant, "ℹ️ Informations")
devient le titre affiché dans le bandeau de ce modèle."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_name(slug, obj_id, "Formation Sécurité Incendie")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Formation Sécurité Incendie</span>' in html
def test_render_quiz_box_classique_falls_back_to_a_generic_title_without_a_name(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Quiz</span>' in html
def test_render_quiz_box_classique_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pill_container(tmp_game_slug_cleanup):
"""Demande explicite (répétée) : "le conteneur de temps + aucun
doivent avoir la background mais pas le mot aucun" — UN SEUL fond
(celui de ".classiqueChip", identique pour "Temps" et "Score", avec
ou sans minuteur configuré) ; la pastille parasite que
".quizBoxTimerDisplay" pose d'ordinaire autour de sa propre valeur
(voir static/style.css) est neutralisée pour ce modèle — un bug de
classe partagée qui collait un second fond/coin arrondi UNIQUEMENT
autour du mot "Aucun", jamais autour de "Score : 0"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert 'class="classiqueChip classiqueChip--timer"' in html
assert 'class="classiqueChip classiqueChip--score"' in html
assert 'class="classiqueChipValue quizBoxTimerDisplay"' in html
def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
"""Écran de résultat de fin de quiz (demande explicite : "elle affiche
un conteneur qui reaffiche le contenu du quiz au lieu du resultat" —
fonctionnalité désormais réellement implémentée, voir
forgeQuizClassiqueOnQuizComplete/forgeQuizResultRestart, static/js/
play/quiz-box-templates.js et dialogue-box-controller.js) : le HTML
expose toujours ce conteneur vide, rempli/affiché EN JEU seulement."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="classiqueResultZone" data-classique-role="result"></div>' in html
def test_render_quiz_box_classique_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
"""Demande explicite : "il manque le footer foncé avec le bouton" —
un bandeau de pied de page (couleur footer_bg du panneau "🎨 Style")
reste TOUJOURS visible, même avant toute réponse (le bouton
"Continuer →" du moteur partagé, lui, n'apparaît qu'après coup, posé
par-dessus ce bandeau en CSS)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="classiqueFooterBand" aria-hidden="true"></div>' in html
def test_render_quiz_box_classique_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="classiqueProgress" data-classique-role="progress"></div>' in html
def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(tmp_game_slug_cleanup):
"""Encart d'explication réservé pour une fonctionnalité FUTURE non
encore implémentée (demande explicite : "tu peut le mettre dans le
modele mais ne fonctionnera pas pour le moment") — présent dans le
HTML mais toujours vide aujourd'hui, aucune donnée ne l'alimente."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert '<div class="classiqueExplain" data-quiz-role="explain"></div>' in html
def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(tmp_game_slug_cleanup):
"""Le panneau "🎨 Style" (couleurs/police) continue de s'appliquer à
TOUS les modèles — un modèle change la disposition/décoration, jamais
une raison d'ignorer les couleurs déjà choisies par le créateur."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_dialogue_box_style(slug, obj_id, {"text_color": "#123456"})
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "defaut"})
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert "#123456" in html
# ---------- Payload ----------
def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
payload = screens.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
box = next(o for o in scene["elements"] if o["kind"] == "quiz_box")
assert box["quiz_config"]["fullscreen"] is True
assert box["quiz_config"]["page_template"] == "manga"
def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(tmp_game_slug_cleanup):
"""Bug signalé : "il faut régler la caméra sur plein écran et
désactiver ce réglage quand la boîte à quiz est plein écran" — ce
drapeau permet à templates/play.html de rendre l'ÉCRAN lui-même en
plein viewport (voir .playScene--quizFullscreen), pas seulement le
widget."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
payload = screens.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
assert scene["any_quiz_box_fullscreen"] is False
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
payload = screens.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
assert scene["any_quiz_box_fullscreen"] is True
def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
assert "playScene--quizFullscreen" in html
# Le réglage "Caméra (px)" (scene_width/scene_height) ne doit plus du
# tout dimensionner l'écran dans ce cas.
assert "width:960px" not in html
def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
# La règle CSS elle-même (toujours présente dans <style>) ne compte
# pas — seule la classe posée sur LA DIV de cet écran importe ici.
assert f'data-screen-id="{screen_id}"' in html
assert 'class="playScreen playScene "' in html
assert "width:960px; height:540px;" in html
# ---------- Éditeur — panneau propriétés (rendu, pas de logique JS ici) ----------
def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert 'id="quiz-fullscreen"' in html
assert 'id="quiz-timer-mode"' in html
assert 'id="quiz-timer-seconds"' in html
assert 'id="quizDialogTemplateGrid"' in html
assert 'id="quizPageTemplateGrid"' not in html
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
assert f"selectQuizBoxDialogTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, tmp_game_slug_cleanup):
"""Demande explicite : les modèles "page de quiz" ne se voient QUE
quand la boîte à quiz est plein écran — jamais les deux galeries en
même temps."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert 'id="quizPageTemplateGrid"' in html
assert 'id="quizDialogTemplateGrid"' not in html
for template in screens.QUIZ_BOX_PAGE_TEMPLATES:
assert f"selectQuizBoxPageTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert 'id="quiz-fullscreen"' not in html
assert 'id="quizDialogTemplateGrid"' not in html
assert 'id="quizPageTemplateGrid"' not in html
# ---------- "Plein écran" dans L'ÉDITEUR (demande explicite : "elle doit
# occuper tout l'écran", "être visible par défaut", "la notion de caméra
# disparaît" — pas seulement au jeu) ----------
def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert f'id="el-{obj_id}"' in html
# La classe qui fait occuper tout le canevas (voir static/style.css
# .canvasElement.isQuizFullscreenPreview) doit être posée sur le
# conteneur de CET objet précis.
import re
m = re.search(r'<div class="canvasElement[^"]*"\s+id="el-' + str(obj_id) + '"', html)
assert m and "isQuizFullscreenPreview" in m.group(0)
def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
"""Bug signalé : "il ne devait plus y avoir de notion de caméra" —
masquer seulement le cadre "🎥 Champ de la caméra" ne suffisait pas,
le CANEVAS restait lui-même dimensionné en pixels fixes
(world_width/world_height, une notion de caméra/scène nominale),
laissant une bande vide visible autour de la boîte plein écran."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
style = m.group(1)
assert "width:100%; height:100%;" in style
assert "px" not in style.split(";")[0] and "px" not in style.split(";")[1]
def test_scene_edit_view_canvas_keeps_pixel_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
assert "width:960px; height:540px;" in m.group(1)
def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
# Un fond plus grand que la scène nominale, pour que "🎥 Champ de la
# caméra" soit normalement affiché (voir resolve_scene_world_size).
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)
html_before = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "sceneCameraFrame" in html_before
quiz_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, quiz_id, {"fullscreen": True})
html_after = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "sceneCameraFrame" not in html_after
def test_scene_edit_view_keeps_the_camera_frame_when_no_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "sceneCameraFrame" in html