Éditeur de quêtes : onglet à côté de Collision, pas une page à part
Build and deploy / test-python (push) Successful in 6m7s
Build and deploy / test-js (push) Successful in 59s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Corrige la mauvaise interprétation de la demande initiale : la page
dédiée "🗺️ Quêtes" (nav du jeu) est retirée au profit d'un nouvel
onglet "🗺️ Quêtes" DANS l'éditeur de scène 2D, juste à côté de
"🧩 Collision" — même liste tabulaire que l'onglet "📣 Événements"
(titre/objectif/récompense/statut/résultat par ligne, pas des cartes),
un clic sur une ligne ouvrant la modale d'arbre de dialogue déjà
construite. L'assistant "+ Action" de l'éditeur de collision ouvre
toujours cette même modale pour "Déclencher une quête".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 20:46:27 +02:00
co-authored by Claude Sonnet 5
parent 51427b5297
commit 78c1aba8a0
10 changed files with 72 additions and 92 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ from .get_quest import get_quest
def create_quest(slug, title="Nouvelle quête"): def create_quest(slug, title="Nouvelle quête"):
"""Titre par défaut plutôt qu'un refus — créée d'un clic depuis la """Titre par défaut plutôt qu'un refus — créée d'un clic depuis la
liste (voir templates/quests.html) ou depuis l'assistant "+ Action" liste (voir l'onglet "🗺️ Quêtes", templates/scene_edit.html) ou depuis l'assistant "+ Action"
de l'éditeur de collision (voir collision-rules-editor.js), avant de l'éditeur de collision (voir collision-rules-editor.js), avant
même que l'auteur ait choisi un titre.""" même que l'auteur ait choisi un titre."""
ensure_quests_schema(slug) ensure_quests_schema(slug)
+1 -1
View File
@@ -1 +1 @@
from . import quests_view, quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
+3 -2
View File
@@ -9,6 +9,7 @@ from core.flask_app import app
def quests_list_json(slug): def quests_list_json(slug):
"""Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir """Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir
collision-rules-editor.js) pour lister les quêtes existantes sans collision-rules-editor.js) pour lister les quêtes existantes sans
quitter la modale déjà ouverte — même contenu que quests_view, en JSON quitter la modale déjà ouverte (l'onglet "🗺️ Quêtes" lui-même reçoit
seul plutôt qu'une page complète.""" déjà sa liste via QUESTS_JSON, voir routes/scenes/scene_edit_view.py —
cette route ne sert qu'à la RAFRAÎCHIR depuis le contexte collision)."""
return jsonify({"quests": db.list_quests(slug)}) return jsonify({"quests": db.list_quests(slug)})
-20
View File
@@ -1,20 +0,0 @@
import json
from flask import render_template
import db
from core.flask_app import app
@app.route("/game/<slug>/quests")
def quests_view(slug):
"""Éditeur de quêtes (voir db/quests/) — page dédiée game-wide, dans le
même esprit que game_dashboard.html : une liste de cartes + un bouton
"+ Nouvelle quête" au-dessus, chaque carte ouvrant une modale avec
l'arbre de dialogue (static/js/quests/quest-editor.js — même module
JS que celui rendu depuis l'assistant "+ Action" de l'éditeur de
collision, voir collision-rules-editor.js)."""
game = db.game_meta(slug)
quests = db.list_quests(slug)
return render_template("quests.html", game=game, quests_json=json.dumps(quests))
+44 -27
View File
@@ -1,56 +1,63 @@
// ---------- Éditeur de quêtes ---------- // ---------- Éditeur de quêtes ----------
// Module partagé entre templates/quests.html (page dédiée : liste de // Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html,
// cartes + "+ Nouvelle quête") et static/js/scenes/collision-rules-editor.js // jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet
// (l'assistant "+ Action" de l'éditeur de collision ouvre CETTE MÊME // "📣 Événements") plutôt qu'une grille de cartes, chaque ligne portant
// modale de dialogue quand l'auteur choisit "Déclencher une quête", // toutes les infos de la quête + un clic dessus ouvrant la modale
// plutôt qu'un simple champ texte pour un id). La modale (arbre de // d'arbre de dialogue. Cette même modale est aussi ouverte depuis
// dialogue) est créée dynamiquement (ensureQuestDialogueModal) plutôt que // l'assistant "+ Action" de l'éditeur de collision (voir
// dupliquée dans deux templates. // static/js/scenes/collision-rules-editor.js) quand l'auteur choisit
// "Déclencher une quête" — créée dynamiquement (ensureQuestDialogueModal)
// plutôt que dupliquée dans deux endroits.
// //
// Une quête (voir db/quests/) : id, title, objectif, recompense_score, // Une quête (voir db/quests/) : id, title, objectif, recompense_score,
// status (nouvelle/en_cours/terminee — statut COURANT, informatif), // status (nouvelle/en_cours/terminee — statut COURANT, informatif),
// resultat (reussi/echoue), dialogues (une liste de répliques PAR // resultat (reussi/echoue), dialogues (une liste de répliques PAR
// STATUT — 3 colonnes dans l'éditeur : quel dialogue jouer selon l'état // STATUT — 3 colonnes dans la modale : quel dialogue jouer selon l'état
// de la quête au moment où le joueur parle au PNJ). // de la quête au moment où le joueur parle au PNJ).
var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' }; var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' };
var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' }; var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' };
var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
// Quêtes connues côté client — préremplies par QUESTS_JSON (quests.html) // Quêtes connues côté client — préremplies par QUESTS_JSON (voir
// si présent, sinon vide tant qu'on n'a pas encore fetch (contexte // templates/scene_edit.html), toujours présentes puisque l'onglet
// éditeur de collision, voir questPickerEnsureLoaded). // Quêtes vit dans le même éditeur que l'onglet Collision qui en a
// besoin (collisionRuleActionSummary).
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
function questById(questId) { function questById(questId) {
return QUESTS.find(function (q) { return q.id === questId; }); return QUESTS.find(function (q) { return q.id === questId; });
} }
function questCardHtml(quest, i) { function questListRowHtml(quest) {
var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : ''; var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '<span class="hint">—</span>';
return ( return (
'<div class="questCard" style="animation-delay:' + (i * 50) + 'ms" onclick="openQuestDialogueModal(' + quest.id + ')">' + '<tr class="questListRow" onclick="openQuestDialogueModal(' + quest.id + ')">' +
'<div class="questCardHeader">' + '<td><strong>' + (quest.title || '').replace(/</g, '&lt;') + '</strong></td>' +
'<h3 class="questCardTitle">' + (quest.title || '').replace(/</g, '&lt;') + '</h3>' + '<td>' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<span class="hint">—</span>') + '</td>' +
'<span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span>' + '<td><span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span></td>' +
'</div>' + '<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
'<p class="questCardObjectif">' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<span class="hint">Aucun objectif défini.</span>') + '</p>' + '<td>' + resultatHtml + '</td>' +
'<div class="questCardFooter">' + '<td class="fieldsTableActions">' +
'<span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span>' + '<button type="button" class="button is-small is-danger" title="Supprimer" onclick="event.stopPropagation(); questDeleteFromList(' + quest.id + ')">🗑️</button>' +
resultatHtml + '</td>' +
'</div>' + '</tr>'
'</div>'
); );
} }
function renderQuestListPage() { function renderQuestListPage() {
var container = document.getElementById('questsGrid'); var container = document.getElementById('questsTabPanel');
if (!container) return; if (!container) return;
var wrap = document.getElementById('questsListWrap');
if (!QUESTS.length) { if (!QUESTS.length) {
container.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>'; wrap.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
return; return;
} }
container.innerHTML = QUESTS.map(questCardHtml).join(''); wrap.innerHTML =
'<div class="fieldsTableWrap"><table class="table is-fullwidth is-hoverable fieldsTable">' +
'<thead><tr><th>Titre</th><th>Objectif</th><th>Récompense</th><th>Statut</th><th>Résultat</th><th></th></tr></thead>' +
'<tbody>' + QUESTS.map(questListRowHtml).join('') + '</tbody>' +
'</table></div>';
} }
function createQuestFromListPage() { function createQuestFromListPage() {
@@ -65,6 +72,16 @@ function createQuestFromListPage() {
}); });
} }
function questDeleteFromList(questId) {
var quest = questById(questId);
if (!quest || !confirm('Supprimer la quête « ' + quest.title + ' » ? Toute règle de collision qui la référence perdra sa cible.')) return;
fetch('/game/' + GAME_SLUG + '/quests/' + questId + '/delete', { method: 'POST' })
.then(function () {
QUESTS = QUESTS.filter(function (q) { return q.id !== questId; });
renderQuestListPage();
});
}
// ---------- Modale "arbre de dialogue" ---------- // ---------- Modale "arbre de dialogue" ----------
// Créée une seule fois (au premier appel), réutilisée ensuite — partagée // Créée une seule fois (au premier appel), réutilisée ensuite — partagée
// entre la page dédiée et l'assistant "+ Action" de l'éditeur de // entre la page dédiée et l'assistant "+ Action" de l'éditeur de
+4 -13
View File
@@ -760,20 +760,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } .collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; }
/* ---- Éditeur de quêtes (templates/quests.html + modale de dialogue /* ---- Éditeur de quêtes (onglet "🗺️ Quêtes" à côté de "🧩 Collision",
partagée avec l'assistant "+ Action" de l'éditeur de collision, voir templates/scene_edit.html + modale de dialogue partagée avec
l'assistant "+ Action" de l'éditeur de collision, voir
static/js/quests/quest-editor.js) ---- */ static/js/quests/quest-editor.js) ---- */
.questsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:18px; margin-top:14px; } .questListRow{ cursor:pointer; }
.questCard{
background:linear-gradient(165deg, var(--panel), var(--panel2)); border:1px solid var(--border);
border-radius:16px; padding:16px; cursor:pointer; box-shadow:0 6px 18px rgba(0,0,0,.18);
animation:collisionCardIn .35s ease backwards; transition:transform .18s ease, box-shadow .18s ease;
}
.questCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); border-color:var(--accent); }
.questCardHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
.questCardTitle{ margin:0; font-size:15px; font-weight:700; }
.questCardObjectif{ font-size:12.5px; color:var(--forge-text-muted, #8b95a7); margin-bottom:12px; min-height:1.4em; }
.questCardFooter{ display:flex; gap:8px; flex-wrap:wrap; }
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; } .questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; } .questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
.questPill-resultat{ background:rgba(23,162,184,.18); color:#17a2b8; } .questPill-resultat{ background:rgba(23,162,184,.18); color:#17a2b8; }
-1
View File
@@ -53,7 +53,6 @@
une version simplifiée (juste les écrans) pour un type "restreint" une version simplifiée (juste les écrans) pour un type "restreint"
(quiz/embranchement/rpg), la version complète pour "custom". --> (quiz/embranchement/rpg), la version complète pour "custom". -->
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a> <a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a>
<a href="{{ url_for('quests_view', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug ~ '/quests' else '' }}" title="Quêtes (objectif, récompense, statut, dialogue)">🗺️ Quêtes</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a> <a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
<!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet <!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
STATIQUE (aucun Python/Flask embarqué, voir STATIQUE (aucun Python/Flask embarqué, voir
-17
View File
@@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block title %}Quêtes — {{ game.name }}{% endblock %}
{% block content %}
<div class="dashPanelHeader" style="align-items:center; justify-content:space-between;">
<h2>🗺️ Quêtes</h2>
<button type="button" class="collisionRuleAddBtn" onclick="createQuestFromListPage()">✚ Nouvelle quête</button>
</div>
<p class="hint">Une quête : un objectif, une récompense (score), un statut, un résultat — et un dialogue par statut, à écrire en cliquant sur sa carte.</p>
<div id="questsGrid" class="questsGrid"></div>
<script>
var QUESTS_JSON = {{ quests_json|safe }};
var GAME_SLUG = "{{ game.slug }}";
</script>
<script src="{{ url_for('static', filename='js/quests/quest-editor.js') }}"></script>
{% endblock %}
+17
View File
@@ -94,6 +94,7 @@
<div class="builderTabs" id="builderTabs"> <div class="builderTabs" id="builderTabs">
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button> <button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button>
<button type="button" class="builderTabBtn" data-tab="collision-rules" onclick="switchBuilderTab('collision-rules')">🧩 Collision</button> <button type="button" class="builderTabBtn" data-tab="collision-rules" onclick="switchBuilderTab('collision-rules')">🧩 Collision</button>
<button type="button" class="builderTabBtn" data-tab="quests" onclick="switchBuilderTab('quests')">🗺️ Quêtes</button>
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button> <button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
</div> </div>
@@ -161,6 +162,22 @@
</div> </div>
</div> </div>
<!-- Éditeur de quêtes (voir db/quests/) : une LISTE (comme l'onglet
Événements juste après), pas une grille de cartes — chaque ligne
résume titre/objectif/récompense/statut/résultat, cliquer dessus
ouvre la modale d'arbre de dialogue (static/js/quests/
quest-editor.js, partagée avec l'assistant "+ Action" de l'onglet
Collision). Rendu ENTIÈREMENT en JS depuis QUESTS_JSON, même
convention que #collisionRulesCards ci-dessus. -->
<div class="builderTabPanel" id="questsTabPanel" data-tab-panel="quests">
<div class="dashPanelHeader">
<h2>🗺️ Quêtes</h2>
<button type="button" class="button is-small primary" onclick="createQuestFromListPage()">+ Créer une quête</button>
</div>
<p class="hint">Titre, objectif, récompense, statut, résultat — clique sur une ligne pour écrire son enchaînement de dialogue.</p>
<div id="questsListWrap"></div>
</div>
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events"> <div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
<div class="dashPanelHeader"> <div class="dashPanelHeader">
<h2>Événements personnalisés</h2> <h2>Événements personnalisés</h2>
+2 -10
View File
@@ -1,5 +1,5 @@
"""Éditeur de quêtes (voir db/quests/, routes/quests/, nouveau lien """Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes"
"🗺️ Quêtes" dans templates/base.html) : id, titre, objectif, récompense de l'éditeur de scène 2D, templates/scene_edit.html) : id, titre, objectif, récompense
(score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue), (score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue),
et un dialogue par statut (3 colonnes — voir static/js/quests/ et un dialogue par statut (3 colonnes — voir static/js/quests/
quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le
@@ -108,14 +108,6 @@ def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanu
# ---------- Routes ---------- # ---------- Routes ----------
def test_quests_view_route_renders_the_list(client, tmp_game_slug_cleanup):
slug = _make_game(tmp_game_slug_cleanup)
db.create_quest(slug, "Trouver la clé")
resp = client.get(f"/game/{slug}/quests")
assert resp.status_code == 200
assert b"Trouver la cl" in resp.data
def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup): def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup):
slug = _make_game(tmp_game_slug_cleanup) slug = _make_game(tmp_game_slug_cleanup)
resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json") resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json")