Files
Forge-Engine/game_engine/rendering/quiz_box_templates.py
T
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

348 lines
20 KiB
Python

import html as html_lib
from typing import Any, Callable
# ---------- "❓ Boîte à quiz" — DEUX catégories de modèles bien distinctes
# (voir game_engine/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/game/js/play/quiz-box-templates.js), mais TOUS
# doivent respecter le même "contrat" de points d'ancrage pour que le
# moteur PARTAGÉ (static/game/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: dict[str, Any], config: dict[str, Any], active_template: str) -> str:
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: dict[str, Any], style: dict[str, Any]) -> str:
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() -> str:
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: str, header_bg: str | None = None) -> str:
# 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: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
return (
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" '
f'{_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: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "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" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} '
f'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: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Manga" — premier modèle "PAGE DE QUIZ" (voir game_engine/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/game/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" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'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/game/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/game/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: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "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" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'style="{_quiz_box_style(obj, style)} '
f"--classique-footer-bg:{html_lib.escape(style['footer_bg'])}; "
f'--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/game/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/game/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>"
)
_QuizRenderer = Callable[[dict[str, Any], dict[str, Any], dict[str, Any]], str]
QUIZ_BOX_DIALOG_RENDERERS: dict[str, _QuizRenderer] = {
"defaut": render_quiz_box_defaut,
"manga_dialogue": render_quiz_box_manga_dialogue,
}
QUIZ_BOX_PAGE_RENDERERS: dict[str, _QuizRenderer] = {
"classique": render_quiz_box_page_classique,
"manga": render_quiz_box_page_manga,
}
def render_quiz_box(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
"""Point d'entrée UNIQUE (voir game_engine/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)