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
+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