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>
338 lines
20 KiB
Python
338 lines
20 KiB
Python
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)
|