diff --git a/db/__init__.py b/db/__init__.py index e3874e41..25f3a850 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -88,6 +88,7 @@ from .quests.update_quest import update_quest from .quests.delete_quest import delete_quest from .quests.set_quest_dialogues import set_quest_dialogues from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES +from .quests.reward_budget import quest_dialogues_total_reward __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", @@ -108,5 +109,5 @@ __all__ = [ "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", - "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", + "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward", ] diff --git a/db/quests/reward_budget.py b/db/quests/reward_budget.py new file mode 100644 index 00000000..68d66601 --- /dev/null +++ b/db/quests/reward_budget.py @@ -0,0 +1,15 @@ +def quest_dialogues_total_reward(dialogues): + """Somme des récompenses de toutes les questions ("❓ Question", voir + sanitize_quest_dialogues.py), TOUTES colonnes/statuts confondus — une + quête n'a qu'UNE SEULE récompense totale (quest["recompense_score"]), + jamais une par statut. Utilisée pour empêcher d'enregistrer plus de + points répartis entre les questions que la quête n'en offre (voir + routes/quests/quest_dialogues.py et quest_update.py).""" + total = 0 + for lines in (dialogues or {}).values(): + if not isinstance(lines, list): + continue + for line in lines: + if isinstance(line, dict) and line.get("type") == "question": + total += line.get("reward_amount", 0) or 0 + return total diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index d17828af..2f026047 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -11,7 +11,7 @@ _MAX_SPEAKER_LENGTH = 60 # d'autres plus tard sans changer la forme des données. QUESTION_REWARD_TYPES = ["score"] _MIN_CHOICES = 2 -_MAX_CHOICES = 8 +_MAX_CHOICES = 4 def _sanitize_dialogue_line(raw_line): diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py index 2f318de1..379ba347 100644 --- a/routes/quests/quest_dialogues.py +++ b/routes/quests/quest_dialogues.py @@ -9,9 +9,26 @@ from core.flask_app import app def quest_dialogues(slug, quest_id): """Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) — le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet - complet à chaque modification plutôt qu'une réplique isolée.""" - body = request.get_json(silent=True) or {} - dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) - if dialogues is None: + complet à chaque modification plutôt qu'une réplique isolée. + + Refusé (400) si la somme des récompenses des questions ("❓ Question") + dépasse la récompense totale de la quête (recompense_score) — voir + db.quest_dialogues_total_reward. Validé ICI (avant persistance) + plutôt que dans db.set_quest_dialogues, pour renvoyer un message + d'erreur explicite au lieu d'enregistrer silencieusement un état + incohérent.""" + quest = db.get_quest(slug, quest_id) + if quest is None: return jsonify({"error": "quête introuvable"}), 404 + body = request.get_json(silent=True) or {} + sanitized = db.sanitize_quest_dialogues(body.get("dialogues")) + allocated = db.quest_dialogues_total_reward(sanitized) + if allocated > quest["recompense_score"]: + return jsonify({ + "error": ( + f"La somme des récompenses des questions ({allocated} pts) dépasse " + f"la récompense totale de la quête ({quest['recompense_score']} pts)." + ), + }), 400 + dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) return jsonify({"dialogues": dialogues}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py index 2c922c82..46f5384f 100644 --- a/routes/quests/quest_update.py +++ b/routes/quests/quest_update.py @@ -7,7 +7,29 @@ from core.flask_app import app @app.route("/game//quests/", methods=["POST"]) def quest_update(slug, quest_id): + """Refusé (400) si "recompense_score" est abaissé SOUS le total déjà + réparti entre les questions ("❓ Question") du dialogue de cette + quête — voir db.quest_dialogues_total_reward, même contrainte que + routes/quests/quest_dialogues.py mais dans l'autre sens (ici c'est le + budget total qui bouge, pas la répartition).""" body = request.get_json(silent=True) or {} + if "recompense_score" in body: + quest = db.get_quest(slug, quest_id) + if quest is None: + return jsonify({"error": "quête introuvable"}), 404 + try: + new_score = int(body["recompense_score"]) + except (TypeError, ValueError): + new_score = None + if new_score is not None: + allocated = db.quest_dialogues_total_reward(quest["dialogues"]) + if new_score < allocated: + return jsonify({ + "error": ( + f"La récompense totale ({new_score} pts) est inférieure aux " + f"{allocated} pts déjà répartis entre les questions du dialogue." + ), + }), 400 quest = db.update_quest(slug, quest_id, body) if quest is None: return jsonify({"error": "quête introuvable"}), 404 diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index f0a53f9d..d4276a37 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -28,14 +28,15 @@ def render_scene_edit(slug, screen_id, game): collision_settings = None dialogue_box_style = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None + _UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget") if selected_obj: - if selected_obj["kind"] != "dialogue_box": + if selected_obj["kind"] not in _UI_WIDGET_KINDS: collision_settings = screens.resolve_collision_settings(selected_obj) if selected_obj["kind"] == "personnage": personnage_data = screens.resolve_personnage_data(selected_obj) personnage_commands = screens.resolve_personnage_commands(selected_obj) personnage_role = screens.resolve_personnage_role(selected_obj) - elif selected_obj["kind"] == "dialogue_box": + elif selected_obj["kind"] in _UI_WIDGET_KINDS: dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj) _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} @@ -56,7 +57,7 @@ def render_scene_edit(slug, screen_id, game): # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). collision_rule_objects = [] for o in objects: - if o["kind"] in ("fond", "dialogue_box"): + if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"): continue role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None if role == "joueur": diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 534fa6a0..327d16e9 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -58,10 +58,10 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): if o["kind"] == "personnage": o["personnage_commandes"] = resolve_personnage_commands(o) o["personnage_role"] = resolve_personnage_role(o) - if o["kind"] == "dialogue_box": - # Widget d'interface (voir "🖥️ Interface", + if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"): + # Widgets d'interface (voir "🖥️ Interface", # screens/rendering/dialogue_box_style.py) : ni - # collision, ni cible/source de règle — juste son + # 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) continue diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index abf56f16..f53d12df 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -5,11 +5,14 @@ import db from .ensure_scene_schema import ensure_scene_schema _DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} -_KINDS = {"personnage", "decor", "fond", "dialogue_box"} -# Widget d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) : -# assez grand pour lire une réplique sans redimensionner, jamais confondu -# visuellement avec un personnage/décor (128×128 par défaut). +_KINDS = {"personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"} +# Widgets d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) : +# assez grands pour lire une réplique/question sans redimensionner, +# jamais confondus visuellement avec un personnage/décor (128×128 par +# défaut). "score_widget" reste petit — juste un nombre. _DIALOGUE_BOX_DEFAULT_SIZE = (320, 150) +_QUIZ_BOX_DEFAULT_SIZE = (340, 240) +_SCORE_WIDGET_DEFAULT_SIZE = (140, 50) def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None): @@ -46,6 +49,10 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba width, height = background["width"], background["height"] elif kind == "dialogue_box": width, height = _DIALOGUE_BOX_DEFAULT_SIZE + elif kind == "quiz_box": + width, height = _QUIZ_BOX_DEFAULT_SIZE + elif kind == "score_widget": + width, height = _SCORE_WIDGET_DEFAULT_SIZE conn = db.connect(slug) if kind == "fond": z_index = -1000 diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index aa649020..74f85ee9 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -65,6 +65,50 @@ def render_scene_object(obj): f'' f'' ) + 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 = 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. + 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'
' + f'
La question s\'affiche ici.
' + f'
' + f'' + f'' + f'
' + ) + if obj["kind"] == "score_widget": + # Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une + # fois posé (pas de collision/déclencheur, contrairement aux deux + # widgets ci-dessus) : affiche en continu le score de la partie en + # cours (voir static/js/play/dialogue-box-controller.js:: + # forgeUpdateAllScoreWidgets). Même style que les deux autres + # widgets, header_bg sert de fond unique (pas de zone body/footer). + 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'])}; " + f"background:{html_lib.escape(style['header_bg'])};" + ) + return ( + f'
' + f'🏆 0' + f'
' + ) if obj["kind"] == "personnage": src = resolve_personnage_idle_frame(obj) src_attr = f' src="{html_lib.escape(src)}"' if src else "" diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index c9b899d0..b76269d4 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -11,6 +11,7 @@ global.gameData = {}; const { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, + forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets, } = require('../dialogue-box-controller.js'); function fakeWidget(objectId) { @@ -154,3 +155,126 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet' assert.doesNotThrow(() => forgeDialogueBoxAdvance(999)); } finally { global.document = previousDoc; } }); + +// ---------- Enchaînement dialogue + question ("❓ Question") ---------- + +function fakeQuizWidget(objectId) { + const roles = { header: { textContent: '' }, choices: { innerHTML: '' } }; + const classes = new Set(); + 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="choices"]') return roles.choices; + return null; + }, + _roles: roles, _classes: classes, + }; +} + +function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { + return { + querySelector(sel) { + if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null; + if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null; // forgeQuizBoxWidgetById + if (sel.indexOf('.dialogueBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return dialogueWidget; + if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById + return null; + }, + querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests + }; +} + +test.beforeEach(() => { + global.gameData.quests.push({ + id: 10, status: 'en_cours', + dialogues: { + nouvelle: [], + en_cours: [ + { type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' }, + { type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 }, + { type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' }, + ], + terminee: [], + }, + }); + window.forgeQuizScore = 0; +}); + +test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte de dialogue', () => { + const dialogueWidget = fakeWidget(42); + dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox(10); + assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal + + 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.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', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + 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; } +}); + +test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + 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._classes.has('is-active'), false); + assert.equal(dialogueWidget._classes.has('is-active'), true); + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); + + 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; } +}); + +test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => { + const dialogueWidget = fakeWidget(42); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz + try { + forgeShowDialogueBox(10); + forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante + assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse + } finally { global.document = previousDoc; } +}); + +test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => { + const values = [{ textContent: '' }, { textContent: '' }]; + const previousDoc = global.document; + window.forgeQuizScore = 42; + global.document = { querySelectorAll: () => values }; + try { + forgeUpdateAllScoreWidgets(); + assert.equal(values[0].textContent, 42); + assert.equal(values[1].textContent, 42); + } finally { global.document = previousDoc; } +}); diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 79732c14..2fd6d1b6 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -1,32 +1,57 @@ -// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ---------- +// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ---------- // Fait le lien entre l'éditeur de collision (action "quete", voir -// static/js/play/collision-rules-controller.js) et le widget d'interface -// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, +// static/js/play/collision-rules-controller.js) et les widgets d'interface +// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, // screens/rendering/dialogue_box_style.py) : à l'exécution d'une action -// "quete", affiche la réplique en cours du dialogue correspondant au -// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py), -// avance d'une réplique à chaque clic sur "Suivant". +// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la +// quête (gameData.quests, voir full_game_payload.py) ligne par ligne — +// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton +// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir +// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si +// posée sur la scène — sinon ignorée silencieusement, la conversation +// avance sans elle) : cliquer la BONNE réponse crédite le score du +// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la +// question reste affichée (le joueur peut réessayer). +// +// Une fois le dialogue "nouvelle" épuisé : la boîte bascule en écran +// d'OFFRE ("Quête : " / objectif / boutons Accepter-Refuser) au +// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser +// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle", +// la prochaine interaction rejoue exactement le même dialogue). Une fois +// le dialogue "en_cours" épuisé (donc, s'il contenait des questions, +// toutes répondues correctement), la quête passe "terminee" — le joueur +// peut alors quitter la scène normalement (aucun blocage, ces widgets ne +// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours +// (gameData.quests n'est qu'un instantané par joueur, voir +// full_game_payload.py) : jamais persisté en base, qui garde le statut +// de DÉPART pour toute nouvelle partie. +// +// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié +// à ce système de quête/quiz — distinct du score "classique" du moteur +// document (db/scoring/, action de flow "Modifier un score"), qui +// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors +// de propos pour ce sous-système entièrement client (voir +// collision-rules-controller.js : "Pur JS client, sans le moindre appel +// serveur"). // -// Une fois le dialogue épuisé : si la quête est encore "nouvelle", la -// boîte bascule en écran d'OFFRE ("Quête : " / objectif / boutons -// Accepter-Refuser) au lieu de se masquer — accepter fait passer la -// quête "en_cours" (LOCAL à cette partie, jamais persisté en base : -// gameData.quests n'est qu'un instantané par joueur, voir -// full_game_payload.py ; muter la ligne en base changerait le statut de -// DÉPART de la quête pour toutes les parties futures) ; refuser ferme -// simplement la boîte SANS changer le statut — la quête reste "nouvelle", -// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis -// le début (impossible d'avancer sans accepter). Pour tout autre statut -// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin. // Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a -// posé aucun widget sur l'écran — même esprit que le reste du moteur. +// posé aucun widget requis sur l'écran — même esprit que le reste du moteur. var forgeDialogueBoxState = {}; +var forgeQuizBoxState = {}; +window.forgeQuizScore = window.forgeQuizScore || 0; function forgeQuestById(questId) { return (gameData.quests || []).find(function (q) { return q.id === questId; }); } +function forgeEscapeHtml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ---------- Boîte de dialogue ---------- + function forgeDialogueBoxWidgetOnCurrentScreen() { return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget'); } @@ -55,6 +80,57 @@ function forgeResetDialogueBoxFooter(widget, objectId) { if (footer) footer.innerHTML = ''; } +// ---------- Boîte à quiz ---------- + +function forgeQuizBoxWidgetOnCurrentScreen() { + return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget'); +} + +function forgeQuizBoxWidgetById(objectId) { + return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]'); +} + +function forgeHideQuizBoxOnCurrentScreen() { + var widget = forgeQuizBoxWidgetOnCurrentScreen(); + if (widget) widget.classList.remove('is-active'); +} + +// ---------- Score ---------- + +function forgeUpdateAllScoreWidgets() { + var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]'); + for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore; +} + +function forgeAddQuizScore(amount) { + window.forgeQuizScore += (amount || 0); + forgeUpdateAllScoreWidgets(); +} + +// ---------- Enchaînement dialogue/question ---------- + +// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId` +// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de +// dialogue, une ligne "question" dans la boîte à quiz (voir +// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz +// n'est posée : impossible de bloquer la conversation indéfiniment). +function forgeShowCurrentConversationLine(dialogueObjectId) { + var state = forgeDialogueBoxState[dialogueObjectId]; + if (!state) return; + var line = state.lines[state.index]; + var widget = forgeDialogueBoxWidgetById(dialogueObjectId); + if (line.type === 'question') { + if (widget) widget.classList.remove('is-active'); + forgeShowQuizBox(dialogueObjectId, line); + return; + } + forgeHideQuizBoxOnCurrentScreen(); + if (!widget) return; + forgeResetDialogueBoxFooter(widget, dialogueObjectId); + forgeRenderDialogueBoxLine(widget, line); + widget.classList.add('is-active'); +} + // Appelée par l'action "quete" (collision-rules-controller.js) — trouve // le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois, // comme le reste du moteur 2D). @@ -65,39 +141,95 @@ function forgeShowDialogueBox(questId) { var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); - // Aucune réplique posée pour ce statut : une quête "nouvelle" propose + // Aucune ligne posée pour ce statut : une quête "nouvelle" propose // quand même directement l'offre (accepter/refuser), les autres // statuts restent silencieux (rien à montrer). if (!lines.length) { if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest); return; } - forgeResetDialogueBoxFooter(widget, objectId); forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; - forgeRenderDialogueBoxLine(widget, lines[0]); - widget.classList.add('is-active'); + forgeShowCurrentConversationLine(objectId); +} + +// Avance d'une ligne (fin de dialogue -> question, fin de question +// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par +// une bonne réponse au quiz (voir forgeQuizBoxAnswer). +function forgeAdvanceConversation(dialogueObjectId) { + var state = forgeDialogueBoxState[dialogueObjectId]; + if (!state) return; + state.index += 1; + if (state.index >= state.lines.length) { + forgeHideQuizBoxOnCurrentScreen(); + var widget = forgeDialogueBoxWidgetById(dialogueObjectId); + var quest = forgeQuestById(state.questId); + if (quest && quest.status === 'nouvelle') { + if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest); + return; + } + // Tout le dialogue "en_cours" épuisé (donc, s'il contenait des + // questions, toutes répondues correctement) -> quête terminée. Voir + // demande utilisateur : "quand le joueur a répondu à toutes les + // questions la quête passe au statut terminé". + if (quest && quest.status === 'en_cours') quest.status = 'terminee'; + if (widget) widget.classList.remove('is-active'); + delete forgeDialogueBoxState[dialogueObjectId]; + return; + } + forgeShowCurrentConversationLine(dialogueObjectId); } // Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir -// render_scene_object.py, onclick="forgeDialogueBoxAdvance()"). +// render_scene_object.py, onclick="forgeDialogueBoxAdvance()") — sans +// effet pendant une question en cours (le "Suivant" n'existe pas alors, +// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité). function forgeDialogueBoxAdvance(objectId) { var state = forgeDialogueBoxState[objectId]; - var widget = forgeDialogueBoxWidgetById(objectId); - if (!state || !widget) return; - state.index += 1; - if (state.index >= state.lines.length) { - var quest = forgeQuestById(state.questId); - if (quest && quest.status === 'nouvelle') { - forgeShowQuestOffer(widget, objectId, quest); - return; - } - widget.classList.remove('is-active'); - delete forgeDialogueBoxState[objectId]; - return; - } - forgeRenderDialogueBoxLine(widget, state.lines[state.index]); + if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return; + forgeAdvanceConversation(objectId); } +// 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) { + 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, + }; + var header = widget.querySelector('[data-quiz-role="header"]'); + var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); + if (header) header.textContent = line.text; + if (choicesEl) { + choicesEl.innerHTML = line.choices.map(function (choice, i) { + return ''; + }).join(''); + } + 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. +function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { + var state = forgeQuizBoxState[quizObjectId]; + if (!state) return; + if (choiceIndex !== state.correctIndex) return; + forgeAddQuizScore(state.rewardAmount); + var widget = forgeQuizBoxWidgetById(quizObjectId); + if (widget) widget.classList.remove('is-active'); + var dialogueObjectId = state.dialogueObjectId; + delete forgeQuizBoxState[quizObjectId]; + forgeAdvanceConversation(dialogueObjectId); +} + +// ---------- Offre de quête (Accepter/Refuser) ---------- + // Écran d'offre : header = "Quête : ", body = l'objectif, pied = // Accepter/Refuser au lieu du "Suivant" habituel. function forgeShowQuestOffer(widget, objectId, quest) { @@ -143,5 +275,7 @@ if (typeof module !== 'undefined' && module.exports) { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, + forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, + forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, }; } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index d4112e38..8637b9e3 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) { // se déplace lui-même. function forgeSolidObstacles(screenData, movingId) { return (screenData.elements || []).filter(function (o) { - return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false); + return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false); }); } diff --git a/static/js/play/screens.js b/static/js/play/screens.js index d7946cab..68dfedc6 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -57,6 +57,12 @@ function showScreen(id) { // static/js/play/collision-rules-controller.js) — démarré juste après // le contrôleur de personnage dont il dépend (position du joueur). forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; })); + // Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) : + // synchronise le compteur affiché sur CET écran avec le total déjà + // gagné — sans ça, un score posé sur un écran affiché APRÈS que le + // joueur ait déjà marqué des points ailleurs resterait figé à "0" + // (valeur d'exemple rendue côté serveur) jusqu'au prochain point gagné. + if (typeof forgeUpdateAllScoreWidgets === 'function') forgeUpdateAllScoreWidgets(); } // Phase 6 — musique de fond par écran (voir screen-bg-music-url dans diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 0f16fb83..3dffddf3 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -262,7 +262,7 @@ function questQuestionBubbleHtml(line, status, index) { '
' + '
' + '
' + - '' + '' + @@ -362,10 +362,15 @@ function questUpdateField(questId, field, value) { fetch('/game/' + GAME_SLUG + '/quests/' + questId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - .then(function (r) { return r.json(); }) - .then(function (json) { - if (!json.quest) return; - Object.assign(quest, json.quest); + .then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); }) + .then(function (res) { + // Refusé (400) : recompense_score abaissé sous le total déjà + // réparti entre les questions (voir routes/quests/quest_update.py) + // — message d'erreur demandé explicitement, et le champ est + // remis à sa valeur RÉELLE (inchangée côté serveur). + if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; } + if (!res.json.quest) return; + Object.assign(quest, res.json.quest); renderQuestListPage(); renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables }); @@ -385,7 +390,15 @@ function questUpdateField(questId, field, value) { function questSaveDialogues(quest) { fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }), - }); + }) + .then(function (r) { + // Refusé (400) : la somme des récompenses des questions dépasse la + // récompense totale de la quête (voir routes/quests/quest_dialogues.py) + // — message d'erreur demandé explicitement. La bulle en trop reste + // affichée localement (non enregistrée) : l'auteur baisse son + // montant ou celui d'une autre question pour repasser sous le budget. + if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); }); + }); } // Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de @@ -437,7 +450,7 @@ function questSetQuestionChoiceCount(status, index, value) { var quest = questById(_currentQuestModalId()); if (!quest) return; var line = quest.dialogues[status][index]; - var count = Math.max(2, Math.min(8, parseInt(value, 10) || 2)); + var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2)); while (line.choices.length < count) line.choices.push(''); while (line.choices.length > count) line.choices.pop(); if (line.correct_index >= line.choices.length) line.correct_index = 0; diff --git a/static/style.css b/static/style.css index 0ec232e7..d2a526ca 100644 --- a/static/style.css +++ b/static/style.css @@ -668,6 +668,34 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; } .dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); } +/* 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. */ +.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; } +.quizBoxHeader{ padding:8px 12px; font-weight:700; } +.quizBoxChoices{ padding:10px 12px; flex:1; overflow-y:auto; 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); } + +/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/ + déclencheur, contrairement aux deux widgets ci-dessus), voir + forgeUpdateAllScoreWidgets(). */ +.scoreWidget{ + display:flex; align-items:center; justify-content:center; gap:4px; + border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:none; +} + /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/ diff --git a/templates/play.html b/templates/play.html index 63c58335..b096ddc5 100644 --- a/templates/play.html +++ b/templates/play.html @@ -84,13 +84,13 @@ uniquement si un objet kind="fond" plus grand que la scène est posé (caméra qui suit le personnage). -->
- {% for obj in s.elements if obj.kind != 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %} + {% for obj in s.elements if obj.kind not in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
+ .sceneWorld ci-dessus (voir static/style.css) — "💬 Boîte de + dialogue"/"❓ Boîte à quiz"/"🏆 Score" (screens/rendering/dialogue_box_style.py). -->
- {% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %} + {% for obj in s.elements if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
{% else %} diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 871ebcbe..90aa00c9 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -17,7 +17,9 @@ .canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } /* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue" (screens/scenes/render_scene_object.py, un
, pas un ). */ - .canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } + .canvasElementInner > .dialogueBoxWidget, .canvasElementInner > .quizBoxWidget, .canvasElementInner > .scoreWidget{ + position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; + } {% endblock %} {% block content %} @@ -100,8 +102,10 @@
🖥️ Interface
- -

Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.

+ + + +

Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.

@@ -124,7 +128,7 @@ onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
{{ o.rendered_html|safe }}
- {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %} + {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %} + {% if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %} +
🎨 Style
@@ -373,7 +380,7 @@
{% endif %} - {% if obj.kind not in ('fond', 'dialogue_box') %} + {% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}