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 ( '
' 'Score : 0' '' "
" ) 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'
' f'
{header_text}
' + _quiz_topbar_html() + "
" ) def render_quiz_box_defaut(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str: return ( f'" ) 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'" ) 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 ' '' "" # É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. '
' "" ) 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'" ) _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)