Dev #11

Merged
w-vandal merged 23 commits from dev into main 2026-09-04 19:13:42 +00:00
5 changed files with 154 additions and 47 deletions
Showing only changes of commit d705f58c4a - Show all commits
+11 -8
View File
@@ -69,11 +69,12 @@ def render_scene_object(obj):
# 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 = la question, corps = les choix de réponse
# (boutons 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.
# 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.
style = resolve_dialogue_box_style(obj)
box_style = (
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
@@ -83,11 +84,13 @@ def render_scene_object(obj):
)
return (
f'<div class="quizBoxWidget" 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"])}">La question s\'affiche ici.</div>'
f'<div class="quizBoxChoices" data-quiz-role="choices" style="background:{html_lib.escape(style["body_bg"])}">'
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>'
f'</div></div></div>'
)
if obj["kind"] == "score_widget":
# Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une
@@ -158,18 +158,31 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
// ---------- Enchaînement dialogue + question ("❓ Question") ----------
function fakeQuizWidget(objectId) {
const roles = { header: { textContent: '' }, choices: { innerHTML: '' } };
function fakeQuizChoiceBtn() {
const classes = new Set();
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: '' } };
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) },
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;
return null;
},
_roles: roles, _classes: classes,
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
offsetWidth: 100,
_roles: roles, _classes: classes, _buttons: buttons,
};
}
@@ -188,7 +201,7 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
test.beforeEach(() => {
global.gameData.quests.push({
id: 10, status: 'en_cours',
id: 10, title: 'Le quiz du sorcier', status: 'en_cours',
dialogues: {
nouvelle: [],
en_cours: [
@@ -202,10 +215,10 @@ test.beforeEach(() => {
window.forgeQuizScore = 0;
});
test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte de dialogue', () => {
test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => {
const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
const quizWidget = fakeQuizWidget(99);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
@@ -215,36 +228,66 @@ test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte d
forgeDialogueBoxAdvance(42); // -> ligne "question"
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question
assert.equal(quizWidget._classes.has('is-active'), true);
assert.equal(quizWidget._roles.header.textContent, '2+2 ?');
assert.equal(quizWidget._roles.header.textContent, 'Quête : Le quiz du sorcier');
assert.equal(quizWidget._roles.question.textContent, '2+2 ?');
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
} finally { global.document = previousDoc; }
});
test('mauvaise réponse au quiz : aucun effet, la question reste affichée', () => {
test('mauvaise réponse au quiz : aucun point, MAIS avance quand même (ne bloque jamais la progression), la bonne réponse est surlignée', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
global.setTimeout = fn => fn(); // exécute la révélation immédiatement pour le test
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
assert.equal(quizWidget._classes.has('is-active'), true); // reste affichée
assert.equal(window.forgeQuizScore, 0); // aucun point
} finally { global.document = previousDoc; }
assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), true); // le choix cliqué (faux) surligné en rouge
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la BONNE réponse indiquée quand même
// Avance quand même à la ligne suivante — jamais bloqué sur une mauvaise réponse.
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(dialogueWidget._classes.has('is-active'), true);
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
});
test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => {
test('un second clic pendant la révélation (avant l\'avance automatique) est ignoré', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
const pending = [];
global.setTimeout = fn => pending.push(fn); // ne s'exécute PAS tout de suite -> la question reste affichée
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
assert.equal(window.forgeQuizScore, 0); // le 2nd clic (bon) n'a pas compté
pending.forEach(fn => fn());
assert.equal(window.forgeQuizScore, 0); // toujours 0 après l'avance différée
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
});
test('bonne réponse au quiz : crédite le score, la surligne en vert, masque la boîte, avance à la ligne suivante', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
global.setTimeout = fn => fn();
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 1); // bonne réponse
assert.equal(window.forgeQuizScore, 5);
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(dialogueWidget._classes.has('is-active'), true);
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
@@ -252,7 +295,7 @@ test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
assert.equal(forgeQuestById(10).status, 'terminee');
assert.equal(dialogueWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
});
test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => {
+51 -18
View File
@@ -121,7 +121,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') {
if (widget) widget.classList.remove('is-active');
forgeShowQuizBox(dialogueObjectId, line);
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line);
return;
}
forgeHideQuizBoxOnCurrentScreen();
@@ -189,43 +189,76 @@ function forgeDialogueBoxAdvance(objectId) {
forgeAdvanceConversation(objectId);
}
// Combien de temps la bonne/mauvaise réponse reste affichée (surlignée,
// voir forgeQuizBoxAnswer) avant d'avancer automatiquement à la ligne
// suivante — assez long pour LIRE la bonne réponse en cas d'erreur, plus
// court si la réponse était déjà correcte (voir static/style.css,
// @keyframes quizPopIn/quizWrongShake pour l'animation elle-même).
var FORGE_QUIZ_REVEAL_DELAY_CORRECT = 700;
var FORGE_QUIZ_REVEAL_DELAY_WRONG = 1400;
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
// l'écran — si l'auteur n'en a posé aucune, la question est ignorée
// (avance directement) plutôt que de bloquer la conversation pour
// toujours en attendant une réponse qu'aucun widget ne peut recueillir.
function forgeShowQuizBox(dialogueObjectId, line) {
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz,
// la question est ignorée (avance directement) plutôt que de bloquer la
// conversation pour toujours en attendant une réponse qu'aucun widget ne
// peut recueillir.
function forgeShowQuizBox(dialogueObjectId, quest, line) {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id');
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount,
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
};
var header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]');
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (header) header.textContent = line.text;
if (header) header.textContent = 'Quête : ' + (quest ? quest.title : '');
if (questionEl) questionEl.textContent = line.text;
if (choicesEl) {
choicesEl.innerHTML = line.choices.map(function (choice, i) {
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
}).join('');
}
// 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
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
// son animation CSS.
widget.classList.remove('is-active');
void widget.offsetWidth;
widget.classList.add('is-active');
}
// Appelée par le clic sur un choix (voir render_scene_object.py côté
// éditeur — les vrais choix sont injectés EN JEU par forgeShowQuizBox
// ci-dessus, jamais rendus côté serveur). Mauvaise réponse : rien, la
// question reste affichée. Bonne réponse : crédite le score, masque la
// boîte, avance la conversation.
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
// explicite : une mauvaise réponse ne doit jamais bloquer la
// progression) — surligne la bonne réponse en vert (et le choix cliqué
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
// second clic pendant cet affichage est ignoré (`resolved`).
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
var state = forgeQuizBoxState[quizObjectId];
if (!state) return;
if (choiceIndex !== state.correctIndex) return;
forgeAddQuizScore(state.rewardAmount);
if (!state || state.resolved) return;
state.resolved = true;
var widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) widget.classList.remove('is-active');
var correct = choiceIndex === state.correctIndex;
if (widget) {
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
for (var i = 0; i < buttons.length; i++) {
buttons[i].disabled = true;
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
}
}
if (correct) forgeAddQuizScore(state.rewardAmount);
var dialogueObjectId = state.dialogueObjectId;
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(dialogueObjectId);
setTimeout(function () {
if (widget) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(dialogueObjectId);
}, correct ? FORGE_QUIZ_REVEAL_DELAY_CORRECT : FORGE_QUIZ_REVEAL_DELAY_WRONG);
}
// ---------- Offre de quête (Accepter/Refuser) ----------
+33 -6
View File
@@ -467,7 +467,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
(.flowCanvasWrap/.animTimelineWrap ont déjà leur propre défilement),
jamais sur cette zone entière — sans quoi la barre d'onglets et la barre
d'outils défileraient avec le contenu au lieu de rester fixes en haut. */
.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; }
.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; transition:margin .2s ease; }
/* Bug corrigé : les panneaux flottants (voir .floatPanel plus bas) sont
en position:fixed, DONC hors du flux flex de .builder3 — ils flottent
PAR-DESSUS le canevas sans jamais réduire sa largeur, rendant sa
partie droite (sous "⚙️ Propriétés") ou gauche (sous "🧩 Objets")
inaccessible au clic/glisser tant que le panneau correspondant reste
ouvert. Réserve maintenant leur largeur explicitement dès qu'un
panneau est ouvert (voir :not(.hidden), la classe posée/retirée par
openFloatPanel()/closeFloatPanel(), static/js/screen_edit/tree-panels.js). */
#builder3:has(#panelLeftFloat:not(.hidden)) .builderCanvasArea{ margin-left:310px; }
#builder3:has(#propsPanelFloat:not(.hidden)) .builderCanvasArea{ margin-right:370px; }
/* ---------- Panneaux gauche/droite flottants ---------- */
/* Les deux panneaux (éléments de l'écran / propriétés) sont sortis du flux
@@ -670,23 +680,40 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py,
static/js/play/dialogue-box-controller.js) : même squelette que
"💬 Boîte de dialogue" (header/corps, même "🎨 Style") mais pas de pied
— la question se résout au clic sur un choix. Masquée par défaut au
jeu, comme la boîte de dialogue. */
"💬 Boîte de dialogue" (header "Quête : <titre>"/corps, même "🎨 Style")
mais pas de pied — la question se résout au clic sur un choix. Masquée
par défaut au jeu, comme la boîte de dialogue ; rejoue son animation
d'entrée à CHAQUE nouvelle question (voir forgeShowQuizBox). */
@keyframes quizPopIn{ from{ opacity:0; transform:scale(.92) translateY(12px); } to{ opacity:1; transform:none; } }
@keyframes quizWrongShake{ 0%, 100%{ transform:translateX(0); } 20%{ transform:translateX(-6px); } 40%{ transform:translateX(6px); } 60%{ transform:translateX(-4px); } 80%{ transform:translateX(4px); } }
@keyframes quizCorrectPop{ 0%{ transform:scale(1); } 40%{ transform:scale(1.04); } 100%{ transform:scale(1); } }
.quizBoxWidget{
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
}
.playScreen .quizBoxWidget{ display:none; }
.playScreen .quizBoxWidget.is-active{ display:flex; }
.playScreen .quizBoxWidget.is-active{ display:flex; animation:quizPopIn .3s ease; }
.quizBoxHeader{ padding:8px 12px; font-weight:700; }
.quizBoxChoices{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px; }
.quizBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px; }
.quizBoxQuestionText{ margin:0; font-weight:600; }
.quizBoxChoices{ display:flex; flex-direction:column; gap:8px; }
.quizBoxChoiceBtn{
background:rgba(255,255,255,.12); color:inherit; border:1px solid rgba(255,255,255,.3);
border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; text-align:left; cursor:pointer;
transition:background .15s ease, transform .1s ease;
}
.quizBoxChoiceBtn:hover{ background:rgba(255,255,255,.25); transform:translateX(2px); }
.quizBoxChoiceBtn:disabled{ cursor:default; }
.quizBoxChoiceBtn:disabled:hover{ transform:none; }
/* Révélation de la bonne/mauvaise réponse (voir forgeQuizBoxAnswer) —
toujours la bonne en vert (même si le joueur s'est trompé, demande
explicite : "indiquer la bonne réponse"), le choix cliqué en rouge s'il
ne l'était pas. */
.quizBoxChoiceBtn.is-correct{
background:#2ecc71 !important; border-color:#2ecc71 !important; color:#08210f; font-weight:700;
animation:quizCorrectPop .3s ease;
}
.quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; }
/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/
déclencheur, contrairement aux deux widgets ci-dessus), voir
+2 -1
View File
@@ -179,7 +179,8 @@ def test_render_quiz_box_has_header_and_choices(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 'data-quiz-role="header"' in html
assert 'data-quiz-role="header"' in html # "Quête : <titre>", rempli en jeu
assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps
assert 'data-quiz-role="choices"' in html
assert f'data-object-id="{obj_id}"' in html
assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic