Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
+2
-14
@@ -84,17 +84,7 @@ from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
|
||||
from .quests.constants import (
|
||||
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
|
||||
)
|
||||
from .quests.list_quests import list_quests
|
||||
from .quests.get_quest import get_quest
|
||||
from .quests.create_quest import create_quest
|
||||
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
|
||||
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
|
||||
|
||||
__all__ = [
|
||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||
@@ -113,7 +103,5 @@ __all__ = [
|
||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||
"delete_global_variable_by_id",
|
||||
"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", "quest_dialogues_total_reward",
|
||||
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from .constants import QUEST_STATUS_CHOICES
|
||||
|
||||
_MAX_LINES_PER_COLUMN = 200
|
||||
_MAX_SPEAKER_LENGTH = 60
|
||||
|
||||
# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) :
|
||||
# Question à choix (voir "❓ Question", static/js/triggers/trigger-editor.js) :
|
||||
# une bulle jaune posée dans le même enchaînement qu'une réplique de
|
||||
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
|
||||
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
|
||||
@@ -61,22 +59,31 @@ def _sanitize_line(raw_line):
|
||||
return _sanitize_dialogue_line(raw_line)
|
||||
|
||||
|
||||
def sanitize_quest_dialogues(raw_dialogues):
|
||||
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
|
||||
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
|
||||
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
|
||||
voir screens/rendering/scene_object_names.py) ET/OU de questions à
|
||||
choix ({type: "question", text, choices, correct_index, reward_type,
|
||||
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit
|
||||
que resolve_collision_rules.sanitize_collision_rules : rejette tout
|
||||
élément invalide plutôt que de lever."""
|
||||
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
||||
if not isinstance(raw_dialogues, dict):
|
||||
return result
|
||||
for status in QUEST_STATUS_CHOICES:
|
||||
raw_lines = raw_dialogues.get(status)
|
||||
def sanitize_dialogue_lines(raw_lines):
|
||||
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
|
||||
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
|
||||
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
|
||||
ET/OU de questions à choix ({type: "question", text, choices,
|
||||
correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
|
||||
ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
|
||||
rejette tout élément invalide plutôt que de lever. Les lignes d'UN
|
||||
dialogue embarqué dans une action "dialogue" (voir
|
||||
screens/rendering/collision_rules.py) : chaque occurrence de
|
||||
"Déclencher ce dialogue" porte le sien."""
|
||||
if not isinstance(raw_lines, list):
|
||||
continue
|
||||
return []
|
||||
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
|
||||
result[status] = [line for line in cleaned if line]
|
||||
return result
|
||||
return [line for line in cleaned if line]
|
||||
|
||||
|
||||
def sum_question_rewards(lines):
|
||||
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
|
||||
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
|
||||
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
|
||||
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
|
||||
publish/build_scorm_package.py)."""
|
||||
total = 0
|
||||
for line in (lines or []):
|
||||
if isinstance(line, dict) and line.get("type") == "question":
|
||||
total += line.get("reward_amount", 0) or 0
|
||||
return total
|
||||
@@ -1,4 +0,0 @@
|
||||
QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
|
||||
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
||||
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
||||
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
||||
@@ -1,18 +0,0 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
|
||||
|
||||
def create_quest(slug, title="Nouvelle quête"):
|
||||
"""Titre par défaut plutôt qu'un refus — créée d'un clic depuis la
|
||||
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
|
||||
même que l'auteur ait choisi un titre."""
|
||||
ensure_quests_schema(slug)
|
||||
title = (title or "").strip() or "Nouvelle quête"
|
||||
conn = connect(slug)
|
||||
conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,))
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_quest(slug, new_id)
|
||||
@@ -1,10 +0,0 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
|
||||
|
||||
def delete_quest(slug, quest_id):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,29 +0,0 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_quests_schema(slug):
|
||||
"""Migration légère (même principe que ensure_custom_events_schema.py) :
|
||||
crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran),
|
||||
référencée par id technique depuis l'action "quete" d'une règle de
|
||||
collision (voir screens/rendering/collision_rules.py, quete_id). Les
|
||||
dialogues (voir static/js/quests/quest-editor.js — 3 colonnes
|
||||
nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à
|
||||
part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en
|
||||
bloc pour UNE quête à la fois (même convention que _scene_objects.attributes)."""
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS _quests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
objectif TEXT DEFAULT '',
|
||||
recompense_score INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'nouvelle',
|
||||
resultat TEXT,
|
||||
dialogues_json TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,11 +0,0 @@
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .list_quests import _row_to_quest
|
||||
|
||||
|
||||
def get_quest(slug, quest_id):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone()
|
||||
conn.close()
|
||||
return _row_to_quest(row) if row else None
|
||||
@@ -1,22 +0,0 @@
|
||||
import json
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
|
||||
|
||||
def _row_to_quest(row):
|
||||
quest = dict(row)
|
||||
try:
|
||||
quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}")
|
||||
except (TypeError, ValueError):
|
||||
quest["dialogues"] = {}
|
||||
quest.pop("dialogues_json", None)
|
||||
return quest
|
||||
|
||||
|
||||
def list_quests(slug):
|
||||
ensure_quests_schema(slug)
|
||||
conn = connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall()
|
||||
conn.close()
|
||||
return [_row_to_quest(r) for r in rows]
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
import json
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
from .sanitize_quest_dialogues import sanitize_quest_dialogues
|
||||
|
||||
|
||||
def set_quest_dialogues(slug, quest_id, raw_dialogues):
|
||||
"""Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un
|
||||
bloc — même convention "replace-all" que set_scene_object_collision_rules.py,
|
||||
jamais une réplique isolée."""
|
||||
ensure_quests_schema(slug)
|
||||
if get_quest(slug, quest_id) is None:
|
||||
return None
|
||||
dialogues = sanitize_quest_dialogues(raw_dialogues)
|
||||
conn = connect(slug)
|
||||
conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return dialogues
|
||||
@@ -1,42 +0,0 @@
|
||||
from ..connection import connect
|
||||
from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES
|
||||
from .ensure_quests_schema import ensure_quests_schema
|
||||
from .get_quest import get_quest
|
||||
|
||||
|
||||
def update_quest(slug, quest_id, fields):
|
||||
"""Met à jour uniquement les clés reconnues et valides de `fields` —
|
||||
ignore tout le reste plutôt que de lever, même esprit que
|
||||
sanitize_collision_rules (jamais confiance dans ce qu'envoie le
|
||||
client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide)."""
|
||||
ensure_quests_schema(slug)
|
||||
if get_quest(slug, quest_id) is None:
|
||||
return None
|
||||
updates = {}
|
||||
if "title" in fields:
|
||||
title = (fields["title"] or "").strip()
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if "objectif" in fields:
|
||||
updates["objectif"] = (fields["objectif"] or "").strip()
|
||||
if "recompense_score" in fields:
|
||||
try:
|
||||
updates["recompense_score"] = int(fields["recompense_score"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES:
|
||||
updates["status"] = fields["status"]
|
||||
if "resultat" in fields:
|
||||
resultat = fields["resultat"]
|
||||
if resultat in QUEST_RESULT_CHOICES:
|
||||
updates["resultat"] = resultat
|
||||
elif resultat is None:
|
||||
updates["resultat"] = None
|
||||
if not updates:
|
||||
return get_quest(slug, quest_id)
|
||||
conn = connect(slug)
|
||||
set_clause = ", ".join(f"{key} = ?" for key in updates)
|
||||
conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return get_quest(slug, quest_id)
|
||||
@@ -277,17 +277,18 @@ def build_scorm_zip(slug):
|
||||
else "static/js/play/offline/scorm-api.js"
|
||||
)
|
||||
|
||||
# Score max possible (voir db/quests/reward_budget.py) : sans lui, un
|
||||
# LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de points brut,
|
||||
# sans barème) et l'affiche à tort avec un "%" — demande utilisateur
|
||||
# "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, c'est un
|
||||
# défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant aussi
|
||||
# min/max (voir static/js/play/offline/scorm-api.js), le LMS peut
|
||||
# Score max possible (voir db/dialogue_lines.py::sum_question_rewards) :
|
||||
# sans lui, un LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de
|
||||
# points brut, sans barème) et l'affiche à tort avec un "%" — demande
|
||||
# utilisateur "5% pour 1.2 mais 100% pour 2004 avec 1 seule question,
|
||||
# c'est un défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant
|
||||
# aussi min/max (voir static/js/play/offline/scorm-api.js), le LMS peut
|
||||
# calculer raw/max lui-même, un vrai pourcentage au lieu du score brut
|
||||
# mal étiqueté.
|
||||
max_quiz_score = sum(
|
||||
db.quest_dialogues_total_reward(quest["dialogues"]) for quest in db.list_quests(slug)
|
||||
)
|
||||
# mal étiqueté. Les dialogues vivent dans les règles de collision des
|
||||
# objets de scène (voir screens/rendering/collision_rules.py::
|
||||
# "dialogue".dialogue), retrouvés via screens.collect_all_dialogue_lines.
|
||||
all_dialogue_lines = [line for lines in screens.collect_all_dialogue_lines(slug) for line in lines]
|
||||
max_quiz_score = db.sum_question_rewards(all_dialogue_lines)
|
||||
|
||||
# gameData.icon_data_uris (voir forgeRenderIcone,
|
||||
# static/js/play/offline/render-special-widgets.js) : ajouté au
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers
|
||||
|
||||
@@ -7,12 +7,13 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/new", methods=["POST"])
|
||||
def create_global_var(slug):
|
||||
"""Depuis la page de gestion des variables (game_variables.html) —
|
||||
idempotent par nom (voir db.create_global_variable), donc sans risque en
|
||||
cas de double soumission."""
|
||||
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
|
||||
— idempotent par nom (voir db.create_global_variable), donc sans
|
||||
risque en cas de double soumission."""
|
||||
name = (request.form.get("name") or "").strip()
|
||||
var_type = request.form.get("type") or "texte"
|
||||
default_value = request.form.get("default_value") or ""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
|
||||
# explicitement rend la variable partagée par tous les joueurs, voir
|
||||
# db/global_vars/create_global_variable.py) — absent du formulaire
|
||||
@@ -20,4 +21,6 @@ def create_global_var(slug):
|
||||
per_player = request.form.get("per_player", "1") == "1"
|
||||
if name:
|
||||
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import redirect, url_for
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,5 +7,8 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||
def global_var_delete(slug, var_id):
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.delete_global_variable_by_id(slug, var_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -9,5 +9,8 @@ from core.flask_app import app
|
||||
def global_var_edit(slug, var_id):
|
||||
var_type = request.form.get("type") or "texte"
|
||||
value = request.form.get("value") or ""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.update_global_variable(slug, var_id, var_type, value)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
|
||||
@@ -1,11 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>/delete", methods=["POST"])
|
||||
def quest_delete(slug, quest_id):
|
||||
db.delete_quest(slug, quest_id)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,34 +0,0 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>/dialogues", methods=["POST"])
|
||||
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.
|
||||
|
||||
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})
|
||||
@@ -1,12 +0,0 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/new", methods=["POST"])
|
||||
def quest_new(slug):
|
||||
body = request.get_json(silent=True) or {}
|
||||
quest = db.create_quest(slug, body.get("title"))
|
||||
return jsonify({"quest": quest})
|
||||
@@ -1,36 +0,0 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/<int:quest_id>", 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
|
||||
return jsonify({"quest": quest})
|
||||
@@ -1,15 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/quests/list.json")
|
||||
def quests_list_json(slug):
|
||||
"""Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir
|
||||
collision-rules-editor.js) pour lister les quêtes existantes sans
|
||||
quitter la modale déjà ouverte (l'onglet "🗺️ Quêtes" lui-même reçoit
|
||||
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)})
|
||||
@@ -6,9 +6,7 @@ from . import (
|
||||
scene_object_commands,
|
||||
scene_object_role,
|
||||
scene_object_collision,
|
||||
scene_object_collision_rules,
|
||||
scene_object_name,
|
||||
scene_object_dialogue_box_style,
|
||||
scene_collision_rule_objects,
|
||||
scene_object_names_list,
|
||||
)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
|
||||
def scene_collision_rule_objects(slug, screen_id):
|
||||
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
|
||||
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
|
||||
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
|
||||
scene-editor.js) qui peut faire apparaître/disparaître un objet de
|
||||
cette liste (rôle passé à/depuis "joueur", voir
|
||||
build_collision_rule_objects) ou changer son libellé affiché (nom).
|
||||
Recalculée entièrement plutôt que diffée : la liste reste petite (un
|
||||
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
|
||||
fonction est déjà appelée à chaque rendu complet de la page."""
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
return jsonify(build_collision_rule_objects(objects))
|
||||
@@ -5,8 +5,6 @@ from flask import render_template, request, abort, g
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
def render_scene_edit(slug, screen_id, game):
|
||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
||||
@@ -63,12 +61,6 @@ def render_scene_edit(slug, screen_id, game):
|
||||
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
|
||||
}
|
||||
|
||||
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
|
||||
# collision_rule_objects.py, factorisé pour être réutilisé par
|
||||
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
|
||||
# suppression/changement de rôle ou de nom sans recharger la page).
|
||||
collision_rule_objects = build_collision_rule_objects(objects)
|
||||
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||
@@ -125,24 +117,15 @@ def render_scene_edit(slug, screen_id, game):
|
||||
flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view,
|
||||
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
||||
animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
|
||||
global_variables=global_variables,
|
||||
global_variables=global_variables, global_variable_types=db.GLOBAL_VARIABLE_TYPES,
|
||||
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||
custom_events_map_json=json.dumps(custom_events_map),
|
||||
definitions=db.list_definitions(slug),
|
||||
definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}),
|
||||
definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}),
|
||||
collision_rule_objects_json=json.dumps(collision_rule_objects),
|
||||
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES,
|
||||
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX,
|
||||
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que
|
||||
# collisionRuleActionSummary (collision-rules-editor.js) affiche
|
||||
# tout de suite le TITRE de la quête référencée par une règle
|
||||
# existante, sans attendre l'ouverture de l'assistant "+ Action"
|
||||
# (seul moment où questPickerHtml irait la chercher sinon).
|
||||
quests_json=json.dumps(db.list_quests(slug)),
|
||||
# "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations",
|
||||
# static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE
|
||||
# QUEL objet de scène (personnage, décor, fond), toutes scènes du
|
||||
# jeu confondues.
|
||||
# "qui parle" dans un dialogue de déclencheur (voir "ℹ️
|
||||
# Informations", static/js/triggers/trigger-editor.js) : les noms
|
||||
# donnés à N'IMPORTE QUEL objet de scène (personnage, décor,
|
||||
# fond), toutes scènes du jeu confondues.
|
||||
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/collision-rules", methods=["POST"])
|
||||
def scene_object_collision_rules(slug, object_id):
|
||||
"""Liste complète des règles "déclencheur -> action" d'un objet (voir
|
||||
"🧩 Collision" dans templates/scene_edit.html) — le client poste
|
||||
toujours la liste ENTIÈRE (ajout/suppression déjà appliqués côté
|
||||
client), jamais une règle isolée, même patron que
|
||||
scene_object_commands.py pour "extra"."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if not obj:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
rules = screens.sanitize_collision_rules(body.get("rules"))
|
||||
screens.set_scene_object_collision_rules(slug, object_id, rules)
|
||||
return jsonify({"ok": True, "rules": rules})
|
||||
@@ -9,12 +9,13 @@ from core.flask_app import app
|
||||
def scene_object_names_list(slug):
|
||||
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
||||
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
||||
templates/scene_edit.html, questSpeakerOptions() dans static/js/
|
||||
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
|
||||
chargement complet de la page (HORS de #builder3, jamais rafraîchie
|
||||
par une sélection d'objet sans rechargement, voir goToSelected()) :
|
||||
sans cet endpoint, renommer un personnage puis rouvrir la modale de
|
||||
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
|
||||
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
|
||||
temps — appelée en AJAX depuis saveSceneObjectName()."""
|
||||
templates/scene_edit.html, triggerSpeakerOptions() dans static/js/
|
||||
triggers/trigger-editor.js) — cette variable n'est posée qu'une fois
|
||||
au chargement complet de la page (HORS de #builder3, jamais
|
||||
rafraîchie par une sélection d'objet sans rechargement, voir
|
||||
goToSelected()) : sans cet endpoint, renommer un personnage puis
|
||||
rouvrir un dialogue de déclencheur dans la MÊME session ne faisait
|
||||
jamais apparaître son nouveau nom, tant qu'aucun vrai rechargement de
|
||||
page n'avait eu lieu entre temps — appelée en AJAX depuis
|
||||
saveSceneObjectName()."""
|
||||
return jsonify(screens.list_named_scene_object_names(slug))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import triggers
|
||||
@@ -0,0 +1,112 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/objects-catalog", methods=["GET"])
|
||||
def triggers_objects_catalog(slug):
|
||||
"""Catalogue des objets de scène éligibles à un déclencheur, pour la
|
||||
grille "choisis un objet" de la modale d'ajout (voir
|
||||
screens.list_all_collision_objects)."""
|
||||
return jsonify(screens.list_all_collision_objects(slug))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers", methods=["GET"])
|
||||
def triggers_get(slug):
|
||||
"""TOUS les déclencheurs du jeu (voir screens.list_all_triggers) — plus
|
||||
de recherche inversée par quête, la logique est portée directement par
|
||||
chaque déclencheur."""
|
||||
return jsonify(screens.list_all_triggers(slug))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers", methods=["POST"])
|
||||
def triggers_add(slug):
|
||||
"""Ajoute un déclencheur sur l'objet choisi — body {object_id, rule},
|
||||
`rule` construite par l'assistant "déclencheur -> action" (voir
|
||||
openTriggerWizard, static/js/scenes/collision-rules-editor.js) :
|
||||
n'importe quel type de déclencheur/action qu'il propose passe donc
|
||||
déjà tel quel, sans changement à faire ici si de nouveaux types
|
||||
apparaissent un jour. Même contrat "liste ENTIÈRE" que l'éditeur de
|
||||
collision (routes/scenes/scene_object_collision_rules.py) : on
|
||||
recharge les règles existantes de l'objet, on y ajoute la nouvelle, on
|
||||
sauvegarde la liste complète."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
object_id = int(body.get("object_id"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "object_id invalide"}), 400
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
new_rule = body.get("rule")
|
||||
if not isinstance(new_rule, dict):
|
||||
return jsonify({"error": "rule invalide"}), 400
|
||||
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
rules.append(new_rule)
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>", methods=["DELETE"])
|
||||
def triggers_delete(slug, object_id):
|
||||
"""Retire TOUTES les règles de collision de CET objet — sans quête
|
||||
pour distinguer "cette règle-ci" d'une autre, un objet ne porte de
|
||||
toute façon en pratique qu'un seul déclencheur à la fois (posé par
|
||||
l'assistant, jamais cumulé)."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
screens.set_scene_object_collision_rules(slug, object_id, [])
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>/dialogue", methods=["POST"])
|
||||
def trigger_dialogue_update(slug, object_id):
|
||||
"""Édition EN LIGNE des répliques d'UN dialogue précis (voir
|
||||
screens/rendering/collision_rules.py::"dialogue".dialogue) — body
|
||||
{dialogue_id, lines}. 404 si `dialogue_id` ne correspond à aucun
|
||||
dialogue de CET objet (règle supprimée entre-temps, référence
|
||||
orpheline)."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
dialogue_id = body.get("dialogue_id")
|
||||
if not isinstance(dialogue_id, str) or not dialogue_id.strip():
|
||||
return jsonify({"error": "dialogue_id invalide"}), 400
|
||||
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
if not screens.set_dialogue_lines_in_rules(rules, dialogue_id, body.get("lines")):
|
||||
return jsonify({"error": "dialogue introuvable"}), 404
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>/append-action", methods=["POST"])
|
||||
def trigger_append_action(slug, object_id):
|
||||
""""+ Ajouter une action" en bout de chaîne (voir screens/rendering/
|
||||
collision_rules.py::set_then_action_in_rules, static/js/triggers/
|
||||
trigger-editor.js) — body {after_id, action} : `after_id` est le
|
||||
dialogue.id (feuille "dialogue") ou id (feuille "variable") de la
|
||||
DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si
|
||||
cette feuille a déjà un "then" (jamais écrasé par cette route — un
|
||||
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
after_id = body.get("after_id")
|
||||
if not isinstance(after_id, str) or not after_id.strip():
|
||||
return jsonify({"error": "after_id invalide"}), 400
|
||||
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
if not screens.set_then_action_in_rules(rules, after_id, body.get("action")):
|
||||
return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
+6
-2
@@ -54,8 +54,7 @@ from .rendering.personnage_data import resolve_personnage_data, resolve_personna
|
||||
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
|
||||
from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
|
||||
from .rendering.collision_rules import (
|
||||
resolve_collision_rules, sanitize_collision_rules,
|
||||
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
||||
resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
|
||||
)
|
||||
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
|
||||
@@ -87,6 +86,11 @@ from .scenes.set_scene_object_collision_rules import set_scene_object_collision_
|
||||
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.render_scene_object import render_scene_object
|
||||
from .scenes.build_collision_rule_objects import build_collision_rule_objects
|
||||
from .rendering.trigger_graph import (
|
||||
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
||||
list_completable_dialogue_ids,
|
||||
)
|
||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||
|
||||
from .flow.ensure_flow_schema import ensure_flow_schema
|
||||
|
||||
@@ -10,6 +10,7 @@ from ..rendering.personnage_role import resolve_personnage_role
|
||||
from ..rendering.collision_settings import resolve_collision_settings
|
||||
from ..rendering.collision_rules import resolve_collision_rules
|
||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||
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
|
||||
|
||||
@@ -156,18 +157,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# ci-dessus sont déjà indexés par id, jamais par nom.
|
||||
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
||||
|
||||
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
|
||||
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
|
||||
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
|
||||
# à l'exécution d'une action "quete" (collision-rules-controller.js),
|
||||
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
|
||||
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
|
||||
quests = db.list_quests(slug)
|
||||
# Déclencheurs "terminés" (voir screens/rendering/collision_rules.py::
|
||||
# "dialogue".mark_completed) : GAME-WIDE, la liste des id de dialogue
|
||||
# que le créateur a marqués comme concluant la partie — nécessaire à
|
||||
# static/js/play/dialogue-box-controller.js::
|
||||
# forgeSyncAllDialoguesCompletionToScorm pour savoir quand TOUS ont
|
||||
# été joués, sans plus aucune notion de quête.
|
||||
completable_dialogue_ids = list_completable_dialogue_ids(slug)
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"data": data, "fields_meta": fields_meta,
|
||||
"variables": variables, "personnage_animations": personnage_animations,
|
||||
"definition_names": definition_names,
|
||||
"quests": quests,
|
||||
"completable_dialogue_ids": completable_dialogue_ids,
|
||||
}
|
||||
|
||||
@@ -1,42 +1,166 @@
|
||||
import json
|
||||
|
||||
# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir
|
||||
# templates/scene_edit.html) : chaque objet de scène (hors le personnage
|
||||
# "joueur" — voir screens/rendering/personnage_role.py — et un "fond",
|
||||
# jamais une cible/source de collision) porte une LISTE de règles
|
||||
# "déclencheur -> action", construites carte par carte dans l'éditeur.
|
||||
from db.dialogue_lines import sanitize_dialogue_lines
|
||||
from screens.flow.constants import CONDITION_OPERATORS
|
||||
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
|
||||
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
|
||||
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
|
||||
# objet de scène (hors le personnage "joueur" — voir screens/rendering/
|
||||
# personnage_role.py — et un "fond", jamais une cible/source de collision)
|
||||
# porte une LISTE de règles "déclencheur -> action".
|
||||
#
|
||||
# Déclencheur : "collision" (chevauchement direct, voir
|
||||
# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le
|
||||
# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet).
|
||||
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
|
||||
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
|
||||
# demande explicite).
|
||||
#
|
||||
# Action (une seule par règle) :
|
||||
# - "quete" : quete_id (id technique d'une quête — voir db/quests/,
|
||||
# choisie/créée via l'assistant "+ Action" qui ouvre
|
||||
# directement l'éditeur de quête, static/js/quests/
|
||||
# quest-editor.js) — une quête inexistante/supprimée
|
||||
# depuis reste une référence orpheline plutôt qu'une
|
||||
# contrainte SQL, comme le reste de ce moteur (voir
|
||||
# evenement_id ci-dessous, même principe).
|
||||
# - "attaque" : joue la pose "attack" du personnage si disponible (voir
|
||||
# personnage-controller.js) — pas de système de combat
|
||||
# (dégâts/PV...) pour l'instant, effet visuel seul.
|
||||
# - "evenement" : evenement_id — réutilise TEL QUEL le système
|
||||
# d'événements personnalisés existant (db.list_custom_events,
|
||||
# window.dispatchGameEvent), la logique de flow d'un écran
|
||||
# document peut donc déjà écouter ce déclenchement.
|
||||
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du
|
||||
# joueur, voir personnage_commands.py) tant que la
|
||||
# collision/le périmètre est actif, puis exécute
|
||||
# `sub_action` (même forme qu'une action, SAUF
|
||||
# "interagir" à nouveau — UN seul niveau d'imbrication,
|
||||
# pas une récursion illimitée) à l'appui de la touche.
|
||||
ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"]
|
||||
TRIGGER_TYPES = ["collision", "perimetre"]
|
||||
DEFAULT_PERIMETRE_PX = 5
|
||||
# Action :
|
||||
# - "dialogue" : la logique n'est plus portée par une "quête" mais
|
||||
# DIRECTEMENT par le déclencheur — cette action EST un
|
||||
# dialogue, pas une référence à autre chose. `dialogue`
|
||||
# (optionnel) : {id, lines} — `id` généré côté client
|
||||
# (voir static/js/scenes/collision-rules-editor.js),
|
||||
# `lines` une liste de répliques/questions (voir
|
||||
# db/dialogue_lines.py) — chaque occurrence de
|
||||
# "Déclencher ce dialogue" (directe, sous "interagir",
|
||||
# ou dans une branche de "condition") porte SON PROPRE
|
||||
# dialogue, édité en ligne dans l'onglet "Déclencheurs".
|
||||
# `mark_completed` (optionnel, bool) : le créateur
|
||||
# marque explicitement ce déclencheur comme "terminé"
|
||||
# une fois ce dialogue joué (voir static/js/play/
|
||||
# dialogue-box-controller.js::
|
||||
# forgeSyncAllDialoguesCompletionToScorm).
|
||||
# - "variable" : modifie une variable globale — MÊME vocabulaire que
|
||||
# l'action "Modifier une variable" du graphe de logique
|
||||
# document (voir screens/data_actions/apply_variable_action.py,
|
||||
# screens/labels/data_operations.py::DATA_OPERATION_LABELS,
|
||||
# réutilisé tel quel plutôt que réinventé) :
|
||||
# `target_variable` (nom), `data_operation` (une clé de
|
||||
# DATA_OPERATION_LABELS), `data_value` (optionnel, absent
|
||||
# pour definir_bool_vrai/definir_bool_faux/basculer_bool
|
||||
# qui n'ont besoin d'aucune valeur). `id` généré côté
|
||||
# client, comme `dialogue.id` — sert à adresser CETTE
|
||||
# feuille précise pour y attacher un "then" plus tard
|
||||
# (voir set_then_action_in_rules).
|
||||
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir"
|
||||
# du joueur, voir personnage_commands.py) tant que la
|
||||
# collision est active, puis exécute `sub_action` (même
|
||||
# forme qu'une action, SAUF "interagir" à nouveau — UN
|
||||
# seul niveau d'imbrication, pas une récursion illimitée)
|
||||
# à l'appui de la touche.
|
||||
# - "condition" : Si <variable> <opérateur> <valeur> (voir
|
||||
# screens/flow/constants.py::CONDITION_OPERATORS, même
|
||||
# vocabulaire que le nœud Condition de l'éditeur de
|
||||
# logique) -> exécute `si_vrai` ou `si_faux` (chacun soit
|
||||
# None — "ne rien faire", soit une FEUILLE — "dialogue"
|
||||
# ou "variable", voir maquette validée, JAMAIS une
|
||||
# nouvelle "interagir"/"condition" imbriquée : une
|
||||
# branche reste une feuille).
|
||||
#
|
||||
# CHAÎNAGE : une feuille ("dialogue" ou "variable") peut porter un champ
|
||||
# optionnel "then" — l'action suivante à exécuter une fois celle-ci
|
||||
# terminée, elle-même une feuille, qui peut à son tour avoir un "then"
|
||||
# (borné par _MAX_THEN_CHAIN) — demande explicite : "pouvoir modifier un
|
||||
# déclencheur pour lui ajouter des actions" (plusieurs actions chaînées,
|
||||
# pas juste remplacer l'action actuelle).
|
||||
#
|
||||
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
|
||||
# demande explicite.)
|
||||
ACTION_TYPES = ["dialogue", "variable", "interagir", "condition"]
|
||||
LEAF_ACTION_TYPES = ("dialogue", "variable")
|
||||
TRIGGER_TYPES = ["collision"]
|
||||
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
|
||||
# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une
|
||||
# structure sans fin construite par un JSON posté à la main.
|
||||
_MAX_ACTION_DEPTH = 2
|
||||
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH.
|
||||
_MAX_THEN_CHAIN = 4
|
||||
|
||||
|
||||
_MAX_ACTION_ID_LENGTH = 40
|
||||
# Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
# data_operations.py::DATA_OPERATIONS) — data_value est toujours omis
|
||||
# pour celles-ci, quoi que le client envoie (même logique que
|
||||
# static/js/scenes/collision-rules-editor.js::COLLISION_NO_VALUE_OPERATIONS,
|
||||
# qui masque le champ correspondant côté formulaire).
|
||||
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
|
||||
|
||||
|
||||
def _sanitize_dialogue(raw_dialogue):
|
||||
if not isinstance(raw_dialogue, dict):
|
||||
return None
|
||||
dialogue_id = raw_dialogue.get("id")
|
||||
if not isinstance(dialogue_id, str) or not dialogue_id.strip():
|
||||
return None
|
||||
return {
|
||||
"id": dialogue_id.strip()[:_MAX_ACTION_ID_LENGTH],
|
||||
"lines": sanitize_dialogue_lines(raw_dialogue.get("lines")),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_dialogue_action(raw_action):
|
||||
action = {"type": "dialogue"}
|
||||
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
|
||||
if dialogue:
|
||||
action["dialogue"] = dialogue
|
||||
if raw_action.get("mark_completed") is True:
|
||||
action["mark_completed"] = True
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_variable_action(raw_action):
|
||||
target_variable = raw_action.get("target_variable")
|
||||
if not isinstance(target_variable, str) or not target_variable.strip():
|
||||
return None
|
||||
data_operation = raw_action.get("data_operation")
|
||||
if data_operation not in DATA_OPERATION_LABELS:
|
||||
return None
|
||||
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
|
||||
action_id = raw_action.get("id")
|
||||
if isinstance(action_id, str) and action_id.strip():
|
||||
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
|
||||
if data_operation not in _NO_VALUE_OPERATIONS:
|
||||
raw_value = raw_action.get("data_value")
|
||||
if isinstance(raw_value, str) and raw_value.strip():
|
||||
action["data_value"] = raw_value.strip()
|
||||
elif isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
|
||||
action["data_value"] = str(raw_value)
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_leaf_action(raw_action, depth=1):
|
||||
"""Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle
|
||||
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
|
||||
feuille est attendue : action de premier niveau, sous "interagir", ou
|
||||
branche Vrai/Faux de "condition"."""
|
||||
if not isinstance(raw_action, dict):
|
||||
return None
|
||||
action_type = raw_action.get("type")
|
||||
if action_type == "dialogue":
|
||||
action = _sanitize_dialogue_action(raw_action)
|
||||
elif action_type == "variable":
|
||||
action = _sanitize_variable_action(raw_action)
|
||||
else:
|
||||
return None
|
||||
if not action:
|
||||
return None
|
||||
if depth < _MAX_THEN_CHAIN:
|
||||
then_action = _sanitize_leaf_action(raw_action.get("then"), depth=depth + 1)
|
||||
if then_action:
|
||||
action["then"] = then_action
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_condition_branch(raw_branch):
|
||||
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
|
||||
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
|
||||
"variable", éventuellement chaînée via "then") — jamais "interagir"/
|
||||
"condition" à nouveau, pour garder la structure bornée (voir maquette
|
||||
validée : chaque branche n'a que ces choix)."""
|
||||
if raw_branch is None:
|
||||
return None
|
||||
return _sanitize_leaf_action(raw_branch)
|
||||
|
||||
|
||||
def _sanitize_action(raw_action, depth=1):
|
||||
@@ -47,31 +171,33 @@ def _sanitize_action(raw_action, depth=1):
|
||||
return None
|
||||
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
|
||||
return None # un seul niveau d'imbrication autorisé
|
||||
action = {"type": action_type}
|
||||
if action_type == "quete":
|
||||
try:
|
||||
action["quete_id"] = int(raw_action.get("quete_id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
elif action_type == "evenement":
|
||||
try:
|
||||
action["evenement_id"] = int(raw_action.get("evenement_id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
elif action_type == "interagir":
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
return _sanitize_leaf_action(raw_action)
|
||||
if action_type == "interagir":
|
||||
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
|
||||
if not sub_action:
|
||||
return None
|
||||
action["sub_action"] = sub_action
|
||||
# "attaque" : aucun champ supplémentaire.
|
||||
return action
|
||||
return {"type": "interagir", "sub_action": sub_action}
|
||||
# "condition"
|
||||
variable = (raw_action.get("variable") or "").strip()
|
||||
operateur = raw_action.get("operateur")
|
||||
if not variable or operateur not in CONDITION_OPERATOR_KEYS:
|
||||
return None
|
||||
return {
|
||||
"type": "condition",
|
||||
"variable": variable,
|
||||
"operateur": operateur,
|
||||
"valeur": str(raw_action.get("valeur") if raw_action.get("valeur") is not None else ""),
|
||||
"si_vrai": _sanitize_condition_branch(raw_action.get("si_vrai")),
|
||||
"si_faux": _sanitize_condition_branch(raw_action.get("si_faux")),
|
||||
}
|
||||
|
||||
|
||||
def sanitize_collision_rules(raw_rules):
|
||||
"""Valide/nettoie une liste de règles postée par le client (voir
|
||||
routes/scenes/scene_object_collision_rules.py) — une règle invalide
|
||||
(déclencheur/action inconnus, imbrication trop profonde...) est
|
||||
IGNORÉE plutôt que de faire planter la sauvegarde entière."""
|
||||
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
|
||||
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
|
||||
la sauvegarde entière."""
|
||||
if not isinstance(raw_rules, list):
|
||||
return []
|
||||
cleaned = []
|
||||
@@ -84,16 +210,96 @@ def sanitize_collision_rules(raw_rules):
|
||||
action = _sanitize_action(raw_rule.get("action"))
|
||||
if not action:
|
||||
continue
|
||||
rule = {"trigger": trigger, "action": action}
|
||||
if trigger == "perimetre":
|
||||
try:
|
||||
rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX)))
|
||||
except (TypeError, ValueError):
|
||||
rule["perimetre_px"] = DEFAULT_PERIMETRE_PX
|
||||
cleaned.append(rule)
|
||||
cleaned.append({"trigger": trigger, "action": action})
|
||||
return cleaned
|
||||
|
||||
|
||||
def _apply_dialogue_lines(action, dialogue_id, lines):
|
||||
if not isinstance(action, dict):
|
||||
return False
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
if isinstance(dialogue, dict) and dialogue.get("id") == dialogue_id:
|
||||
action["dialogue"] = {"id": dialogue_id, "lines": lines}
|
||||
return True
|
||||
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
|
||||
if action_type == "variable":
|
||||
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
|
||||
if action_type == "interagir":
|
||||
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
|
||||
if action_type == "condition":
|
||||
return (
|
||||
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
|
||||
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
|
||||
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
|
||||
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
|
||||
chaîné via "then", sous "interagir", ou dans une branche de
|
||||
"condition") — utilisé par routes/triggers/ pour l'édition en ligne
|
||||
d'un dialogue depuis l'onglet "Déclencheurs". Retourne False sans
|
||||
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
|
||||
orpheline, ex. règle supprimée entre-temps)."""
|
||||
lines = sanitize_dialogue_lines(raw_lines)
|
||||
for rule in rules:
|
||||
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _leaf_id(action):
|
||||
if action.get("type") == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
return dialogue.get("id") if isinstance(dialogue, dict) else None
|
||||
if action.get("type") == "variable":
|
||||
return action.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _apply_then_action(action, leaf_id, then_action):
|
||||
if not isinstance(action, dict):
|
||||
return False
|
||||
action_type = action.get("type")
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
if _leaf_id(action) == leaf_id:
|
||||
if "then" in action:
|
||||
return False # jamais écraser un chaînage déjà posé
|
||||
action["then"] = then_action
|
||||
return True
|
||||
return _apply_then_action(action.get("then"), leaf_id, then_action)
|
||||
if action_type == "interagir":
|
||||
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
|
||||
if action_type == "condition":
|
||||
return (
|
||||
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
|
||||
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
|
||||
"""Attache EN PLACE une NOUVELLE action `then` sur la feuille
|
||||
("dialogue" ou "variable") identifiée par `leaf_id` (dialogue.id pour
|
||||
une feuille dialogue, id pour une feuille variable) — n'importe où
|
||||
dans l'arbre d'action d'une des règles. Utilisé par routes/triggers/
|
||||
pour "+ Ajouter une action" en bout de chaîne (voir static/js/triggers/
|
||||
trigger-editor.js). Retourne False sans rien modifier si `leaf_id`
|
||||
est introuvable, OU si la feuille trouvée a DÉJÀ un "then" (on
|
||||
n'écrase jamais un chaînage existant par cette route — seulement
|
||||
ajouter à la fin réelle de la chaîne)."""
|
||||
then_action = _sanitize_leaf_action(raw_then_action)
|
||||
if not then_action:
|
||||
return False
|
||||
for rule in rules:
|
||||
if _apply_then_action(rule.get("action"), leaf_id, then_action):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_collision_rules(obj):
|
||||
"""Décode attributes._collision_rules en liste Python, jamais None —
|
||||
un attribut absent/corrompu retombe sur aucune règle (comportement
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
|
||||
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
|
||||
# "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours
|
||||
# d'une quête déclenchée par une règle de collision "quete" (voir
|
||||
# d'un dialogue déclenché par une règle de collision "dialogue" (voir
|
||||
# static/js/play/dialogue-box-controller.js) — header (nom du
|
||||
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
|
||||
# entièrement stylisable par l'auteur.
|
||||
|
||||
@@ -11,10 +11,10 @@ _EXCLUDED_KINDS = {"dialogue_box"}
|
||||
def list_named_scene_object_names(slug):
|
||||
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
|
||||
Informations", templates/scene_edit.html) — n'importe quel kind
|
||||
(personnage, décor, fond), toutes scènes 2D du jeu confondues : une
|
||||
quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre
|
||||
en scène un objet posé sur une autre scène que celle où la règle de
|
||||
collision se déclenche. Ordre alphabétique, jamais de doublon."""
|
||||
(personnage, décor, fond), toutes scènes 2D du jeu confondues : un
|
||||
déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un
|
||||
objet posé sur une autre scène que celle où la règle de collision se
|
||||
déclenche. Ordre alphabétique, jamais de doublon."""
|
||||
names = set()
|
||||
for s in list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
def _game_objects_with_thumbnails(slug, screens_mod):
|
||||
"""Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à
|
||||
screens.list_scene_objects, qui ne prend qu'un screen_id) —
|
||||
rendered_html ajouté comme l'exigent screens.build_collision_rule_objects
|
||||
(voir routes/scenes/scene_edit_view.py pour le même patron, une seule
|
||||
scène)."""
|
||||
result = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
objects = screens_mod.list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens_mod.render_scene_object(o)
|
||||
o["screen_id"] = s["id"]
|
||||
o["screen_name"] = s["name"]
|
||||
result.extend(objects)
|
||||
return result
|
||||
|
||||
|
||||
def _describe_leaf(action):
|
||||
"""Une seule feuille ("dialogue" ou "variable", voir
|
||||
screens/rendering/collision_rules.py) décrite pour l'affichage,
|
||||
SANS son éventuel "then" (voir _describe_chain, qui aplatit la
|
||||
chaîne complète)."""
|
||||
if action.get("type") == "dialogue":
|
||||
return {"type": "dialogue", "dialogue": action.get("dialogue")}
|
||||
return {
|
||||
"type": "variable",
|
||||
"id": action.get("id"),
|
||||
"target_variable": action.get("target_variable"),
|
||||
"data_operation": action.get("data_operation"),
|
||||
"data_value": action.get("data_value"),
|
||||
}
|
||||
|
||||
|
||||
def _describe_chain(action):
|
||||
"""Aplatit une feuille + ses "then" successifs en une LISTE, dans
|
||||
l'ordre d'exécution — voir maquette validée ("plusieurs actions
|
||||
chaînées"). Liste vide si `action` n'est ni "dialogue" ni "variable"."""
|
||||
chain = []
|
||||
node = action
|
||||
while isinstance(node, dict) and node.get("type") in ("dialogue", "variable"):
|
||||
chain.append(_describe_leaf(node))
|
||||
node = node.get("then")
|
||||
return chain
|
||||
|
||||
|
||||
def _branch_info(branch):
|
||||
if not isinstance(branch, dict) or branch.get("type") not in ("dialogue", "variable"):
|
||||
return None
|
||||
return {"chain": _describe_chain(branch)}
|
||||
|
||||
|
||||
def _resolve_action_trigger(action, has_interagir=False):
|
||||
"""Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/
|
||||
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
|
||||
chaque feuille ("dialogue"/"variable") affiche DIRECTEMENT son propre
|
||||
contenu, où qu'elle soit dans l'arbre (y compris CHAQUE branche d'une
|
||||
"condition", indépendamment l'une de l'autre), et sa chaîne "then"
|
||||
complète (voir _describe_chain). Retourne {has_interagir, chain,
|
||||
condition} — `chain` est une LISTE (jamais None, vide si l'action ne
|
||||
se termine par aucune feuille reconnue) ; `condition` est None sauf
|
||||
pour une action "condition", auquel cas il porte {variable, operateur,
|
||||
valeur, si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne
|
||||
rien faire") ou {chain: [...]}."""
|
||||
if not isinstance(action, dict):
|
||||
return {"has_interagir": has_interagir, "chain": [], "condition": None}
|
||||
action_type = action.get("type")
|
||||
if action_type in ("dialogue", "variable"):
|
||||
return {"has_interagir": has_interagir, "chain": _describe_chain(action), "condition": None}
|
||||
if action_type == "interagir":
|
||||
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
|
||||
if action_type == "condition":
|
||||
return {
|
||||
"has_interagir": has_interagir, "chain": [],
|
||||
"condition": {
|
||||
"variable": action.get("variable"), "operateur": action.get("operateur"),
|
||||
"valeur": action.get("valeur"),
|
||||
"si_vrai": _branch_info(action.get("si_vrai")),
|
||||
"si_faux": _branch_info(action.get("si_faux")),
|
||||
},
|
||||
}
|
||||
return {"has_interagir": has_interagir, "chain": [], "condition": None}
|
||||
|
||||
|
||||
def list_all_triggers(slug):
|
||||
"""TOUS les déclencheurs du jeu, un par occurrence de "À la collision"
|
||||
posée sur un objet de scène — remplace l'ancienne recherche inversée
|
||||
par quête : plus de filtrage par id, chaque déclencheur affiche
|
||||
directement sa propre logique (voir onglet "Déclencheurs",
|
||||
static/js/triggers/trigger-editor.js)."""
|
||||
import screens as screens_mod
|
||||
|
||||
objects = _game_objects_with_thumbnails(slug, screens_mod)
|
||||
rule_objects = screens_mod.build_collision_rule_objects(objects)
|
||||
|
||||
result = []
|
||||
for obj in rule_objects:
|
||||
for rule in obj["rules"]:
|
||||
info = _resolve_action_trigger(rule["action"])
|
||||
result.append({
|
||||
"object_id": obj["id"],
|
||||
"object_name": obj["name"],
|
||||
"thumbnail_html": obj["thumbnail_html"],
|
||||
"has_interagir": info["has_interagir"],
|
||||
"trigger": rule["trigger"],
|
||||
"condition": info["condition"],
|
||||
"chain": info["chain"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _collect_dialogue_lines_from_action(action, acc):
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
if isinstance(dialogue, dict):
|
||||
acc.append(dialogue.get("lines") or [])
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "variable":
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_dialogue_lines_from_action(action.get("sub_action"), acc)
|
||||
elif action_type == "condition":
|
||||
_collect_dialogue_lines_from_action(action.get("si_vrai"), acc)
|
||||
_collect_dialogue_lines_from_action(action.get("si_faux"), acc)
|
||||
|
||||
|
||||
def collect_all_dialogue_lines(slug):
|
||||
"""Toutes les répliques/questions de TOUS les dialogues embarqués dans
|
||||
les règles de collision de TOUS les objets de scène du jeu (voir
|
||||
screens/rendering/collision_rules.py::"dialogue".dialogue), y compris
|
||||
au fil d'une chaîne "then" — une liste de listes de lignes (une par
|
||||
dialogue trouvé), jamais fusionnée ici. Utilisée pour calculer le
|
||||
score max possible du quiz à l'export SCORM (voir
|
||||
publish/build_scorm_package.py)."""
|
||||
import screens as screens_mod
|
||||
|
||||
acc = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
for obj in screens_mod.list_scene_objects(slug, s["id"]):
|
||||
for rule in screens_mod.resolve_collision_rules(obj):
|
||||
_collect_dialogue_lines_from_action(rule.get("action"), acc)
|
||||
return acc
|
||||
|
||||
|
||||
def _collect_completable_ids_from_action(action, acc):
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
if action.get("mark_completed") and isinstance(dialogue, dict) and dialogue.get("id"):
|
||||
acc.append(dialogue["id"])
|
||||
_collect_completable_ids_from_action(action.get("then"), acc)
|
||||
elif action_type == "variable":
|
||||
_collect_completable_ids_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_completable_ids_from_action(action.get("sub_action"), acc)
|
||||
elif action_type == "condition":
|
||||
_collect_completable_ids_from_action(action.get("si_vrai"), acc)
|
||||
_collect_completable_ids_from_action(action.get("si_faux"), acc)
|
||||
|
||||
|
||||
def list_completable_dialogue_ids(slug):
|
||||
"""Tous les id de dialogue du jeu dont l'action porte
|
||||
`mark_completed` (voir screens/rendering/collision_rules.py), y
|
||||
compris au fil d'une chaîne "then" — utilisée par static/js/play/
|
||||
dialogue-box-controller.js::forgeSyncAllDialoguesCompletionToScorm
|
||||
pour savoir quand la partie est terminée (TOUS joués), sans plus
|
||||
aucune notion de quête."""
|
||||
import screens as screens_mod
|
||||
|
||||
acc = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
for obj in screens_mod.list_scene_objects(slug, s["id"]):
|
||||
for rule in screens_mod.resolve_collision_rules(obj):
|
||||
_collect_completable_ids_from_action(rule.get("action"), acc)
|
||||
return acc
|
||||
|
||||
|
||||
def list_all_collision_objects(slug):
|
||||
"""Catalogue de TOUS les objets éligibles à une règle de collision,
|
||||
toutes scènes "jeu_2d" confondues — pour peupler le sélecteur d'objet
|
||||
de la modale d'ajout de déclencheur (voir routes/triggers/)."""
|
||||
import screens as screens_mod
|
||||
|
||||
objects = _game_objects_with_thumbnails(slug, screens_mod)
|
||||
rule_objects = screens_mod.build_collision_rule_objects(objects)
|
||||
return [
|
||||
{"id": o["id"], "name": o["name"], "kind": o["kind"], "thumbnail_html": o["thumbnail_html"]}
|
||||
for o in rule_objects
|
||||
]
|
||||
+5
-3
@@ -10,9 +10,11 @@ def build_collision_rule_objects(objects):
|
||||
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
||||
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
||||
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
|
||||
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
|
||||
scene-editor.js) pour reconstruire la liste SANS recharger la page.
|
||||
scene_edit_view.py), par l'endpoint AJAX scene_collision_rule_objects.py
|
||||
(appelé après ajout/suppression/changement de rôle ou de nom, voir
|
||||
static/js/scenes/scene-editor.js) ET par le listage de TOUS les
|
||||
déclencheurs du jeu (screens/rendering/trigger_graph.py) — vit dans
|
||||
screens/ (pas routes/) pour être appelable depuis les deux.
|
||||
|
||||
`objects` doit déjà porter "rendered_html" (voir
|
||||
screens.render_scene_object) sur chaque élément.
|
||||
@@ -47,7 +47,7 @@ def render_scene_object(obj):
|
||||
# body/footer sont remplis EN JEU (data-dialogue-role sert de
|
||||
# sélecteur), masqué par défaut au jeu (voir static/style.css,
|
||||
# `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de
|
||||
# collision "quete" ait un dialogue à afficher. Toujours VISIBLE
|
||||
# collision "dialogue" ait un dialogue à afficher. Toujours VISIBLE
|
||||
# dans l'éditeur (avec un texte d'exemple) pour pouvoir le
|
||||
# positionner/styliser.
|
||||
style = resolve_dialogue_box_style(obj)
|
||||
|
||||
@@ -9,8 +9,8 @@ def set_scene_object_name(slug, object_id, name):
|
||||
partout ailleurs, mais jamais éditable avant le panneau "ℹ️
|
||||
Informations", templates/scene_edit.html). Pour un personnage, ce nom
|
||||
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
|
||||
de quête (voir screens/rendering/scene_object_names.py, static/js/
|
||||
quests/quest-editor.js)."""
|
||||
d'un déclencheur (voir screens/rendering/scene_object_names.py,
|
||||
static/js/triggers/trigger-editor.js)."""
|
||||
ensure_scene_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id))
|
||||
|
||||
+21
-29
@@ -1,11 +1,12 @@
|
||||
// Bout en bout : une règle de collision "quete" doit réellement faire
|
||||
// Bout en bout : une règle de collision "dialogue" doit réellement faire
|
||||
// apparaître/avancer/disparaître la boîte de dialogue posée sur la scène
|
||||
// — pas seulement chaque brique testée isolément (voir
|
||||
// collision-rules-controller.test.js et dialogue-box-controller.test.js).
|
||||
// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle
|
||||
// "à la collision -> déclencher la quête « test »", une quête au statut
|
||||
// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur
|
||||
// la même scène.
|
||||
// "à la collision -> déclencher ce dialogue (avec mark_completed)", un
|
||||
// widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran
|
||||
// d'offre automatique, le dialogue s'affiche directement et se marque
|
||||
// terminé à la fin.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); //
|
||||
const {
|
||||
forgeCollisionRuleTick,
|
||||
} = require('../collision-rules-controller.js');
|
||||
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
@@ -43,11 +43,17 @@ function fakeEl(style) {
|
||||
};
|
||||
}
|
||||
|
||||
test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => {
|
||||
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage',
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }],
|
||||
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||
collision_rules: [{
|
||||
trigger: 'collision',
|
||||
action: {
|
||||
type: 'dialogue', mark_completed: true,
|
||||
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const box = { id: 3, kind: 'dialogue_box' };
|
||||
|
||||
@@ -61,18 +67,10 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
boxWidget._footer = { innerHTML: '' };
|
||||
|
||||
global.window.currentScreenId = 1;
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = {
|
||||
personnage_animations: {},
|
||||
quests: [{
|
||||
id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin',
|
||||
dialogues: {
|
||||
nouvelle: [
|
||||
{ speaker: 'Mr. Pinguin', text: 'Bonjour !' },
|
||||
{ speaker: 'Charlie', text: 'Salut !' },
|
||||
],
|
||||
en_cours: [], terminee: [],
|
||||
},
|
||||
}],
|
||||
completable_dialogue_ids: ['d_intro'],
|
||||
};
|
||||
|
||||
const previousDoc = global.document;
|
||||
@@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
try {
|
||||
const screenData = { elements: [player, mrPinguin, box] };
|
||||
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche la quête
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche le dialogue
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin');
|
||||
assert.equal(boxWidget._body.textContent, 'Bonjour !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas la quête
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget)
|
||||
@@ -107,14 +105,8 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
assert.equal(boxWidget._body.textContent, 'Salut !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie');
|
||||
assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin');
|
||||
assert.match(boxWidget._footer.innerHTML, /Accepter/);
|
||||
|
||||
forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter"
|
||||
assert.equal(forgeQuestById(99).status, 'en_cours');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Moteur d'exécution des règles "🧩 Collision" (voir
|
||||
// Moteur d'exécution des déclencheurs (voir
|
||||
// static/js/play/collision-rules-controller.js) : déclenche une action une
|
||||
// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que
|
||||
// la condition reste vraie — et gère "interagir" à part (bulle + attente
|
||||
@@ -14,6 +14,7 @@ global.heldKeys = new Set();
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
@@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
|
||||
});
|
||||
|
||||
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => {
|
||||
const objRect = rect(0, 0, 10, 10); // centre (5,5)
|
||||
const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true);
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => {
|
||||
const calls = [];
|
||||
global.window.dispatchGameEvent = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 });
|
||||
assert.deepEqual(calls, [42]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
|
||||
test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js) avec le nom de l\'objet, les répliques DE CETTE ACTION et mark_completed', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [42]);
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
|
||||
global.window.dispatchGameEvent = () => {};
|
||||
test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [[undefined, 'Crokine', [], undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '1' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_vrai', 'Crokine', vraiLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_faux, jamais si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_faux', 'Crokine', fauxLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien faire" (null) ne fait rien', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
|
||||
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '5');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
|
||||
const action = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
assert.equal(gameData.variables.vies.value, '2');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeShowDialogueBox (exécuté APRÈS le dialogue, pas ici)', () => {
|
||||
global.gameData = { variables: {} };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
const playerEl = fakeEl();
|
||||
const enemyEl = fakeEl();
|
||||
enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
const npcEl = fakeEl();
|
||||
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 1: playerEl, 2: enemyEl });
|
||||
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||
global.gameData = { personnage_animations: {} };
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
const calls = [];
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
try {
|
||||
const screenData = { elements: [player, enemy] };
|
||||
let attackCalls = 0;
|
||||
const originalRun = global.runSpriteAnimation;
|
||||
global.runSpriteAnimation = () => { attackCalls++; };
|
||||
const screenData = { elements: [player, npc] };
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
global.runSpriteAnimation = originalRun;
|
||||
// "attaque" ne joue une animation que si le personnage a une pose
|
||||
// "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0
|
||||
// appel réel à runSpriteAnimation, mais le test vérifie surtout
|
||||
// l'absence de double déclenchement plutôt que l'appel lui-même.
|
||||
assert.equal(attackCalls, 0);
|
||||
} finally { global.document = previousDoc; }
|
||||
assert.deepEqual(calls, ['d_7']);
|
||||
} finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; }
|
||||
});
|
||||
|
||||
test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => {
|
||||
@@ -148,7 +212,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
|
||||
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
|
||||
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 1 } } }],
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
|
||||
};
|
||||
const parent = fakeParentEl();
|
||||
const playerEl = fakeEl(parent);
|
||||
@@ -170,7 +234,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
|
||||
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
|
||||
const parentNear = fakeParentEl();
|
||||
const parentFar = fakeParentEl();
|
||||
const playerElNear = fakeEl(parentNear);
|
||||
@@ -198,5 +262,5 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
|
||||
// static/js/play/dialogue-box-controller.js) : affiche la réplique en
|
||||
// cours du dialogue d'une quête (gameData.quests) correspondant à son
|
||||
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin.
|
||||
// static/js/play/dialogue-box-controller.js) : affiche les répliques
|
||||
// D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules-
|
||||
// controller.js, chaque occurrence de "Déclencher ce dialogue" porte les
|
||||
// siennes, voir screens/rendering/collision_rules.py), avance au clic sur
|
||||
// "Suivant", se masque à la fin — sans écran d'offre automatique ni
|
||||
// notion de quête : le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||
// l'objet déclencheur.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -9,10 +13,9 @@ global.window = global.window || {};
|
||||
global.gameData = {};
|
||||
|
||||
const {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
||||
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
@@ -43,50 +46,37 @@ test.beforeEach(() => {
|
||||
// fuiraient sur le calcul de statut final du test suivant.
|
||||
global.window.forgeQuizTotalAnswered = 0;
|
||||
global.window.forgeQuizCorrectAnswered = 0;
|
||||
global.gameData = {
|
||||
quests: [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
|
||||
{ id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } },
|
||||
{ id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } },
|
||||
],
|
||||
};
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = { completable_dialogue_ids: [] };
|
||||
});
|
||||
|
||||
test('forgeQuestById retrouve une quête par id', () => {
|
||||
assert.equal(forgeQuestById(5).id, 5);
|
||||
assert.equal(forgeQuestById(999), undefined);
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
|
||||
test('forgeShowDialogueBox — sans dialogueId : aucun effet (pas de plantage)', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(999));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', []));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => {
|
||||
test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle"
|
||||
forgeShowDialogueBox('d_6', 'PNJ', []);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => {
|
||||
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(7);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu');
|
||||
assert.equal(widget._roles.body.textContent, 'Trouver le trésor');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(5));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => {
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement
|
||||
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
assert.equal(widget._roles.body.textContent, 'Merci !');
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
@@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => {
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier');
|
||||
assert.equal(widget._roles.body.textContent, 'Parler au sorcier');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(forgeQuestById(5).status, 'en_cours');
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => {
|
||||
test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propage vers le SCORM', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.completable_dialogue_ids = ['d_5']; // seul dialogue connu
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxDeclineQuest(42);
|
||||
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiatement l\'action chaînée (rien à exhiber avant)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
assert.equal(calls[0][2].name, 'Crokine');
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la dernière réplique, jamais avant', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
|
||||
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
@@ -206,36 +216,30 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.gameData.quests.push({
|
||||
id: 10, title: 'Le quiz du sorcier', status: 'en_cours',
|
||||
dialogues: {
|
||||
nouvelle: [],
|
||||
en_cours: [
|
||||
const QUIZ_LINES = [
|
||||
{ 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: [],
|
||||
},
|
||||
});
|
||||
];
|
||||
|
||||
test.beforeEach(() => {
|
||||
window.forgeQuizScore = 0;
|
||||
});
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, 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, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
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, 'Quête : Le quiz du sorcier');
|
||||
assert.equal(quizWidget._roles.header.textContent, 'Le 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\)/);
|
||||
@@ -248,7 +252,7 @@ test('mauvaise réponse au quiz : aucun point, MAIS avance quand même sur "Cont
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
||||
assert.equal(window.forgeQuizScore, 0); // aucun point
|
||||
@@ -271,7 +275,7 @@ test('un second clic pendant la révélation (avant "Continuer →") est ignoré
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
|
||||
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
|
||||
@@ -287,7 +291,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 5);
|
||||
@@ -298,8 +302,8 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
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');
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -313,7 +317,7 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
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
|
||||
@@ -333,9 +337,9 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
});
|
||||
|
||||
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les quêtes devraient
|
||||
// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu
|
||||
// construit uniquement avec des quêtes/quiz ne touchait jamais
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
|
||||
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
|
||||
// un jeu construit uniquement avec des dialogues/quiz ne touchait jamais
|
||||
// gameData.scoring, laissant Score/Completion perpétuellement "unknown"
|
||||
// dans le LMS même après avoir fini le jeu.
|
||||
|
||||
@@ -366,42 +370,40 @@ test('forgeAddQuizScore ne rétrograde jamais un statut déjà plus avancé que
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('accepter une quête fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
test('afficher un dialogue fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]);
|
||||
assert.equal(gameData.scoring.status, 'en_cours');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1', 'd_2'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
|
||||
|
||||
gameData.quests[1].status = 'terminee';
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
window.forgeCompletedDialogueIds.d_2 = true;
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
});
|
||||
|
||||
test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gameData.scoring si c\'est la seule quête', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.quests = [
|
||||
{ id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } },
|
||||
];
|
||||
test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => {
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name });
|
||||
global.gameData.completable_dialogue_ids = ['d_20'];
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(20);
|
||||
forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee"
|
||||
assert.equal(forgeQuestById(20).status, 'terminee');
|
||||
forgeCompleteDialogue('d_20', 'Crokine');
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
} finally { global.document = previousDoc; }
|
||||
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
|
||||
});
|
||||
|
||||
// ---------- Seuil de réussite (voir db/games/get_success_threshold.py) ----------
|
||||
@@ -433,31 +435,18 @@ test('forgeComputeFinalScormStatus — seuil par défaut (70%) si FORGE_SUCCESS_
|
||||
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
global.window.forgeQuizTotalAnswered = 4;
|
||||
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
|
||||
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'echoue');
|
||||
});
|
||||
|
||||
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ----------
|
||||
|
||||
test('accepter une quête notifie xAPI (verbe "attempted" ciblant CETTE quête)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestAccepted = quest => calls.push(quest);
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].id, 5);
|
||||
} finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestAccepted; }
|
||||
});
|
||||
// ---------- Granularité xAPI (dialogue terminé, question répondue) ----------
|
||||
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
@@ -467,21 +456,19 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestionAnswered = (quest, index, text, correct, score) => calls.push({ quest, index, text, correct, score });
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].objectName, 'Le Sorcier');
|
||||
assert.equal(calls[0].correct, true);
|
||||
assert.equal(calls[0].score, 5);
|
||||
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
|
||||
@@ -496,22 +483,19 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ quest, index, text, correct, choiceIndex, correctIndex });
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].correct, false);
|
||||
assert.equal(calls[0].choiceIndex, 1);
|
||||
assert.equal(calls[0].correctIndex, 0);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
|
||||
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
|
||||
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
|
||||
// porter des règles "déclencheur -> action" posées dans l'onglet
|
||||
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier
|
||||
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre
|
||||
// la position du joueur, déclenche l'action une seule fois par entrée
|
||||
// (pas en continu tant que la condition reste vraie), même cadence que
|
||||
// personnage-controller.js (50ms) et réutilise ses mêmes briques
|
||||
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// porter des règles "déclencheur -> action", posées depuis l'onglet
|
||||
// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
|
||||
// Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque
|
||||
// règle contre la position du joueur, déclenche l'action une seule fois
|
||||
// par entrée (pas en continu tant que la condition reste vraie), même
|
||||
// cadence que personnage-controller.js (50ms) et réutilise ses mêmes
|
||||
// briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
|
||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||
// branchement FORGE_OFFLINE nécessaire ici.
|
||||
@@ -62,16 +62,10 @@ function forgeExpandRect(rect, margin) {
|
||||
|
||||
// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
|
||||
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
|
||||
// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes
|
||||
// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant
|
||||
// pour une portée approximative, cohérent avec le reste du moteur qui ne
|
||||
// vise pas une physique précise).
|
||||
// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
|
||||
// servait à rien pour l'instant, demande explicite).
|
||||
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||
if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2;
|
||||
var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2;
|
||||
var dist = Math.hypot(ocx - pcx, ocy - pcy);
|
||||
return dist <= (rule.perimetre_px || 0);
|
||||
return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
}
|
||||
|
||||
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un
|
||||
@@ -118,27 +112,50 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais
|
||||
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick
|
||||
// qui gère "interagir" à part (affichage de bulle + attente de touche).
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
// (affichage de bulle + attente de touche).
|
||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
if (action.type === 'evenement') {
|
||||
window.dispatchGameEvent(action.evenement_id);
|
||||
return;
|
||||
}
|
||||
if (action.type === 'attaque') {
|
||||
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null;
|
||||
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
return;
|
||||
}
|
||||
if (action.type === 'quete') {
|
||||
// Affiche la réplique en cours du dialogue de cette quête, selon son
|
||||
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
|
||||
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
|
||||
// effet si aucun widget n'est posé ou si la quête n'a aucune
|
||||
// réplique pour son statut, comme le reste de ce moteur.
|
||||
forgeShowDialogueBox(action.quete_id);
|
||||
return;
|
||||
if (action.type === 'dialogue') {
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
||||
// de dialogue" posé sur l'écran (voir static/js/play/
|
||||
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
||||
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
var dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
||||
// s'exécute tout de suite, contrairement à un dialogue.
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'condition') {
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
var isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +253,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
||||
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
|
||||
const personnageMod = require('./personnage-controller.js');
|
||||
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
|
||||
@@ -1,55 +1,58 @@
|
||||
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
||||
// Fait le lien entre l'éditeur de collision (action "quete", voir
|
||||
// Fait le lien entre l'éditeur de déclencheurs (action "dialogue", voir
|
||||
// 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", 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).
|
||||
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
|
||||
// (action.dialogue.lines, transmises telles quelles par
|
||||
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
|
||||
// dialogue" porte son propre dialogue, voir screens/rendering/
|
||||
// collision_rules.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/triggers/trigger-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 : <titre>" / 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.
|
||||
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
|
||||
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
|
||||
// globales et conditions (voir screens/rendering/collision_rules.py::
|
||||
// "condition") — le dialogue choisi s'affiche directement. Une action
|
||||
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
|
||||
// (ou immédiatement s'il est vide), son id est ajouté à
|
||||
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
|
||||
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
|
||||
// dialogues `mark_completed` du jeu joués, voir
|
||||
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
|
||||
// lui-même reste un réglage du créateur totalement indépendant
|
||||
// (db/games/get_success_threshold.py), inchangé ici.
|
||||
//
|
||||
// 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
|
||||
// à ce système de dialogue/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").
|
||||
//
|
||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
||||
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
|
||||
// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore
|
||||
// qui ne compte que les points gagnés.
|
||||
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
||||
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
||||
|
||||
function forgeQuestById(questId) {
|
||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||
}
|
||||
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm.
|
||||
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
@@ -78,9 +81,8 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer
|
||||
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer
|
||||
// un nouveau dialogue dans la même boîte.
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
@@ -110,11 +112,11 @@ function forgeUpdateAllScoreWidgets() {
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
// apply-actions.js, même forme {score, status}) — bug signalé : "les
|
||||
// indicateurs qu'on utilise pour les quêtes devraient aussi servir aux
|
||||
// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux
|
||||
// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que
|
||||
// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser
|
||||
// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y
|
||||
// toucher, un jeu construit uniquement avec des quêtes/quiz (jamais
|
||||
// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais
|
||||
// l'action de flow "Modifier un score") laissait Score/Completion
|
||||
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
||||
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
||||
@@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() {
|
||||
gameData.scoring.score = window.forgeQuizScore;
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
||||
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
|
||||
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
|
||||
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
|
||||
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Une quête acceptée = la partie a commencé, même sans le moindre point
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
// encore gagné — sans cet appel, Score restait "0" mais Completion
|
||||
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
|
||||
function forgeSyncQuestStartedToScorm() {
|
||||
// Appelé depuis forgeShowDialogueBox.
|
||||
function forgeSyncDialogueStartedToScorm() {
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
}
|
||||
|
||||
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
|
||||
// sont "terminee" — règle simple et prévisible plutôt que de deviner
|
||||
// laquelle des quêtes créées "compte" pour la fin du jeu.
|
||||
// Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
|
||||
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
|
||||
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
|
||||
// été joués (voir forgeAdvanceConversation) — règle simple et
|
||||
// prévisible plutôt que de deviner lequel des dialogues créés "compte"
|
||||
// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
|
||||
// build_scorm_package.py) : demande utilisateur — "il manque
|
||||
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
||||
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
||||
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les
|
||||
// quêtes terminées" qui est déjà la seule condition de fin de partie.
|
||||
// Aucune question posée du tout (jeu fait uniquement de dialogues) :
|
||||
// reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
|
||||
// les dialogues terminés" qui est déjà la seule condition de fin de
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
@@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() {
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllQuestsCompletionToScorm() {
|
||||
var quests = gameData.quests || [];
|
||||
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
@@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index);
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
return;
|
||||
}
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
@@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
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).
|
||||
function forgeShowDialogueBox(questId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (!quest) return;
|
||||
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
|
||||
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
|
||||
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
|
||||
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
|
||||
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
|
||||
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
|
||||
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
|
||||
// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous.
|
||||
// `thenAction` (chaînage, voir screens/rendering/collision_rules.py::
|
||||
// "then") : l'action suivante, exécutée seulement une fois ce dialogue
|
||||
// épuisé — jamais avant, contrairement à une action "variable" qui,
|
||||
// elle, chaîne immédiatement (voir collision-rules-controller.js).
|
||||
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
|
||||
if (!dialogueId) return;
|
||||
lines = lines || [];
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
// 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).
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
// immédiatement (rien à exhiber avant), sinon silencieux.
|
||||
if (markCompleted) forgeCompleteDialogue(dialogueId, objectName);
|
||||
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||
forgeDialogueBoxState[objectId] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(objectId);
|
||||
}
|
||||
|
||||
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
|
||||
// le créateur pilote lui-même quand, via la case "Marquer ce dialogue
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
// 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).
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des
|
||||
// lignes : markCompleted PUIS thenAction (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite : "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
@@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
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 (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest);
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
}
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[dialogueObjectId];
|
||||
return;
|
||||
@@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
}
|
||||
|
||||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||
// 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, questionIndex) {
|
||||
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
|
||||
// Informations"), corps = la question + ses choix — 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, dialogueId, objectName, line, questionIndex) {
|
||||
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, resolved: false,
|
||||
quest: quest, questionIndex: questionIndex, questionText: line.text,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
};
|
||||
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 = 'Quête : ' + (quest ? quest.title : '');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
@@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
forgeAddQuizScore(state.rewardAmount);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
}
|
||||
|
||||
// ---------- Offre de quête (Accepter/Refuser) ----------
|
||||
|
||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (header) header.textContent = 'Quête : ' + quest.title;
|
||||
if (body) body.textContent = quest.objectif || '';
|
||||
if (footer) {
|
||||
footer.innerHTML =
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 };
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en
|
||||
// mémoire (gameData.quests), jamais persisté en base : voir la note en
|
||||
// tête de fichier (le statut en base est celui de DÉPART pour toute
|
||||
// nouvelle partie, pas un état de partie en cours).
|
||||
function forgeDialogueBoxAcceptQuest(questId, objectId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (quest) quest.status = 'en_cours';
|
||||
forgeSyncQuestStartedToScorm();
|
||||
if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest);
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// Referme la boîte SANS toucher au statut de la quête (reste "nouvelle")
|
||||
// — la prochaine interaction rejoue donc le même dialogue depuis le
|
||||
// début : impossible d'avancer la quête sans un jour l'accepter.
|
||||
function forgeDialogueBoxDeclineQuest(objectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
|
||||
@@ -134,7 +134,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
@@ -147,5 +147,5 @@ test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004
|
||||
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
|
||||
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
||||
// ceci lèverait en tentant d'appeler .GetValue sur null.
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered('d_1', 'x', 0, 'x', true, 0, 0));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
|
||||
// granularité par quête/question demandée par l'utilisateur (quête
|
||||
// acceptée/terminée, réponse à une question), en plus du score/statut
|
||||
// global déjà couvert par dialogue-box-controller.test.js.
|
||||
// granularité par dialogue/question demandée par l'utilisateur (dialogue
|
||||
// terminé, réponse à une question), en plus du score/statut global déjà
|
||||
// couvert par dialogue-box-controller.test.js.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -10,8 +10,8 @@ global.window.addEventListener = global.window.addEventListener || function () {
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject,
|
||||
} = require('../xapi-client.js');
|
||||
|
||||
test.beforeEach(() => {
|
||||
@@ -21,45 +21,35 @@ test.beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
|
||||
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
|
||||
test('forgeXapiDialogueObject construit un id de sous-activité dérivé de l\'activité du jeu, nommé d\'après l\'objet déclencheur', () => {
|
||||
const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'Le sorcier');
|
||||
});
|
||||
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
|
||||
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
|
||||
test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant le dialogue (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(calls.length, 1);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
});
|
||||
@@ -67,7 +57,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 0);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.result.success, false);
|
||||
assert.equal(body.result.score.raw, 0);
|
||||
@@ -76,18 +66,17 @@ test('une mauvaise réponse envoie quand même un statement, avec success=false
|
||||
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.display['en-US'], 'attempted');
|
||||
assert.equal(body.verb.display['fr-FR'], 'commencé');
|
||||
assert.equal(body.verb.display['en-US'], 'completed');
|
||||
assert.equal(body.verb.display['fr-FR'], 'terminé');
|
||||
});
|
||||
|
||||
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
||||
global.window.FORGE_XAPI_CONFIG = null;
|
||||
let called = false;
|
||||
global.fetch = () => { called = true; return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'x');
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function forgeScormInit() {
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
|
||||
@@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(quest, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "quest:" + quest.id + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
|
||||
@@ -73,26 +73,28 @@ function forgeXapiObject() {
|
||||
}
|
||||
|
||||
// Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) :
|
||||
// une quête/question n'est PAS l'activité de premier niveau du jeu
|
||||
// un dialogue/question n'est PAS l'activité de premier niveau du jeu
|
||||
// (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
|
||||
// id dérivé — assez pour que le LRS les distingue sans configuration
|
||||
// supplémentaire côté créateur du jeu.
|
||||
function forgeXapiQuestObject(quest) {
|
||||
// supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet
|
||||
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
|
||||
// "quête" pour le porter.
|
||||
function forgeXapiDialogueObject(dialogueId, objectName) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id,
|
||||
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(quest, questionIndex, questionText) {
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
);
|
||||
}
|
||||
|
||||
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir quand une quête a été acceptée, à telle
|
||||
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ
|
||||
// par évènement, distinct du score/statut global (forgeXapiNotify
|
||||
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de
|
||||
// premier niveau).
|
||||
function forgeXapiNotifyQuestAccepted(quest) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
// "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le
|
||||
// vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est
|
||||
// engagé sur cette quête".
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/attempted",
|
||||
null, forgeXapiQuestObject(quest)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestCompleted(quest) {
|
||||
// Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir [...] à telle question quel point obtenu,
|
||||
// bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du
|
||||
// score/statut global (forgeXapiNotifyScoreChanged/StatusChanged
|
||||
// ci-dessus, qui restent l'activité de premier niveau).
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiQuestObject(quest)
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) {
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(quest, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +193,7 @@ if (typeof window !== "undefined") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
// ---------- Éditeur de quêtes ----------
|
||||
// Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html,
|
||||
// jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet
|
||||
// "📣 Événements") plutôt qu'une grille de cartes. Un clic sur une ligne
|
||||
// ouvre la modale de FICHE (titre/objectif/récompense/statut/résultat —
|
||||
// "je crée"), qui ouvre à son tour, sur un bouton dédié, la modale
|
||||
// PLEIN ÉCRAN d'arbre de dialogue ("j'ajoute les dialogues") — deux
|
||||
// étapes séparées, jamais le même formulaire. Les deux modales sont
|
||||
// aussi ouvertes depuis l'assistant "+ Action" de l'éditeur de collision
|
||||
// (voir static/js/scenes/collision-rules-editor.js) quand l'auteur
|
||||
// choisit "Déclencher une quête" — créées dynamiquement (ensureQuest*Modal)
|
||||
// plutôt que dupliquées dans deux endroits.
|
||||
//
|
||||
// Une quête (voir db/quests/) : id, title, objectif, recompense_score,
|
||||
// status (nouvelle/en_cours/terminee — statut COURANT, informatif),
|
||||
// resultat (reussi/echoue), dialogues (une liste de répliques PAR
|
||||
// STATUT — 3 colonnes dans la modale : quel dialogue jouer selon l'état
|
||||
// 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_RESULT_LABELS = { reussi: 'Réussi', echoue: 'Échoué' };
|
||||
var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
|
||||
|
||||
// Quêtes connues côté client — préremplies par QUESTS_JSON (voir
|
||||
// templates/scene_edit.html), toujours présentes puisque l'onglet
|
||||
// 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 : [];
|
||||
|
||||
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
|
||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
||||
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
|
||||
|
||||
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
|
||||
// .questBubble-pnj, static/style.css) même si le nom affiché est
|
||||
// n'importe quel objet nommé — "Joueur" en vert, n'importe
|
||||
// quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles
|
||||
// du pnj et du joueur sont d'une couleur différente").
|
||||
function questBubbleKind(speaker) {
|
||||
return speaker === 'Joueur' ? 'joueur' : 'pnj';
|
||||
}
|
||||
|
||||
function questById(questId) {
|
||||
return QUESTS.find(function (q) { return q.id === questId; });
|
||||
}
|
||||
|
||||
function questListRowHtml(quest) {
|
||||
var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '<span class="hint">—</span>';
|
||||
return (
|
||||
'<tr class="questListRow" onclick="openQuestFieldsModal(' + quest.id + ')">' +
|
||||
'<td><strong>' + (quest.title || '').replace(/</g, '<') + '</strong></td>' +
|
||||
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '<') : '<span class="hint">—</span>') + '</td>' +
|
||||
'<td><span class="questPill questPill-score">+' + (quest.recompense_score || 0) + '</span></td>' +
|
||||
'<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
|
||||
'<td>' + resultatHtml + '</td>' +
|
||||
'<td class="fieldsTableActions">' +
|
||||
'<button type="button" class="button is-small is-danger" title="Supprimer" onclick="event.stopPropagation(); questDeleteFromList(' + quest.id + ')">🗑️</button>' +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestListPage() {
|
||||
var container = document.getElementById('questsTabPanel');
|
||||
if (!container) return;
|
||||
var wrap = document.getElementById('questsListWrap');
|
||||
if (!QUESTS.length) {
|
||||
wrap.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
renderQuestListPage();
|
||||
openQuestFieldsModal(json.quest.id);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Deux modales distinctes ----------
|
||||
// 1. #questFieldsModal (taille normale) : "je crée" — titre/objectif/
|
||||
// récompense/statut/résultat, + un bouton qui ouvre...
|
||||
// 2. #questDialogueModal (PLEIN ÉCRAN) : "j'ajoute les dialogues" — les
|
||||
// 3 colonnes de répliques, rien d'autre. Étapes séparées demandées
|
||||
// explicitement par l'utilisateur (pas un seul gros formulaire).
|
||||
// Créées une seule fois chacune (au premier appel), réutilisées ensuite —
|
||||
// partagées avec l'assistant "+ Action" de l'éditeur de collision,
|
||||
// jamais dupliquées dans les templates.
|
||||
var _questModalOnUse = null;
|
||||
var _questModalCurrentId = null;
|
||||
|
||||
function ensureQuestFieldsModal() {
|
||||
var modal = document.getElementById('questFieldsModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.id = 'questFieldsModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeQuestFieldsModal()"></div>' +
|
||||
'<div class="modal-content questFieldsModalContent"><div class="box">' +
|
||||
'<div id="questFieldsModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...)
|
||||
// pour finaliser la règle de collision avec cette quête, affiche alors un
|
||||
// bouton "✓ Utiliser cette quête" dans les deux modales.
|
||||
//
|
||||
// Masque #collisionRuleModal s'il est ouvert, SANS effacer _collisionWizard
|
||||
// (contrairement à closeCollisionRuleModal(), static/js/scenes/
|
||||
// collision-rules-editor.js) : appelée depuis questPickerChoose()/
|
||||
// questPickerCreate() (assistant "+ Action" de l'onglet Collision, action
|
||||
// "quete" — voir renderQuestPickerStep()), CETTE modale et
|
||||
// #collisionRuleModal partagent le même z-index (.modal de base) — sans la
|
||||
// masquer, les deux restaient actives en même temps, empilées dans l'ordre
|
||||
// du DOM plutôt que l'une à la place de l'autre : le bouton "+ Action"
|
||||
// semblait ne plus rien faire ensuite (il rouvrait bien #collisionRuleModal,
|
||||
// mais caché derrière celle-ci restée active). _collisionWizard doit
|
||||
// SURVIVRE à ce masquage : "✓ Utiliser cette quête" (questUseThisQuest)
|
||||
// finalise la règle en cours via collisionWizardFinalizeAction(), qui en a
|
||||
// encore besoin (objectId, trigger, pendingInteragir pour "interagir").
|
||||
function _hideCollisionRuleModalKeepingWizardState() {
|
||||
var modal = document.getElementById('collisionRuleModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function openQuestFieldsModal(questId, onUse) {
|
||||
_hideCollisionRuleModalKeepingWizardState();
|
||||
_questModalOnUse = onUse || null;
|
||||
_questModalCurrentId = questId;
|
||||
var modal = ensureQuestFieldsModal();
|
||||
modal.classList.add('is-active');
|
||||
renderQuestFieldsModalBody(questId);
|
||||
}
|
||||
|
||||
// _questModalOnUse/_questModalCurrentId ne sont PAS remis à null ici :
|
||||
// openQuestDialogueModal() ferme cette modale en s'ouvrant (voir plus
|
||||
// bas) et a besoin de les garder pour son propre bouton "✓ Utiliser
|
||||
// cette quête" — chaque point d'entrée (openQuestFieldsModal) les fixe
|
||||
// de toute façon fraîchement à chaque ouverture.
|
||||
function closeQuestFieldsModal() {
|
||||
var modal = document.getElementById('questFieldsModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function questUseThisQuest(questId) {
|
||||
var quest = questById(questId);
|
||||
if (_questModalOnUse && quest) _questModalOnUse(quest);
|
||||
closeQuestDialogueModal();
|
||||
closeQuestFieldsModal();
|
||||
_questModalOnUse = null;
|
||||
_questModalCurrentId = null;
|
||||
}
|
||||
|
||||
function questFieldRowHtml(quest) {
|
||||
return (
|
||||
'<div class="questFieldsGrid">' +
|
||||
'<label class="questField"><span>Titre</span><input type="text" class="input" value="' + (quest.title || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'title', this.value)\"></label>" +
|
||||
'<label class="questField questField-wide"><span>Objectif</span><input type="text" class="input" value="' + (quest.objectif || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'objectif', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Récompense (score)</span><input type="number" class="input" value="' + (quest.recompense_score || 0) + '" onchange="questUpdateField(' + quest.id + ", 'recompense_score', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Statut</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'status', this.value)\">" +
|
||||
QUEST_STATUS_ORDER.map(function (s) { return '<option value="' + s + '"' + (quest.status === s ? ' selected' : '') + '>' + QUEST_STATUS_LABELS[s] + '</option>'; }).join('') +
|
||||
'</select></label>' +
|
||||
'<label class="questField"><span>Résultat</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'resultat', this.value)\">" +
|
||||
'<option value=""' + (!quest.resultat ? ' selected' : '') + '>—</option>' +
|
||||
'<option value="reussi"' + (quest.resultat === 'reussi' ? ' selected' : '') + '>Réussi</option>' +
|
||||
'<option value="echoue"' + (quest.resultat === 'echoue' ? ' selected' : '') + '>Échoué</option>' +
|
||||
'</select></label>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestFieldsModalBody(questId) {
|
||||
var quest = questById(questId);
|
||||
var body = document.getElementById('questFieldsModalBody');
|
||||
if (!quest || !body) return;
|
||||
var useBtn = _questModalOnUse
|
||||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
|
||||
: '';
|
||||
body.innerHTML =
|
||||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||||
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '<') + '</h2>' +
|
||||
'<button type="button" class="button is-small" onclick="closeQuestFieldsModal()">✕</button>' +
|
||||
'</div>' +
|
||||
questFieldRowHtml(quest) +
|
||||
'<div class="fieldRow" style="justify-content:space-between; align-items:center; margin-top:6px;">' +
|
||||
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">Ajouter les dialogues</button>' +
|
||||
useBtn +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
|
||||
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
|
||||
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
|
||||
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
|
||||
// valeur déjà enregistrée.
|
||||
function questSpeakerOptions(currentSpeaker) {
|
||||
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||
return options;
|
||||
}
|
||||
|
||||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||||
// distinctes demandées explicitement, pas un seul bloc. Toujours centrée
|
||||
// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) —
|
||||
// aucune limite au nombre de bulles par colonne (voir questAddBubble).
|
||||
function questDialogueBubbleHtml(line, status, index) {
|
||||
var kind = questBubbleKind(line.speaker);
|
||||
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
|
||||
return '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-' + kind + '">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questEscAttr(str) {
|
||||
return (str || '').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
|
||||
// dialogue (même colonne, même alignement vertical relié par un trait,
|
||||
// voir .questBubbleRow/.questBubble ci-dessus et static/style.css) mais
|
||||
// un type de ligne différent (voir db/quests/sanitize_quest_dialogues.py) :
|
||||
// header = nombre de choix + récompense (type + montant), body = la
|
||||
// question et ses choix, avec la bonne réponse cochée. Aucune limite au
|
||||
// nombre de questions par colonne (voir questAddQuestion), comme pour
|
||||
// une réplique.
|
||||
function questQuestionChoiceRowHtml(choice, choiceIndex, correctIndex, status, index) {
|
||||
var radioName = 'questCorrect-' + status + '-' + index;
|
||||
return (
|
||||
'<div class="questChoiceRow">' +
|
||||
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
|
||||
(choiceIndex === correctIndex ? 'checked' : '') +
|
||||
' onchange="questSetQuestionCorrectIndex(\'' + status + '\', ' + index + ', ' + choiceIndex + ')">' +
|
||||
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + questEscAttr(choice) + '" ' +
|
||||
'onchange="questSetQuestionChoiceText(\'' + status + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questQuestionBubbleHtml(line, status, index) {
|
||||
var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-question">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<label class="questQuestionHeaderField" title="Nombre de choix"><input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||||
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
|
||||
'<label class="questQuestionHeaderField" title="Récompense (points de score)"><input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||||
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + questEscAttr(line.text) + '" ' +
|
||||
'onchange="questUpdateQuestionText(\'' + status + '\', ' + index + ', this.value)">' +
|
||||
'<div class="questChoicesList">' + choicesHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questDialogueColumnHtml(quest, status) {
|
||||
var lines = (quest.dialogues && quest.dialogues[status]) || [];
|
||||
var bubblesHtml = lines.map(function (line, i) {
|
||||
return line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i);
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
|
||||
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
|
||||
'<div class="questDialogueColumnButtons">' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">Réplique</button>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">Question</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Modale de dialogue — PLEIN ÉCRAN (voir .questDialogueModal en CSS),
|
||||
// ouverte DEPUIS la modale de fiche (bouton "💬 Ajouter les dialogues"),
|
||||
// jamais les deux à la fois : referme la fiche en s'ouvrant plutôt que de
|
||||
// les empiler, "← Fiche de la quête" y ramène.
|
||||
function ensureQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal questDialogueModal';
|
||||
modal.id = 'questDialogueModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeQuestDialogueModal()"></div>' +
|
||||
'<div class="modal-content questDialogueModalContent"><div class="box">' +
|
||||
'<div id="questDialogueModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function openQuestDialogueModal(questId) {
|
||||
_questModalCurrentId = questId;
|
||||
closeQuestFieldsModal();
|
||||
var modal = ensureQuestDialogueModal();
|
||||
modal.classList.add('is-active');
|
||||
renderQuestDialogueModalBody(questId);
|
||||
}
|
||||
|
||||
function closeQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function questBackToFieldsModal(questId) {
|
||||
closeQuestDialogueModal();
|
||||
openQuestFieldsModal(questId, _questModalOnUse);
|
||||
}
|
||||
|
||||
function renderQuestDialogueModalBody(questId) {
|
||||
var quest = questById(questId);
|
||||
var body = document.getElementById('questDialogueModalBody');
|
||||
if (!quest || !body) return;
|
||||
_questModalCurrentId = questId;
|
||||
var useBtn = _questModalOnUse
|
||||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
|
||||
: '';
|
||||
body.innerHTML =
|
||||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||||
'<div style="display:flex; align-items:center; gap:10px;">' +
|
||||
'<button type="button" class="button is-small" onclick="questBackToFieldsModal(' + quest.id + ')" title="Retour à la fiche">←</button>' +
|
||||
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '<') + ' — dialogues</h2>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
|
||||
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<p class="hint mt-2 mb-2">Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :</p>' +
|
||||
'<div class="questDialogueColumns">' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '</div>';
|
||||
}
|
||||
|
||||
function questUpdateField(questId, field, value) {
|
||||
var quest = questById(questId);
|
||||
if (!quest) return;
|
||||
var body = {};
|
||||
body[field] = field === 'recompense_score' ? Number(value) : (value || null);
|
||||
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) { 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
|
||||
});
|
||||
}
|
||||
|
||||
// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir
|
||||
// db/quests/set_quest_dialogues.py) — jamais une réplique isolée.
|
||||
// N'ADOPTE JAMAIS la réponse du serveur comme nouvel état local (bug
|
||||
// corrigé : sanitize_quest_dialogues.py rejette toute réplique au texte
|
||||
// encore vide — juste ajoutée, pas encore tapée — donc chaque
|
||||
// enregistrement renvoyait un tableau amputé de la bulle en cours
|
||||
// d'écriture ; l'adopter effaçait silencieusement la bulle que l'auteur
|
||||
// venait d'ajouter, et le clic suivant sur "+ Réplique" semblait la
|
||||
// "remplacer" au lieu d'en ajouter une seconde). L'état local, construit
|
||||
// par des mutations client explicites, est déjà la seule source de
|
||||
// vérité pendant l'édition.
|
||||
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
|
||||
// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations")
|
||||
// — juste un défaut de départ, l'auteur choisit ensuite librement dans
|
||||
// le menu déroulant de la réplique.
|
||||
function questNextDefaultSpeaker(lastSpeaker) {
|
||||
if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
|
||||
return 'Joueur';
|
||||
}
|
||||
|
||||
function questAddBubble(status) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues = quest.dialogues || {};
|
||||
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||
lines.push({ type: 'dialogue', speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
// "❓ Question" — voir questQuestionBubbleHtml ci-dessus. Défauts : 2
|
||||
// choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt
|
||||
// (l'auteur règle le montant dans le header de la bulle).
|
||||
function questAddQuestion(status) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues = quest.dialogues || {};
|
||||
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
||||
lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questUpdateQuestionText(status, index, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].text = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
// Ajuste le NOMBRE de choix (2 à 8, voir db/quests/sanitize_quest_dialogues.py
|
||||
// ::_MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les
|
||||
// derniers, sans jamais toucher aux choix déjà remplis qui restent dans
|
||||
// la nouvelle taille. Recadre la bonne réponse sur le premier choix si
|
||||
// elle pointait vers un choix retiré.
|
||||
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(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;
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionChoiceText(status, index, choiceIndex, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].choices[choiceIndex] = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionCorrectIndex(status, index, choiceIndex) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].correct_index = choiceIndex;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionRewardAmount(status, index, value) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetBubbleSpeaker(status, index, speaker) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].speaker = speaker;
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questUpdateBubbleText(status, index, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].text = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questDeleteBubble(status, index) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status].splice(index, 1);
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function _currentQuestModalId() {
|
||||
return _questModalCurrentId;
|
||||
}
|
||||
|
||||
// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ----------
|
||||
// Affiche des cartes + "+ Nouvelle quête" DANS la modale de collision
|
||||
// déjà ouverte (voir renderCollisionWizardActionStep) ; choisir/créer une
|
||||
// quête ouvre ENSUITE la modale de FICHE (celle-ci par-dessus), avec un
|
||||
// bouton "✓ Utiliser cette quête" (disponible aussi depuis la modale de
|
||||
// dialogue) qui referme tout et finalise la règle — exactement le
|
||||
// comportement demandé : cliquer "Déclencher une quête" ouvre l'éditeur
|
||||
// de quête, pas un simple champ texte.
|
||||
function questPickerHtml(quests) {
|
||||
if (!quests.length) {
|
||||
return '<p class="hint mb-2">Aucune quête pour l\'instant.</p>';
|
||||
}
|
||||
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
|
||||
'<span>' + (q.title || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var _questPickerOnUse = null;
|
||||
|
||||
function renderQuestPickerStep(onUse) {
|
||||
_questPickerOnUse = onUse;
|
||||
var body = collisionWizardBodyEl();
|
||||
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">Nouvelle quête</button>';
|
||||
fetch('/game/' + GAME_SLUG + '/quests/list.json')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS = json.quests;
|
||||
var list = document.getElementById('questPickerList');
|
||||
if (list) list.innerHTML = questPickerHtml(QUESTS);
|
||||
});
|
||||
}
|
||||
|
||||
function questPickerChoose(questId) {
|
||||
openQuestFieldsModal(questId, _questPickerOnUse);
|
||||
}
|
||||
|
||||
function questPickerCreate() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
openQuestFieldsModal(json.quest.id, _questPickerOnUse);
|
||||
});
|
||||
}
|
||||
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "🗺️ Quêtes" restait vide après un remplacement de <main> (ex.
|
||||
// suppression d'un objet de scène, voir scene_object_delete.py) qui ne
|
||||
// redéclenche jamais cet événement.
|
||||
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
|
||||
@@ -1,185 +1,77 @@
|
||||
// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ----------
|
||||
// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage
|
||||
// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/
|
||||
// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà
|
||||
// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais
|
||||
// un vrai formulaire à plusieurs champs) : un clic choisit une option, la
|
||||
// carte suivante s'emboîte aussitôt. Voir screens/rendering/
|
||||
// collision_rules.py pour la forme exacte d'une règle (côté serveur, où
|
||||
// elle est validée une seconde fois avant d'être enregistrée).
|
||||
// ---------- Assistant "déclencheur -> action" (fusionné dans l'onglet "Déclencheurs") ----------
|
||||
// L'onglet "🧩 Collision" séparé a été retiré (redondant une fois la
|
||||
// vue/gestion des déclencheurs intégrée à l'onglet "Déclencheurs", voir
|
||||
// static/js/triggers/trigger-editor.js::openAddTriggerModal) — ce
|
||||
// fichier ne porte plus que le MOTEUR de l'assistant lui-même
|
||||
// (déclencheur -> action, un clic choisit une option, la carte suivante
|
||||
// s'emboîte aussitôt), invoqué exclusivement via openTriggerWizard()
|
||||
// ci-dessous. Voir screens/rendering/collision_rules.py pour la forme
|
||||
// exacte d'une règle (côté serveur, où elle est validée une seconde fois
|
||||
// avant d'être enregistrée).
|
||||
|
||||
var COLLISION_ACTION_LABELS = {
|
||||
quete: 'Déclencher une quête', attaque: 'Attaquer la cible',
|
||||
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche',
|
||||
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||
interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
|
||||
};
|
||||
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le
|
||||
// moment, demande explicite) — server-side (screens/rendering/
|
||||
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles,
|
||||
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux.
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' };
|
||||
|
||||
function collisionRuleObjectLabel(obj) {
|
||||
var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor';
|
||||
return (obj.name || kindLabel);
|
||||
}
|
||||
|
||||
// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte
|
||||
// (déclencheur, puis action, puis éventuellement sous-action) — texte
|
||||
// seul, la logique de lecture vit dans collisionRuleActionSummary/
|
||||
// collisionRuleTriggerSummary ci-dessous.
|
||||
function collisionRuleTriggerSummary(rule) {
|
||||
if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)';
|
||||
return COLLISION_TRIGGER_LABELS.collision;
|
||||
}
|
||||
|
||||
function collisionRuleActionSummary(action) {
|
||||
if (action.type === 'quete') {
|
||||
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
|
||||
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
|
||||
}
|
||||
if (action.type === 'evenement') {
|
||||
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
|
||||
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
|
||||
}
|
||||
return COLLISION_ACTION_LABELS[action.type] || action.type;
|
||||
}
|
||||
|
||||
// Une règle = une chaîne de "maillons" en forme de pièces qui s'emboîtent
|
||||
// (déclencheur -> action -> sous-action éventuelle), pas une liste plate —
|
||||
// chaque maillon porte un data-kind (couleur en CSS) et un délai d'entrée
|
||||
// en cascade (voir @keyframes collisionLinkIn, static/style.css).
|
||||
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
|
||||
var links = [
|
||||
{ kind: 'trigger', text: collisionRuleTriggerSummary(rule) },
|
||||
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action), questId: rule.action.quete_id },
|
||||
];
|
||||
var action = rule.action;
|
||||
while (action.type === 'interagir' && action.sub_action) {
|
||||
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action), questId: action.sub_action.quete_id });
|
||||
action = action.sub_action;
|
||||
}
|
||||
// Un maillon "quete" (kind==='quete') ouvre directement sa modale de
|
||||
// dialogue (voir openQuestDialogueModal(), static/js/quests/
|
||||
// quest-editor.js) au clic — demande explicite, plutôt qu'un simple
|
||||
// texte informatif comme les autres maillons. Reste un <span> comme les
|
||||
// autres (jamais un <button>, dont le style natif — apparence système,
|
||||
// marges par défaut — grignotait la pointe en clip-path malgré les
|
||||
// resets CSS) : un onclick sur un <span> suffit à le rendre cliquable,
|
||||
// ".collisionRuleLink[data-kind='quete']" ajoute juste le curseur/hover
|
||||
// qui le distingue visuellement (voir static/style.css).
|
||||
var linksHtml = links.map(function (link, i) {
|
||||
var clickable = link.kind === 'quete' && link.questId != null;
|
||||
var onclick = clickable ? ' onclick="openQuestDialogueModal(' + link.questId + ')"' : '';
|
||||
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms"' + onclick + '>' +
|
||||
link.text.replace(/</g, '<') + '</span>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="collisionRuleChain">' +
|
||||
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
|
||||
'<button type="button" class="collisionRuleChainDelete" title="Supprimer cette règle" ' +
|
||||
'onclick="deleteCollisionRule(' + objectId + ', ' + ruleIndex + ')">🗑️</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
|
||||
// Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||
// pour celles-ci.
|
||||
var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||
|
||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
|
||||
// dans la scène (left/top de plusieurs centaines de px) ressortait donc
|
||||
// hors du petit cadre 120×120 de cette miniature : la règle CSS
|
||||
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le
|
||||
// neutraliser (position:static !important etc.), mais s'appuyer sur un
|
||||
// forçage CSS pour annuler un style inline entier reste fragile. Plus
|
||||
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style
|
||||
// hérité du canevas — seul le src (l'image elle-même) est repris.
|
||||
// hors du petit cadre de la miniature : la règle CSS `.questTriggerObjectCard
|
||||
// img`/`.questTriggerObjectThumb img` (static/style.css) est censée le
|
||||
// neutraliser, mais s'appuyer sur un forçage CSS pour annuler un style
|
||||
// inline entier reste fragile. Plus robuste : reconstruire ici une balise
|
||||
// <img> "propre", sans AUCUN style hérité du canevas — seul le src
|
||||
// (l'image elle-même) est repris. Utilisée par static/js/triggers/
|
||||
// trigger-editor.js (chaîne de bulles + grille de choix d'objet).
|
||||
function collisionRuleThumbHtml(obj) {
|
||||
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||||
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
||||
}
|
||||
|
||||
function collisionRuleCardHtml(obj) {
|
||||
var rulesHtml = obj.rules.length
|
||||
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
|
||||
: '<p class="hint">Aucune règle pour l\'instant.</p>';
|
||||
return (
|
||||
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
|
||||
'<div class="collisionRuleCardHeader">' +
|
||||
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' +
|
||||
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '<') + '</h3>' +
|
||||
'</div>' +
|
||||
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
|
||||
'</div>'
|
||||
);
|
||||
// Id court généré CÔTÉ CLIENT pour une NOUVELLE feuille ("dialogue" ou
|
||||
// "variable", voir screens/rendering/collision_rules.py) — jamais
|
||||
// recalculé/réattribué côté serveur. Pour un dialogue, sert de
|
||||
// dialogue.id (édité en ligne, voir trigger-editor.js) ; pour une
|
||||
// variable, sert d'id à la feuille elle-même — dans les deux cas,
|
||||
// permet d'adresser CETTE feuille précise pour y attacher un "then"
|
||||
// plus tard (voir "+ Ajouter une action", trigger-editor.js).
|
||||
function collisionGenerateActionId() {
|
||||
return 'd_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
|
||||
function renderCollisionRuleCards() {
|
||||
var container = document.getElementById('collisionRulesCards');
|
||||
if (!container) return;
|
||||
if (!COLLISION_RULE_OBJECTS.length) {
|
||||
container.innerHTML = '<p class="hint">Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).</p>';
|
||||
return;
|
||||
}
|
||||
// Chaque carte entre en cascade (voir @keyframes collisionCardIn,
|
||||
// static/style.css) plutôt que toutes en même temps.
|
||||
container.innerHTML = COLLISION_RULE_OBJECTS.map(function (obj, i) {
|
||||
return collisionRuleCardHtml(obj).replace('<div class="collisionRuleCard"', '<div style="animation-delay:' + (i * 50) + 'ms" class="collisionRuleCard"');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collisionRuleObjectById(objectId) {
|
||||
return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; });
|
||||
}
|
||||
|
||||
// Enregistre la liste COMPLÈTE des règles de cet objet (voir
|
||||
// routes/scenes/scene_object_collision_rules.py — toujours la liste
|
||||
// entière, jamais une règle isolée) puis redessine SA carte.
|
||||
function saveCollisionRules(objectId) {
|
||||
var obj = collisionRuleObjectById(objectId);
|
||||
if (!obj) return;
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rules: obj.rules }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules)
|
||||
renderCollisionRuleCards();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCollisionRule(objectId, ruleIndex) {
|
||||
var obj = collisionRuleObjectById(objectId);
|
||||
if (!obj) return;
|
||||
obj.rules.splice(ruleIndex, 1);
|
||||
saveCollisionRules(objectId);
|
||||
}
|
||||
|
||||
// ---------- Assistant "+ Action" ----------
|
||||
// ---------- Assistant "déclencheur -> action" ----------
|
||||
// État du parcours en cours — un seul à la fois (la modale n'autorise
|
||||
// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi,
|
||||
// et l'action en cours de construction (posée directement dans
|
||||
// `action`, ou dans `action.sub_action` au second niveau pour "interagir").
|
||||
// Plus de "quête cible" à connaître d'avance — la règle est posée sur
|
||||
// l'objet, un déclencheur "dialogue" se suffit à lui-même.
|
||||
var _collisionWizard = null;
|
||||
|
||||
function openCollisionRuleModal(objectId) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX };
|
||||
document.getElementById('collisionRuleModal').classList.add('is-active');
|
||||
// Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
|
||||
// (voir triggerSelectObject, trigger-editor.js).
|
||||
function openTriggerWizard(objectId, bodyEl) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
function closeCollisionRuleModal() {
|
||||
document.getElementById('collisionRuleModal').classList.remove('is-active');
|
||||
_collisionWizard = null;
|
||||
}
|
||||
|
||||
function collisionWizardBodyEl() {
|
||||
return document.getElementById('collisionRuleModalBody');
|
||||
return _collisionWizard.bodyEl;
|
||||
}
|
||||
|
||||
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
|
||||
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
|
||||
// Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
|
||||
// carte à choix reste affichée telle quelle (plutôt que sautée
|
||||
// automatiquement) pour rester extensible à de futurs types sans rien
|
||||
// changer à la structure de l'assistant.
|
||||
function renderCollisionWizardTriggerStep() {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||||
@@ -187,22 +79,11 @@ function renderCollisionWizardTriggerStep() {
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.perimetre + '</span>' +
|
||||
'<span class="fieldRow" style="align-items:center; gap:6px;" onclick="event.stopPropagation()">' +
|
||||
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:64px;" class="input is-small">' +
|
||||
'<span class="hint">px</span>' +
|
||||
'</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseTrigger(trigger) {
|
||||
_collisionWizard.trigger = trigger;
|
||||
if (trigger === 'perimetre') {
|
||||
var pxEl = document.getElementById('collisionWizardPerimetrePx');
|
||||
_collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX;
|
||||
}
|
||||
renderCollisionWizardActionStep(false);
|
||||
}
|
||||
|
||||
@@ -240,54 +121,233 @@ function collisionWizardFinalizeAction(action, nested) {
|
||||
collisionWizardSaveRule(action);
|
||||
}
|
||||
|
||||
// Enregistre via la route /game/<slug>/triggers (routes/triggers/triggers.py)
|
||||
// — même contrat "liste ENTIÈRE" que l'ancien éditeur de collision
|
||||
// (recharge les règles existantes de l'objet, y ajoute la nouvelle,
|
||||
// sauvegarde tout), géré côté serveur : le client ne poste que la
|
||||
// nouvelle règle + l'id d'objet.
|
||||
function collisionWizardSaveRule(action) {
|
||||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||||
if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx;
|
||||
var obj = collisionRuleObjectById(_collisionWizard.objectId);
|
||||
obj.rules.push(rule);
|
||||
var objectId = _collisionWizard.objectId;
|
||||
closeCollisionRuleModal();
|
||||
saveCollisionRules(objectId);
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// Chaque type d'action demande éventuellement une précision avant de
|
||||
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
|
||||
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
|
||||
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
|
||||
// plus, se finalise directement.
|
||||
// "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py) enchaînent sur "Ensuite ?" (voir
|
||||
// renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
|
||||
// action, demande explicite : "pouvoir modifier un déclencheur pour lui
|
||||
// ajouter des actions") ; "interagir" enchaîne sur une carte de plus
|
||||
// (voir collisionWizardFinalizeAction) ; "condition" enchaîne sur son
|
||||
// propre mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'attaque' || type === 'interagir') {
|
||||
if (type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'quete') {
|
||||
renderQuestPickerStep(function (quest) {
|
||||
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'evenement') {
|
||||
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
|
||||
if (!eventIds.length) {
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "Événements", puis reviens ici.</p>';
|
||||
return;
|
||||
}
|
||||
var buttons = eventIds.map(function (id, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">' +
|
||||
'<span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
if (type === 'condition') {
|
||||
renderCollisionWizardConditionStep(nested);
|
||||
}
|
||||
}
|
||||
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "🧩 Collision" restait vide après un remplacement de <main>
|
||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||||
// ne redéclenche jamais cet événement.
|
||||
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|
||||
// ---------- Étape "Modifier une variable" ----------
|
||||
// Même vocabulaire EXACT que l'action "Modifier une variable" du graphe
|
||||
// de logique document (GLOBAL_VARIABLE_NAMES et DATA_OPERATION_LABELS_MAP
|
||||
// déjà posés par templates/scene_edit.html — ce dernier n'était utilisé
|
||||
// nulle part avant cette fonctionnalité) : variable ciblée, opération,
|
||||
// valeur (masquée pour les opérations qui n'en ont pas besoin, voir
|
||||
// COLLISION_NO_VALUE_OPERATIONS). `onChoose(action)`.
|
||||
function renderCollisionWizardVariableStep(onChoose) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operationOptions = Object.keys(DATA_OPERATION_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + DATA_OPERATION_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Modifier...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="varWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="varWizOperation" onchange="collisionWizardVariableOperationChanged()">' + operationOptions + '</select>' +
|
||||
'<input type="text" id="varWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardVariableNext()">Continuer</button>';
|
||||
_collisionWizard.onVariableChosen = onChoose;
|
||||
collisionWizardVariableOperationChanged();
|
||||
}
|
||||
|
||||
function collisionWizardVariableOperationChanged() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var valueInput = document.getElementById('varWizValue');
|
||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function collisionWizardVariableNext() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var action = {
|
||||
type: 'variable', id: collisionGenerateActionId(),
|
||||
target_variable: document.getElementById('varWizVariable').value,
|
||||
data_operation: operation,
|
||||
};
|
||||
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
|
||||
action.data_value = document.getElementById('varWizValue').value;
|
||||
}
|
||||
var onChoose = _collisionWizard.onVariableChosen;
|
||||
_collisionWizard.onVariableChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
|
||||
// ---------- Étape "Ensuite ?" (chaînage + Marquer terminé) ----------
|
||||
// Après CHAQUE feuille construite (dialogue ou variable, au premier
|
||||
// niveau, sous "interagir", ou dans une branche de condition) : la case
|
||||
// "Marquer terminé" (seulement pour un dialogue) PUIS le choix de
|
||||
// chaîner une autre action à la suite (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — jamais bloqué à une seule
|
||||
// action par déclencheur. `onDone(leafAction)` reçoit la PREMIÈRE
|
||||
// feuille de la chaîne (les suivantes sont attachées via `.then`).
|
||||
function renderCollisionWizardChainStep(leafAction, onDone) {
|
||||
var completionHtml = leafAction.type === 'dialogue'
|
||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">' +
|
||||
'<input type="checkbox" id="condWizMarkCompleted"' + (leafAction.mark_completed ? ' checked' : '') + '> Marquer ce dialogue comme terminé une fois joué' +
|
||||
'</label>'
|
||||
: '';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
completionHtml +
|
||||
'<p class="hint mb-2">Ensuite ?</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChainDone(\'none\')"><span>Rien de plus</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChainDone(\'dialogue\')"><span>Déclencher un dialogue</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChainDone(\'variable\')"><span>Modifier une variable</span></button>' +
|
||||
'</div>';
|
||||
_collisionWizard.chainLeaf = leafAction;
|
||||
_collisionWizard.onChainDone = onDone;
|
||||
}
|
||||
|
||||
function collisionWizardChainDone(choice) {
|
||||
var leafAction = _collisionWizard.chainLeaf;
|
||||
var onDone = _collisionWizard.onChainDone;
|
||||
if (leafAction.type === 'dialogue') {
|
||||
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
|
||||
else delete leafAction.mark_completed;
|
||||
}
|
||||
if (choice === 'none') {
|
||||
onDone(leafAction);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var nextLeaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
leafAction.then = nextLeaf;
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone);
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
leafAction.then = action;
|
||||
renderCollisionWizardChainStep(action, onDone);
|
||||
});
|
||||
}
|
||||
|
||||
// Étape "condition" — variable/opérateur/valeur (même vocabulaire que le
|
||||
// nœud Condition de l'éditeur de logique, voir screens/flow/constants.py
|
||||
// ::CONDITION_OPERATORS ; GLOBAL_VARIABLE_NAMES et
|
||||
// CONDITION_OPERATOR_LABELS_MAP posés par templates/scene_edit.html).
|
||||
// `nested` (interagir -> condition) est reporté jusqu'à la finalisation,
|
||||
// une fois les deux branches Vrai/Faux choisies ci-dessous.
|
||||
function renderCollisionWizardConditionStep(nested) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operatorOptions = Object.keys(CONDITION_OPERATOR_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + CONDITION_OPERATOR_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Si...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="condWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="condWizOperator">' + operatorOptions + '</select>' +
|
||||
'<input type="text" id="condWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardConditionNext(' + (nested ? 'true' : 'false') + ')">Continuer</button>';
|
||||
}
|
||||
|
||||
function collisionWizardConditionNext(nested) {
|
||||
_collisionWizard.pendingCondition = {
|
||||
type: 'condition',
|
||||
variable: document.getElementById('condWizVariable').value,
|
||||
operateur: document.getElementById('condWizOperator').value,
|
||||
valeur: document.getElementById('condWizValue').value,
|
||||
si_vrai: null, si_faux: null,
|
||||
};
|
||||
_collisionWizard.pendingConditionNested = nested;
|
||||
renderCollisionWizardBranchStep('si_vrai');
|
||||
}
|
||||
|
||||
// Étape branche (Vrai puis Faux) — chaque branche est soit "Ne rien
|
||||
// faire", soit une FEUILLE ("Déclencher un dialogue" ou "Modifier une
|
||||
// variable", voir screens/rendering/collision_rules.py::
|
||||
// _sanitize_condition_branch : une branche reste une feuille, jamais une
|
||||
// nouvelle "interagir"/"condition" imbriquée) — les DEUX branches
|
||||
// peuvent chacune déclencher LEUR PROPRE feuille (et sa propre chaîne),
|
||||
// indépendamment l'une de l'autre.
|
||||
function renderCollisionWizardBranchStep(branchKey) {
|
||||
var label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + label + '</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
'<span>Ne rien faire</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseBranch(branchKey, choice) {
|
||||
if (choice === 'none') {
|
||||
_collisionWizard.pendingCondition[branchKey] = null;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collisionWizardBranchDone(branchKey) {
|
||||
if (branchKey === 'si_vrai') {
|
||||
renderCollisionWizardBranchStep('si_faux');
|
||||
return;
|
||||
}
|
||||
var nested = _collisionWizard.pendingConditionNested;
|
||||
var condition = _collisionWizard.pendingCondition;
|
||||
_collisionWizard.pendingCondition = null;
|
||||
collisionWizardFinalizeAction(condition, nested);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
||||
'<div class="canvasElementInner">' + json.rendered_html + '</div>' +
|
||||
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
|
||||
canvas.appendChild(el);
|
||||
refreshCollisionRuleObjects();
|
||||
if (json.animations) {
|
||||
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
||||
restartPersonnageIdlePreview(json.id, json.animations);
|
||||
@@ -87,7 +86,6 @@ function deleteSceneObject(id) {
|
||||
var el = document.getElementById('el-' + id);
|
||||
if (el) el.remove();
|
||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||
refreshCollisionRuleObjects();
|
||||
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
|
||||
// est affiché) — referme le panneau de propriétés et l'URL, sans
|
||||
// requête supplémentaire au serveur.
|
||||
@@ -355,24 +353,6 @@ function saveSceneObjectRole(role) {
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role }),
|
||||
}).then(refreshCollisionRuleObjects);
|
||||
}
|
||||
|
||||
// Réutilisé après ajout/suppression/changement de rôle ou de nom (voir
|
||||
// routes/scenes/scene_collision_rule_objects.py) : un changement de rôle
|
||||
// peut faire apparaître/disparaître une carte (passage à/depuis "joueur",
|
||||
// jamais dans "🧩 Collision") et un changement de nom change son libellé
|
||||
// affiché — recalculée entièrement côté serveur plutôt que rejouée en JS
|
||||
// (même logique déjà nécessaire pour le rôle implicite d'une scène posée
|
||||
// avant ce réglage, voir build_collision_rule_objects) SANS recharger la
|
||||
// page.
|
||||
function refreshCollisionRuleObjects() {
|
||||
if (typeof COLLISION_RULE_OBJECTS === 'undefined') return;
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/collision-rule-objects')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
COLLISION_RULE_OBJECTS = json;
|
||||
renderCollisionRuleCards();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -579,26 +559,24 @@ function addPersonnageCommandExtraRow() {
|
||||
}
|
||||
|
||||
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
|
||||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de
|
||||
// quête (voir static/js/quests/quest-editor.js).
|
||||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
|
||||
// déclencheur (voir static/js/triggers/trigger-editor.js).
|
||||
function saveSceneObjectName(objectId, name) {
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name }),
|
||||
}).then(function () {
|
||||
refreshCollisionRuleObjects();
|
||||
refreshSceneObjectNames();
|
||||
});
|
||||
}).then(refreshSceneObjectNames);
|
||||
}
|
||||
|
||||
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
|
||||
// posée UNE SEULE FOIS au chargement complet de la page (<script> inline
|
||||
// en bas, HORS de #builder3, jamais rafraîchie par une simple sélection
|
||||
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
|
||||
// renommer un personnage puis ouvrir la modale de dialogue dans la MÊME
|
||||
// session ne faisait jamais apparaître son nouveau nom dans "qui parle"
|
||||
// (voir questSpeakerOptions(), static/js/quests/quest-editor.js) tant
|
||||
// qu'aucun vrai rechargement de page n'avait eu lieu entre temps.
|
||||
// renommer un personnage puis ouvrir un dialogue de déclencheur dans la
|
||||
// MÊME session ne faisait jamais apparaître son nouveau nom dans "qui
|
||||
// parle" (voir triggerSpeakerOptions(), static/js/triggers/
|
||||
// trigger-editor.js) tant qu'aucun vrai rechargement de page n'avait eu
|
||||
// lieu entre temps.
|
||||
function refreshSceneObjectNames() {
|
||||
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
||||
fetch('/game/' + GAME_SLUG + '/scene-object-names')
|
||||
@@ -721,21 +699,16 @@ initPersonnageCommandsPanel();
|
||||
// viennent d'être recréés par le même remplacement, voir
|
||||
// startAllPersonnagePreviews()/personnage-preview.js).
|
||||
//
|
||||
// .builderCanvasArea (les onglets "🖥️ Scène"/"🧩 Collision"/"🗺️ Quêtes"/
|
||||
// "📣 Événements") est ENTIÈREMENT à l'intérieur de #builder3 (voir
|
||||
// templates/scene_edit.html) — le remplacer réinjecte donc aussi
|
||||
// #collisionRulesCards/le conteneur des quêtes tels que le SERVEUR les
|
||||
// rend, c'est-à-dire VIDES (ces listes sont rendues entièrement en JS,
|
||||
// jamais en Jinja, voir collision-rules-editor.js/quest-editor.js) : sans
|
||||
// les redessiner ici, les onglets "🧩 Collision"/"🗺️ Quêtes" semblaient se
|
||||
// "vider" à chaque sélection d'un objet de scène. COLLISION_RULE_OBJECTS/
|
||||
// QUESTS_JSON eux-mêmes ne sont PAS affectés par ce remplacement (déclarés
|
||||
// dans le <script> inline en bas de page, HORS de #builder3) — il suffit
|
||||
// donc de rejouer leur rendu, pas de les recharger.
|
||||
// .builderCanvasArea (les onglets "🖥️ Scène"/"Déclencheurs"/"📣 Événements")
|
||||
// est ENTIÈREMENT à l'intérieur de #builder3 (voir templates/scene_edit.html)
|
||||
// — le remplacer réinjecte donc aussi le conteneur des déclencheurs tel
|
||||
// que le SERVEUR le rend, c'est-à-dire VIDE (rendu entièrement en JS,
|
||||
// jamais en Jinja, voir trigger-editor.js) : sans le redessiner ici,
|
||||
// l'onglet "Déclencheurs" semblait se "vider" à chaque sélection d'un
|
||||
// objet de scène.
|
||||
function initSceneBuilderPanel() {
|
||||
initScenePosFields();
|
||||
initPersonnageCommandsPanel();
|
||||
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
||||
if (typeof renderCollisionRuleCards === 'function') renderCollisionRuleCards();
|
||||
if (typeof renderQuestListPage === 'function') renderQuestListPage();
|
||||
if (typeof initTriggersTab === 'function') initTriggersTab();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// ---------- Onglet "Déclencheurs" ----------
|
||||
// templates/scene_edit.html : un GRAPHE DE LOGIQUE lu par déclencheur
|
||||
// (demande explicite : "un RH doit le comprendre, un visuel global") —
|
||||
// TOUS les déclencheurs du JEU ENTIER vivent côte à côte dans la même
|
||||
// colonne (plus de "quête" pour les regrouper, voir screens/rendering/
|
||||
// trigger_graph.py::list_all_triggers), chacun avec SON PROPRE dialogue
|
||||
// embarqué ({id, lines}, voir screens/rendering/collision_rules.py::
|
||||
// "dialogue".dialogue). Les boutons Réplique/Question sont posés
|
||||
// DIRECTEMENT sur le rectangle de dialogue de chaque déclencheur — le
|
||||
// seul bouton au bas de la colonne est "Ajouter un déclencheur". Le
|
||||
// créateur décide lui-même, via ses variables globales et conditions
|
||||
// (voir screens/rendering/collision_rules.py::"condition"), QUAND
|
||||
// déclencher quel dialogue et QUAND marquer un dialogue comme terminé
|
||||
// (action.mark_completed, voir static/js/play/dialogue-box-controller.js).
|
||||
|
||||
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
|
||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
||||
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
|
||||
|
||||
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
|
||||
// .questBubble-pnj, static/style.css) même si le nom affiché est
|
||||
// n'importe quel objet nommé — "Joueur" en vert, n'importe quel autre nom
|
||||
// en bleu.
|
||||
function triggerBubbleKind(speaker) {
|
||||
return speaker === 'Joueur' ? 'joueur' : 'pnj';
|
||||
}
|
||||
|
||||
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
|
||||
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
|
||||
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
|
||||
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
|
||||
// valeur déjà enregistrée.
|
||||
function triggerSpeakerOptions(currentSpeaker) {
|
||||
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||
return options;
|
||||
}
|
||||
|
||||
function triggerEscAttr(str) {
|
||||
return (str || '').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
||||
function triggerColumnHtml() {
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<div class="questDialogueColumnBody" id="triggerColumnBody">' +
|
||||
'<div class="questTriggersSlot" id="triggersSlot"></div>' +
|
||||
'</div>' +
|
||||
'<div class="questDialogueColumnButtons">' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openAddTriggerModal()">Ajouter un déclencheur</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Dialogue INLINE (voir screens/rendering/collision_rules.py
|
||||
// ::"dialogue".dialogue) ----------
|
||||
// Un dialogue = {id, lines} porté PAR L'ACTION elle-même — rendu comme un
|
||||
// rectangle à bords arrondis, MÊME largeur que le reste de la chaîne
|
||||
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
|
||||
// ajoute une bulle directement dedans.
|
||||
|
||||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||||
// distinctes, pas un seul bloc.
|
||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
var kind = triggerBubbleKind(line.speaker);
|
||||
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
|
||||
return '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-' + kind + '">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="triggerInlineSetBubbleSpeaker(\'' + dialogueId + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="triggerInlineUpdateBubbleText(\'' + dialogueId + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
|
||||
// dialogue (voir .questBubbleRow/.questBubble et static/style.css) mais
|
||||
// un type de ligne différent (voir db/dialogue_lines.py) : header =
|
||||
// nombre de choix + récompense (type + montant), body = la question et
|
||||
// ses choix, avec la bonne réponse cochée.
|
||||
function triggerQuestionChoiceRowHtml(dialogueId, choice, choiceIndex, correctIndex, index) {
|
||||
var radioName = 'triggerCorrect-' + dialogueId + '-' + index;
|
||||
return (
|
||||
'<div class="questChoiceRow">' +
|
||||
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
|
||||
(choiceIndex === correctIndex ? 'checked' : '') +
|
||||
' onchange="triggerInlineSetQuestionCorrectIndex(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ')">' +
|
||||
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + triggerEscAttr(choice) + '" ' +
|
||||
'onchange="triggerInlineSetQuestionChoiceText(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function triggerQuestionBubbleHtml(dialogueId, line, index) {
|
||||
var choicesHtml = line.choices.map(function (c, i) { return triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index); }).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-question">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Nombre de choix</span>' +
|
||||
'<input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||||
'onchange="triggerInlineSetQuestionChoiceCount(\'' + dialogueId + '\', ' + index + ', this.value)"></label>' +
|
||||
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Points gagnés</span>' +
|
||||
'<input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||||
'onchange="triggerInlineSetQuestionRewardAmount(\'' + dialogueId + '\', ' + index + ', this.value)"> pts</label>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + triggerEscAttr(line.text) + '" ' +
|
||||
'onchange="triggerInlineUpdateQuestionText(\'' + dialogueId + '\', ' + index + ', this.value)">' +
|
||||
'<div class="questChoicesList">' + choicesHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Pastille "Déclencher ce dialogue" — visuelle uniquement, la même pour
|
||||
// TOUTES les occurrences : posée juste avant le rectangle de dialogue
|
||||
// lui-même (voir triggerInlineDialogueHtml).
|
||||
function triggerDeclencherDialoguePillHtml() {
|
||||
return '<div class="questTriggerPill quete">Déclencher ce dialogue</div>';
|
||||
}
|
||||
|
||||
// Libellé lisible d'une action "variable" (voir screens/rendering/
|
||||
// collision_rules.py::"variable" et screens/labels/data_operations.py) —
|
||||
// DATA_OPERATION_LABELS_MAP est déjà posé par templates/scene_edit.html.
|
||||
function triggerVariableActionLabel(item) {
|
||||
var opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
||||
var valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ' ' + item.data_value : '';
|
||||
return (item.target_variable || '').replace(/</g, '<') + ' — ' + opLabel.replace(/</g, '<') + valuePart;
|
||||
}
|
||||
|
||||
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
||||
// le reste de la chaîne), pas de bulles dedans contrairement au
|
||||
// rectangle de dialogue.
|
||||
function triggerVariableActionHtml(item) {
|
||||
return (
|
||||
'<div class="dialogueBox questVariableAction">' +
|
||||
'<div class="dialogueBoxHeader">' +
|
||||
'<span class="dialogueBoxTitle">Variable</span>' +
|
||||
'</div>' +
|
||||
'<p class="questVariableActionLabel">' + triggerVariableActionLabel(item) + '</p>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
|
||||
// _describe_chain) : "Déclencher ce dialogue" + son rectangle, ou une
|
||||
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
|
||||
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
|
||||
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
|
||||
// objet (voir triggerOpenAppendActionModal).
|
||||
function triggerActionChainHtml(chain, objectId) {
|
||||
if (!chain || !chain.length) return '';
|
||||
var itemsHtml = chain.map(function (item) {
|
||||
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
|
||||
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
|
||||
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
|
||||
}).join('');
|
||||
var lastItem = chain[chain.length - 1];
|
||||
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
|
||||
var appendBtn = lastLeafId
|
||||
? '<div class="questTriggerConnector"></div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
|
||||
: '';
|
||||
return itemsHtml + appendBtn;
|
||||
}
|
||||
|
||||
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
|
||||
// "dialogue".dialogue pour la forme {id, lines}. `dialogue` peut être
|
||||
// absent (action posée sans passer par l'assistant) : rien n'est rendu
|
||||
// plutôt que de planter, même tolérance que le reste de ce moteur.
|
||||
function triggerInlineDialogueHtml(dialogue) {
|
||||
if (!dialogue) return '';
|
||||
var lines = dialogue.lines || [];
|
||||
var bubblesHtml = lines.map(function (line, i) {
|
||||
return line.type === 'question' ? triggerQuestionBubbleHtml(dialogue.id, line, i) : triggerDialogueBubbleHtml(dialogue.id, line, i);
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="dialogueBox">' +
|
||||
'<div class="dialogueBoxHeader">' +
|
||||
'<span class="dialogueBoxTitle">Dialogue</span>' +
|
||||
'<div class="dialogueBoxActions">' +
|
||||
'<button type="button" class="addReplyBtn" onclick="triggerInlineAddBubble(\'' + dialogue.id + '\')">Réplique</button>' +
|
||||
'<button type="button" class="addReplyBtn addQuestionBtn" onclick="triggerInlineAddQuestion(\'' + dialogue.id + '\')">Question</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
bubblesHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Déclencheurs (voir screens/rendering/trigger_graph.py::
|
||||
// list_all_triggers) ----------
|
||||
// Maquette validée : carte objet + pastilles empilées VERTICALEMENT
|
||||
// reliées par des flèches. Le "Déclencher ce dialogue" se termine par le
|
||||
// rectangle de dialogue lui-même — la flèche pointe DIRECTEMENT dessus.
|
||||
// Quand l'action se termine par une "condition", la chaîne se sépare en
|
||||
// un losange à embranchement Vrai/Faux (voir triggerConditionColumnHtml) —
|
||||
// chaque branche affiche DIRECTEMENT son propre contenu (plus de "branche
|
||||
// qui mène à telle quête" à choisir, chaque branche est indépendante).
|
||||
// collisionRuleThumbHtml (static/js/scenes/collision-rules-editor.js,
|
||||
// chargé sur la même page) reste réutilisé pour la vignette elle-même.
|
||||
|
||||
// Accordéon : object_id -> true si REPLIÉ — par défaut tout est déplié,
|
||||
// absent de cette map = déplié.
|
||||
var _triggerCollapsed = {};
|
||||
// Le déclencheur actif est celui où Réplique/Question ajoutent leur bulle
|
||||
// (surligné, voir .questTriggerCol.active en CSS) — par défaut le
|
||||
// DERNIER dialogue trouvé, mais cliquer sur la carte d'un autre
|
||||
// déclencheur en fait le nouvel actif.
|
||||
var _triggerActiveDialogueId = null;
|
||||
|
||||
function _chainLeafIds(chain) {
|
||||
return (chain || []).map(function (item) { return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id; }).filter(Boolean);
|
||||
}
|
||||
|
||||
function _triggerLeafIds(trigger) {
|
||||
if (trigger.condition) {
|
||||
var vrai = trigger.condition.si_vrai, faux = trigger.condition.si_faux;
|
||||
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
|
||||
}
|
||||
return _chainLeafIds(trigger.chain);
|
||||
}
|
||||
|
||||
function triggerChainHtml(trigger) {
|
||||
var isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
||||
var leafIds = _triggerLeafIds(trigger);
|
||||
var isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
|
||||
var cardOnclick = 'triggerClickObjectCard(' + trigger.object_id + ', ' + (leafIds[0] ? '\'' + leafIds[0] + '\'' : 'null') + ')';
|
||||
var card =
|
||||
'<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="' + cardOnclick + '">' + collisionRuleThumbHtml(trigger) +
|
||||
'<span class="questTriggerObjectName">' + (trigger.object_name || '').replace(/</g, '<') + '</span>' +
|
||||
'<span class="questTriggerCollapseChevron">▾</span>' +
|
||||
'</div>';
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
'<div class="questTriggerCol collapsed">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
|
||||
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
|
||||
card +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
|
||||
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
|
||||
var pillsHtml = pills.map(function (pill, i) {
|
||||
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
|
||||
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
|
||||
}).join('');
|
||||
var tailHtml = trigger.condition
|
||||
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
|
||||
: triggerActionChainHtml(trigger.chain, trigger.object_id);
|
||||
return (
|
||||
'<div class="questTriggerCol' + (isActive ? ' active' : '') + '">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
|
||||
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
|
||||
card +
|
||||
'<div class="questTriggerConnector"></div>' +
|
||||
pillsHtml +
|
||||
tailHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Clic sur la carte objet : replie/déplie TOUTE la logique associée
|
||||
// (accordéon, demande explicite) — et rend ce déclencheur actif au
|
||||
// passage puisqu'on vient d'y porter son attention.
|
||||
function triggerClickObjectCard(objectId, leafId) {
|
||||
_triggerCollapsed[objectId] = !_triggerCollapsed[objectId];
|
||||
if (leafId) _triggerActiveDialogueId = leafId;
|
||||
triggerRenderSlot();
|
||||
}
|
||||
|
||||
// Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||
// "il faut tout mettre verticalement", sans toucher au losange lui-même) :
|
||||
// la branche Vrai descend tout droit vers son "Déclencher ce dialogue"
|
||||
// (ou "Ne rien faire"), PUIS la branche Faux descend à son tour — jamais
|
||||
// deux branches côte à côte. Chaque branche affiche SON PROPRE dialogue
|
||||
// (voir screens/rendering/trigger_graph.py::_resolve_action_trigger),
|
||||
// indépendamment de l'autre.
|
||||
function triggerCondArrowHtml(kind, label) {
|
||||
var color = kind === 'yes' ? '#2ecc71' : '#e0524d';
|
||||
var markerId = 'triggerCondArrow_' + kind + '_' + Math.random().toString(36).slice(2, 8);
|
||||
return (
|
||||
'<div class="questCondArrowWrap">' +
|
||||
'<div class="questCondArrowLabel ' + kind + '">' + label + '</div>' +
|
||||
'<svg viewBox="0 0 34 68" preserveAspectRatio="none" aria-hidden="true">' +
|
||||
'<defs><marker id="' + markerId + '" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">' +
|
||||
'<path d="M0,0 L10,5 L0,10 Z" fill="' + color + '"></path></marker></defs>' +
|
||||
'<line x1="17" y1="0" x2="17" y2="56" stroke="' + color + '" stroke-width="4" marker-end="url(#' + markerId + ')"></line>' +
|
||||
'</svg>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function triggerConditionColumnHtml(condition, objectId) {
|
||||
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||
var label = (condition.variable || '').replace(/</g, '<') + ' ' + (opLabel || '').replace(/</g, '<') + ' ' + (condition.valeur || '').replace(/</g, '<');
|
||||
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
|
||||
var kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
||||
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||
var branch = condition[branchKey];
|
||||
var content = branch
|
||||
? triggerActionChainHtml(branch.chain, objectId)
|
||||
: '<div class="questTriggerPill none">Ne rien faire</div>';
|
||||
return triggerCondArrowHtml(kind, arrowLabel) + content;
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questCondColumn">' +
|
||||
'<div class="questCondDiamond"><span>' + label + '</span></div>' +
|
||||
branchesHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Cache global (voir triggerLoadAll) — muté directement par les fonctions
|
||||
// triggerInline* ci-dessous, puis ré-affiché + sauvegardé, sans jamais
|
||||
// adopter la réponse du serveur comme nouvel état local (sanitize_dialogue_lines
|
||||
// rejette toute réplique au texte encore vide — juste ajoutée, pas encore
|
||||
// tapée — l'adopter effacerait silencieusement la bulle en cours
|
||||
// d'écriture).
|
||||
var _triggersCache = [];
|
||||
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
|
||||
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
|
||||
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
|
||||
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
|
||||
// de condition.
|
||||
var _triggerDialogueRegistry = {};
|
||||
|
||||
function _registerChainDialogues(chain, objectId, dialogueIds) {
|
||||
(chain || []).forEach(function (item) {
|
||||
if (item.type === 'dialogue' && item.dialogue) {
|
||||
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
|
||||
dialogueIds.push(item.dialogue.id);
|
||||
} else if (item.type === 'variable' && item.id) {
|
||||
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _registerTriggerDialogues(list) {
|
||||
_triggerDialogueRegistry = {};
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (t) {
|
||||
if (t.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var branch = t.condition[branchKey];
|
||||
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
|
||||
});
|
||||
} else {
|
||||
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
|
||||
}
|
||||
});
|
||||
return dialogueIds;
|
||||
}
|
||||
|
||||
function triggerLoadAll() {
|
||||
fetch('/game/' + GAME_SLUG + '/triggers')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
_triggersCache = list;
|
||||
var dialogueIds = _registerTriggerDialogues(list);
|
||||
// Le déclencheur actif par défaut est le DERNIER dialogue trouvé —
|
||||
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
|
||||
// garde (cliquer sur une autre carte reste possible, voir
|
||||
// triggerClickObjectCard).
|
||||
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
|
||||
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
|
||||
}
|
||||
triggerRenderSlot();
|
||||
});
|
||||
}
|
||||
|
||||
function triggerRenderSlot() {
|
||||
var slot = document.getElementById('triggersSlot');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = _triggersCache.map(function (t) { return triggerChainHtml(t); }).join('');
|
||||
}
|
||||
|
||||
function triggerDeleteTrigger(objectId) {
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId, { method: 'DELETE' })
|
||||
.then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// ---------- Mutateurs du dialogue INLINE ----------
|
||||
// Chaque fonction retrouve son dialogue via le registre (dialogue_id,
|
||||
// unique par occurrence de "Déclencher ce dialogue") — pas besoin de
|
||||
// connaître l'objet, déjà mémorisé à triggerLoadAll.
|
||||
|
||||
function triggerInlineSaveDialogue(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
|
||||
}).then(function (r) {
|
||||
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
|
||||
// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations")
|
||||
// — juste un défaut de départ, l'auteur choisit ensuite librement dans
|
||||
// le menu déroulant de la réplique.
|
||||
function triggerNextDefaultSpeaker(lastSpeaker) {
|
||||
if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
|
||||
return 'Joueur';
|
||||
}
|
||||
|
||||
function triggerInlineAddBubble(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var lines = entry.dialogueRef.lines;
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// "❓ Question" — voir triggerQuestionBubbleHtml ci-dessus. Défauts :
|
||||
// 2 choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt
|
||||
// (l'auteur règle le montant dans le header de la bulle).
|
||||
function triggerInlineAddQuestion(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].text = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
||||
// _MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les
|
||||
// derniers, sans jamais toucher aux choix déjà remplis qui restent dans
|
||||
// la nouvelle taille. Recadre la bonne réponse sur le premier choix si
|
||||
// elle pointait vers un choix retiré.
|
||||
function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var line = entry.dialogueRef.lines[index];
|
||||
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;
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionChoiceText(dialogueId, index, choiceIndex, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].choices[choiceIndex] = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].correct_index = choiceIndex;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].speaker = speaker;
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineUpdateBubbleText(dialogueId, index, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].text = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineDeleteBubble(dialogueId, index) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.splice(index, 1);
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// ---------- Modale d'ajout de déclencheur ----------
|
||||
// Étape 1 : choisir l'objet de scène (toutes scènes du jeu confondues,
|
||||
// voir GET .../triggers/objects-catalog — contrairement à l'éditeur de
|
||||
// collision, limité à la scène actuellement ouverte). Étape 2 : le MÊME
|
||||
// assistant "déclencheur -> action" que l'éditeur de collision (voir
|
||||
// openTriggerWizard, static/js/scenes/collision-rules-editor.js).
|
||||
function ensureTriggerModal() {
|
||||
var modal = document.getElementById('questTriggerModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal questTriggerModal';
|
||||
modal.id = 'questTriggerModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeTriggerModal()"></div>' +
|
||||
'<div class="modal-content questFieldsModalContent"><div class="box">' +
|
||||
'<div class="fieldRow" style="align-items:center; justify-content:space-between;">' +
|
||||
'<h2 class="title is-5" style="margin:0;" id="questTriggerModalTitle">Ajouter un déclencheur</h2>' +
|
||||
'<button type="button" class="button is-small" onclick="closeTriggerModal()">✕</button>' +
|
||||
'</div>' +
|
||||
'<div id="questTriggerModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function openAddTriggerModal() {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur';
|
||||
triggerRenderObjectStep();
|
||||
}
|
||||
|
||||
function triggerRenderObjectStep() {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
body.innerHTML =
|
||||
'<p class="hint mt-2 mb-2">Choisis l\'objet de scène qui déclenchera ce dialogue :</p>' +
|
||||
'<div id="questTriggerObjectGrid" class="collisionWizardChoices">Chargement…</div>';
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (objects) {
|
||||
var grid = document.getElementById('questTriggerObjectGrid');
|
||||
if (!grid) return;
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map(function (o, i) {
|
||||
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerSelectObject(' + o.id + ')">' +
|
||||
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
|
||||
'<span>' + (o.name || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
// Objet choisi -> enchaîne directement sur l'assistant déclencheur/action
|
||||
// de l'éditeur de collision (même écran, même modale, juste la suite du
|
||||
// parcours) — voir openTriggerWizard.
|
||||
function triggerSelectObject(objectId) {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
openTriggerWizard(objectId, body);
|
||||
}
|
||||
|
||||
// ---------- "+ Ajouter une action" (bout de chaîne d'un déclencheur
|
||||
// DÉJÀ posé) ----------
|
||||
// Demande explicite : "pouvoir modifier un déclencheur pour lui ajouter
|
||||
// des actions" — pas besoin de repasser par la modale de création, on
|
||||
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
|
||||
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
|
||||
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
|
||||
// encore), avant d'envoyer le tout via /append-action.
|
||||
function triggerOpenAppendActionModal(objectId, afterId) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
|
||||
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
|
||||
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
|
||||
_collisionWizard = { bodyEl: body };
|
||||
body.innerHTML =
|
||||
'<p class="hint mb-2">Ajouter...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function triggerAppendLeaf(objectId, afterId, type) {
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
return;
|
||||
}
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
});
|
||||
}
|
||||
|
||||
function triggerSaveAppendedAction(objectId, afterId, action) {
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId + '/append-action', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ after_id: afterId, action: action }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
function closeTriggerModal() {
|
||||
var modal = document.getElementById('questTriggerModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
// ---------- Point d'entrée ----------
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "Déclencheurs" restait vide après un remplacement de <main>
|
||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||||
// ne redéclenche jamais cet événement.
|
||||
function initTriggersTab() {
|
||||
var body = document.getElementById('triggersTabPanelBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
|
||||
triggerLoadAll();
|
||||
}
|
||||
initTriggersTab();
|
||||
+165
-32
@@ -725,7 +725,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
static/js/play/dialogue-box-controller.js) : header/body/footer,
|
||||
toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/
|
||||
styliser) mais masqué par défaut EN JEU tant qu'aucune règle de
|
||||
collision "quete" n'a de réplique à afficher. */
|
||||
collision "dialogue" n'a de réplique à afficher. */
|
||||
.dialogueBoxWidget{
|
||||
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
||||
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
|
||||
@@ -740,14 +740,6 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
|
||||
}
|
||||
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
|
||||
/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) :
|
||||
Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la
|
||||
boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur
|
||||
de pied choisie par l'auteur (voir "🎨 Style"). */
|
||||
.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; }
|
||||
.dialogueBoxAcceptBtn:hover{ background:#3ddb80; }
|
||||
.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
|
||||
@@ -942,6 +934,14 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.collisionWizardChoice:hover{ transform:translateY(-3px) scale(1.02); border-color:var(--accent); box-shadow:0 8px 18px rgba(0,0,0,.25); }
|
||||
.collisionWizardChoice .emoji{ font-size:22px; }
|
||||
|
||||
/* Étape "condition" de l'assistant (voir renderCollisionWizardConditionStep,
|
||||
collision-rules-editor.js) : variable / opérateur / valeur sur une
|
||||
ligne — les <select>/<input> reprennent déjà le style générique
|
||||
(voir plus haut, input[type=text]/select). */
|
||||
.collisionWizardConditionForm{ display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.collisionWizardConditionForm select{ flex:1.3; min-width:120px; }
|
||||
.collisionWizardConditionForm input{ flex:0.7; min-width:80px; }
|
||||
|
||||
/* Bulle "Appuie sur X" (voir forgeShowCollisionInteractBubble,
|
||||
collision-rules-controller.js) : SŒUR de l'objet dans son parent
|
||||
(.sceneWorld/.sceneUI, déjà position:relative) plutôt qu'ENFANT
|
||||
@@ -956,10 +956,9 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
white-space:nowrap; pointer-events:none; z-index:20;
|
||||
}
|
||||
|
||||
/* ---- Éditeur de quêtes (onglet "🗺️ Quêtes" à côté de "🧩 Collision",
|
||||
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) ---- */
|
||||
/* ---- Reliquat de l'ancien onglet "🗺️ Quêtes" (retiré, voir onglet
|
||||
"Déclencheurs" et static/js/triggers/trigger-editor.js) — règles
|
||||
orphelines gardées telles quelles, aucune ne s'applique plus. ---- */
|
||||
.questListRow{ cursor:pointer; }
|
||||
.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; }
|
||||
@@ -984,33 +983,166 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.questDialogueModal{ z-index:61; }
|
||||
.questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; }
|
||||
/* La modale entière est une colonne flex (jamais un scroll global, voir
|
||||
overflow:hidden ici) : le header/la fiche restent fixes en haut, les 3
|
||||
colonnes de répliques se partagent TOUT le reste de la hauteur
|
||||
(demandé explicitement) et défilent CHACUNE indépendamment (voir
|
||||
.questDialogueColumnBubbles plus bas), jamais toute la page. */
|
||||
overflow:hidden ici) : le header reste fixe en haut, le panneau de
|
||||
déclencheurs prend TOUT le reste de la hauteur. */
|
||||
.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.questDialogueModalContent{ width:100%; height:100%; max-width:100%; }
|
||||
#questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
|
||||
/* Onglet "Déclencheurs" (templates/scene_edit.html, #triggersTabPanel) —
|
||||
MÊME comportement que #questDialogueModalBody ci-dessus (juste déplacé
|
||||
hors de la modale plein écran dans un onglet, demande explicite :
|
||||
"sans rien toucher au style en place") : colonne flex qui prend tout
|
||||
l'espace du panneau d'onglet actif (voir .builderTabPanel.active). */
|
||||
#triggersTabPanelBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
|
||||
.questDialogueModalHeader{ flex:0 0 auto; margin-bottom:10px; }
|
||||
.questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; }
|
||||
.questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; }
|
||||
.questField-wide{ grid-column:span 2; }
|
||||
.questDialogueColumns{ flex:1; min-height:0; display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; }
|
||||
/* La modale de dialogue d'une quête est un GRAPHE DE LOGIQUE lu par
|
||||
déclencheur (demande explicite : "un RH doit le comprendre, un visuel
|
||||
global") : UN SEUL panneau (.questDialogueColumn, 90% de largeur,
|
||||
centré, TOUTE LA HAUTEUR disponible) — les déclencheurs de la quête
|
||||
côte à côte (voir .questTriggersSlot plus bas), chacun avec SON PROPRE
|
||||
dialogue édité EN LIGNE (voir .dialogueBox plus bas), PUIS les 3
|
||||
boutons Réplique/Question/Action TOUJOURS tout en bas — ils ajoutent
|
||||
dans le dialogue du déclencheur ACTIF (voir .questTriggerCol.active),
|
||||
jamais dans une liste séparée. */
|
||||
.questDialogueColumns{ flex:1; min-height:0; display:flex; }
|
||||
.questDialogueColumn{
|
||||
background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px;
|
||||
display:flex; flex-direction:column; gap:8px; height:100%; min-height:0;
|
||||
width:90%; margin:0 auto; height:100%; min-height:0; display:flex; flex-direction:column; gap:8px;
|
||||
background:var(--panel2); border:1px solid var(--border); border-radius:12px; padding:12px;
|
||||
}
|
||||
.questDialogueColumnTitle{ flex:0 0 auto; margin:0 0 2px; font-size:13px; text-align:center; }
|
||||
/* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un
|
||||
trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) —
|
||||
demandé explicitement plutôt qu'un alignement gauche/droite. Seule
|
||||
CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et
|
||||
le bouton "+ Réplique" restent fixes, en haut/en bas. */
|
||||
.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; }
|
||||
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; margin-top:4px; }
|
||||
.questDialogueColumnBody{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; gap:14px; padding-top:4px; }
|
||||
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; flex-wrap:wrap; }
|
||||
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
|
||||
.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); }
|
||||
|
||||
/* TOUS les déclencheurs du jeu (voir screens/rendering/
|
||||
trigger_graph.py::list_all_triggers) : réutilise TELLES QUELLES
|
||||
.collisionRuleChain/.collisionRuleLink (collision-rules-editor.js) pour
|
||||
la chaîne de pastilles, juste une petite fiche objet au-dessus. Vit dans le MÊME
|
||||
flux que les répliques (.questDialogueColumnBody ci-dessus), aucun
|
||||
scroll ni cadre séparé ici — plusieurs chaînes possibles côte à côte
|
||||
(flex-wrap). */
|
||||
.questTriggersSlot{ width:100%; display:flex; flex-wrap:wrap; gap:20px; justify-content:center; }
|
||||
.questTriggersSlot:empty::before{ content:"Aucun déclencheur pour l'instant — ajoutes-en un avec le bouton ci-dessous."; color:var(--muted); font-size:13px; }
|
||||
|
||||
/* Carte objet + pastilles empilées VERTICALEMENT, reliées par des flèches
|
||||
(maquette validée avec l'utilisateur) — PAS la chaîne horizontale en
|
||||
chevrons de l'éditeur de collision : deux présentations volontairement
|
||||
différentes pour le même modèle de données sous-jacent. */
|
||||
.questTriggerCol{ position:relative; display:flex; flex-direction:column; align-items:center; width:70%; }
|
||||
/* Modale d'ajout de déclencheur (voir static/js/triggers/trigger-editor.js::
|
||||
openAddTriggerModal) — au-dessus de l'onglet "Déclencheurs", jamais
|
||||
recouverte. */
|
||||
.questTriggerModal{ z-index:62; }
|
||||
.questTriggerDelete{
|
||||
position:absolute; top:-8px; right:2px; width:20px; height:20px; border-radius:50%;
|
||||
background:var(--panel2); border:1px solid var(--border); color:var(--muted, #8b95a7);
|
||||
font-size:10px; display:flex; align-items:center; justify-content:center; cursor:pointer; z-index:2;
|
||||
}
|
||||
.questTriggerDelete:hover{ background:#e0524d; color:#fff; border-color:#e0524d; }
|
||||
.questTriggerObjectCard{
|
||||
width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:12px;
|
||||
padding:10px; display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
.questTriggerObjectCard img{ width:36px; height:36px; border-radius:8px; object-fit:cover; }
|
||||
.questTriggerObjectCard .questTriggerObjectName{ font-weight:700; font-size:12.5px; text-align:center; }
|
||||
/* Déclencheur ACTIF (voir questClickTriggerObjectCard) : c'est LUI qui
|
||||
reçoit les prochaines répliques/questions ajoutées par les boutons du
|
||||
bas de la colonne — surligné pour que ce soit visible sans avoir à
|
||||
deviner. */
|
||||
.questTriggerCol.active .questTriggerObjectCard{ border-color:#3bb3c9; box-shadow:0 0 0 2px rgba(59,179,201,.35); }
|
||||
/* Accordéon (demande explicite : "ça retracte toute la logique associée") :
|
||||
replié = seule la carte objet reste visible, chevron tourné. */
|
||||
.questTriggerCol.collapsed{ width:auto; min-width:140px; }
|
||||
.questTriggerObjectCard .questTriggerCollapseChevron{ font-size:10px; color:var(--muted); transition:transform .15s ease; }
|
||||
.questTriggerCol.collapsed .questTriggerCollapseChevron{ transform:rotate(-90deg); }
|
||||
.questTriggerConnector{ width:2px; height:16px; background:var(--border); position:relative; flex-shrink:0; }
|
||||
.questTriggerConnector::after{
|
||||
content:""; position:absolute; bottom:0; left:50%; transform:translate(-50%,45%);
|
||||
border:5px solid transparent; border-top-color:var(--border);
|
||||
}
|
||||
/* Flèche menant à une condition — plus grande que les connecteurs
|
||||
ordinaires (demande explicite : "la flèche qui part de interagir vers
|
||||
le losange doit être plus grande"). */
|
||||
.questTriggerConnector.tall{ height:32px; width:3px; }
|
||||
.questTriggerConnector.tall::after{ border-width:7px; }
|
||||
.questTriggerPill{ width:100%; border-radius:100px; padding:8px 14px; font-size:12px; font-weight:700; text-align:center; }
|
||||
.questTriggerPill.trigger{ background:#f2994a; color:#1a1002; }
|
||||
.questTriggerPill.interagir{ background:#d4a72c; color:#1a1002; }
|
||||
.questTriggerPill.quete{ background:#9b59b6; color:#fff; }
|
||||
.questTriggerPill.none{ background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7); }
|
||||
|
||||
/* Rectangle "dialogue" — même largeur que le reste de la chaîne (100% de
|
||||
son conteneur, comme les pastilles, maquette validée : "le rectangle
|
||||
doit avoir la même width que les autres éléments"). Un bouton "+" en
|
||||
haut à droite ajoute une réplique DIRECTEMENT dedans (voir
|
||||
static/js/triggers/trigger-editor.js::triggerInlineAddBubble) — chaque
|
||||
occurrence de "Déclencher ce dialogue" porte le sien, édité ici même,
|
||||
à l'endroit où la flèche pointe (plus de colonne séparée). */
|
||||
.dialogueBox{
|
||||
width:100%; background:rgba(0,0,0,.18); border:1.5px solid #3bb3c9; border-radius:14px;
|
||||
padding:10px; display:flex; flex-direction:column; gap:8px;
|
||||
}
|
||||
.dialogueBoxHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.dialogueBoxTitle{ font-size:11px; font-weight:800; color:#3bb3c9; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.dialogueBoxActions{ display:flex; gap:4px; }
|
||||
.addReplyBtn{
|
||||
background:#3bb3c9; color:#04222a; border:none; border-radius:999px; padding:5px 14px;
|
||||
font-size:12px; font-weight:800; line-height:1.4; cursor:pointer; flex-shrink:0;
|
||||
}
|
||||
.addReplyBtn:hover{ filter:brightness(1.1); }
|
||||
.addQuestionBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); color:#1a1002; }
|
||||
|
||||
/* Feuille "Modifier une variable" (voir screens/rendering/collision_rules.py
|
||||
::"variable") — même famille visuelle que .dialogueBox (rectangle
|
||||
pleine largeur), bordure jaune pour la distinguer d'un dialogue au
|
||||
premier coup d'œil, pas de bulles dedans. */
|
||||
.questVariableAction{ border-color:#e0b93d; }
|
||||
.questVariableActionLabel{ margin:0; font-size:13px; color:var(--text); }
|
||||
/* "+ Ajouter une action" en bout de chaîne (demande explicite : "pouvoir
|
||||
modifier un déclencheur pour lui ajouter des actions") — même largeur
|
||||
que le reste de la chaîne, discret (pointillés) tant qu'on ne survole
|
||||
pas. */
|
||||
.questTriggerAddChainedActionBtn{
|
||||
width:100%; background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7);
|
||||
}
|
||||
.questTriggerAddChainedActionBtn:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
|
||||
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||
"il faut tout mettre verticalement", le losange lui-même ne change
|
||||
pas) : le losange reste petit et centré, la branche qui MÈNE à cette
|
||||
quête descend tout droit en premier (flèche verte ou rouge selon
|
||||
Vrai/Faux) vers "Déclencher ce dialogue" (pleine largeur, comme un
|
||||
déclencheur sans condition) puis son rectangle de dialogue, PUIS
|
||||
l'autre branche descend à son tour vers "Ne rien faire" — jamais deux
|
||||
branches côte à côte. */
|
||||
.questCondColumn{ width:100%; display:flex; flex-direction:column; align-items:center; }
|
||||
/* Un VRAI losange (carré tourné à 45°, pas un rectangle aplati) — voir
|
||||
demande explicite "je veut un vrai losange comme avant". */
|
||||
.questCondDiamond{
|
||||
width:150px; height:150px; margin:16px 0; transform:rotate(45deg);
|
||||
background:#3bb3c9; color:#04222a; display:flex; align-items:center; justify-content:center; border-radius:16%;
|
||||
box-shadow:0 4px 14px rgba(59,179,201,.35);
|
||||
}
|
||||
.questCondDiamond span{ transform:rotate(-45deg); font-size:11.5px; font-weight:800; text-align:center; line-height:1.3; padding:0 12px; }
|
||||
/* Flèches Vrai/Faux plus grandes — demande explicite "c'est trop petit
|
||||
trop tassé". */
|
||||
.questCondArrowWrap{ position:relative; display:flex; flex-direction:column; align-items:center; width:100%; height:68px; }
|
||||
.questCondArrowWrap svg{ width:34px; height:100%; overflow:visible; }
|
||||
.questCondArrowLabel{
|
||||
position:absolute; left:calc(50% + 26px); top:14px; z-index:2; border-radius:999px; padding:6px 16px;
|
||||
font-size:12.5px; font-weight:800; white-space:nowrap;
|
||||
}
|
||||
.questCondArrowLabel.yes{ background:rgba(46,204,113,.18); color:#2ecc71; border:1px solid rgba(46,204,113,.4); }
|
||||
.questCondArrowLabel.no{ background:rgba(224,82,77,.18); color:#e0524d; border:1px solid rgba(224,82,77,.4); }
|
||||
|
||||
.questTriggerObjectThumb{ width:40px; height:40px; border-radius:8px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; }
|
||||
.questTriggerObjectThumb img{ width:100%; height:100%; object-fit:cover; }
|
||||
.questTriggerObjectChoice{ align-items:center; }
|
||||
.questTriggerObjectChoice.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
|
||||
|
||||
.questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; }
|
||||
.questBubbleRow:not(:first-child)::before{
|
||||
content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%);
|
||||
@@ -1029,14 +1161,15 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); }
|
||||
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
|
||||
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
|
||||
/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/
|
||||
quest-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
||||
/* "❓ Question" (voir triggerQuestionBubbleHtml, static/js/triggers/
|
||||
trigger-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
||||
même chaîne/alignement vertical (.questBubbleRow). */
|
||||
.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; }
|
||||
.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); }
|
||||
.questQuestionHeaderField{
|
||||
display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap;
|
||||
display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap;
|
||||
}
|
||||
.questQuestionHeaderFieldLabel{ font-weight:700; text-transform:uppercase; font-size:10px; letter-spacing:.02em; opacity:.85; }
|
||||
.questQuestionHeaderField input{
|
||||
width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px;
|
||||
color:#fff; font:inherit; font-size:11px; padding:2px 4px;
|
||||
@@ -1196,7 +1329,7 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||
|
||||
/* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut,
|
||||
qui défile LUI-MÊME), "collision-rules"/"quests"/"events" n'ont pas de
|
||||
qui défile LUI-MÊME), "triggers"/"events" n'ont pas de
|
||||
sous-conteneur dédié au défilement — sans ça, une liste plus haute que
|
||||
l'espace disponible restait simplement coupée par .builderCanvasArea
|
||||
(overflow:hidden), au lieu de défiler. */
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@
|
||||
// bonnes réponses au quiz requis pour un statut de fin "reussi" plutôt
|
||||
// que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js).
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }};
|
||||
// Score max possible du quiz (voir db/quests/reward_budget.py) : permet
|
||||
// Score max possible du quiz (voir db/dialogue_lines.py::sum_question_rewards) : permet
|
||||
// au SCORM 1.2 de fournir cmi.core.score.min/max en plus du score brut
|
||||
// (voir static/js/play/offline/scorm-api.js), pour qu'un LMS calcule un
|
||||
// vrai pourcentage au lieu d'afficher le score brut à tort étiqueté "%".
|
||||
|
||||
+92
-67
@@ -115,7 +115,7 @@
|
||||
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('dialogue_box')">Boîte de dialogue</button>
|
||||
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('quiz_box')">Boîte à quiz</button>
|
||||
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('score_widget')">Score</button>
|
||||
<p class="help">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.</p>
|
||||
<p class="help">Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "Déclencheurs") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -124,15 +124,16 @@
|
||||
<div class="builderCanvasArea">
|
||||
<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" 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="triggers" onclick="switchBuilderTab('triggers')">Déclencheurs</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="variables" onclick="switchBuilderTab('variables')">Variables</button>
|
||||
{% if game.onboarding_type != 'rpg' %}
|
||||
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
||||
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant —
|
||||
l'assistant "+ Action" de l'onglet Collision exclut
|
||||
délibérément le type "evenement" (voir COLLISION_WIZARD_
|
||||
ACTION_TYPES, static/js/scenes/collision-rules-editor.js) —
|
||||
demande explicite : cet onglet n'a donc aucune utilité ici. -->
|
||||
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
|
||||
seul point d'entrée qui le permettait (l'action "evenement" de
|
||||
l'ancien éditeur de collision) a été retiré (demande explicite,
|
||||
ne servait à rien). game.onboarding_type est de toute façon
|
||||
TOUJOURS "rpg" désormais (voir la suppression des types Quiz/
|
||||
Embranchement) — cet onglet ne s'affiche donc plus jamais. -->
|
||||
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">Événements</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -179,55 +180,82 @@
|
||||
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
|
||||
</div>
|
||||
|
||||
<!-- Éditeur de collision (voir screens/rendering/collision_rules.py) :
|
||||
une carte par objet ÉLIGIBLE (jamais un "fond", jamais le
|
||||
personnage "joueur" — ce sont toujours les AUTRES objets qui
|
||||
réagissent à SA présence), listant ses règles "déclencheur ->
|
||||
action" déjà posées + un bouton "+ Action" qui ouvre l'assistant
|
||||
(modale plus bas). Rendu ENTIÈREMENT en JS (voir
|
||||
static/js/scenes/collision-rules-editor.js) à partir de
|
||||
COLLISION_RULE_OBJECTS/CUSTOM_EVENTS_MAP — jamais en Jinja, pour
|
||||
pouvoir se redessiner à chaud après chaque ajout/suppression de
|
||||
règle sans recharger la page. -->
|
||||
<div class="builderTabPanel" id="collisionRulesTabPanel" data-tab-panel="collision-rules">
|
||||
<div class="dashPanelHeader">
|
||||
<h2>Collision</h2>
|
||||
</div>
|
||||
<p class="hint">Pour chaque objet (jamais le joueur lui-même), choisis ce qui doit se passer à son contact — la carte se construit au fil de tes choix, comme des pièces qui s'emboîtent.</p>
|
||||
<div id="collisionRulesCards" class="collisionRulesCards"></div>
|
||||
<!-- Onglet "Déclencheurs" (voir static/js/triggers/trigger-editor.js) :
|
||||
un graphe de logique lu par déclencheur — TOUS les déclencheurs du
|
||||
jeu, plus aucune notion de quête pour les regrouper (un créateur
|
||||
peut faire un simple quiz sans jamais poser de personnage). Chaque
|
||||
déclencheur (objet -> collision -> action) porte SON PROPRE
|
||||
dialogue, édité en ligne (voir openTriggerWizard(), static/js/
|
||||
scenes/collision-rules-editor.js). -->
|
||||
<div class="builderTabPanel" id="triggersTabPanel" data-tab-panel="triggers">
|
||||
<div id="triggersTabPanelBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant "+ Action" — un seul écran affiché à la fois
|
||||
(#collisionRuleModalBody, reconstruit à chaque étape), jamais un
|
||||
vrai formulaire à plusieurs pages : chaque clic choisit une
|
||||
option et avance directement à la suivante. -->
|
||||
<div class="modal" id="collisionRuleModal">
|
||||
<div class="modal-background" onclick="closeCollisionRuleModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="box">
|
||||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||
<h2 class="title is-5" style="margin:0;">Nouvelle règle</h2>
|
||||
<button type="button" class="button is-small" onclick="closeCollisionRuleModal()">✕</button>
|
||||
</div>
|
||||
<div id="collisionRuleModalBody"></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">
|
||||
<!-- Variables globales (voir db/global_vars/) : accessibles à TOUT le
|
||||
jeu par leur NOM (immuable après création — condition de
|
||||
visibilité, action "Modifier une variable", voir
|
||||
static/js/play/conditions.js::readVariableValue), pas seulement à
|
||||
un écran. Même patron que l'onglet Événements ci-dessous (table +
|
||||
formulaires classiques, POST/redirect — pas de rendu JS). -->
|
||||
<div class="builderTabPanel" id="variablesTabPanel" data-tab-panel="variables">
|
||||
<div class="dashPanelHeader">
|
||||
<h2>Quêtes</h2>
|
||||
<button type="button" class="button is-small primary" onclick="createQuestFromListPage()">+ Créer une quête</button>
|
||||
<h2>Variables</h2>
|
||||
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</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>
|
||||
<p class="hint">Accessibles depuis n'importe quelle scène (conditions, actions "Modifier une variable"). Le nom ne peut plus changer une fois créé.</p>
|
||||
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-variables" style="display:none;">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
|
||||
<select name="type">
|
||||
{% for key, t in global_variable_types.items() %}
|
||||
<option value="{{ key }}">{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="default_value" placeholder="Valeur par défaut">
|
||||
<label class="checkbox" style="display:flex; align-items:center; gap:4px; white-space:nowrap;" title="Chaque joueur a sa propre valeur (décoché = valeur unique partagée par tous)">
|
||||
<input type="checkbox" name="per_player" value="1" checked> Par joueur
|
||||
</label>
|
||||
<button type="submit" class="primary">Créer</button>
|
||||
</form>
|
||||
|
||||
{% if global_variables %}
|
||||
{% for v in global_variables %}
|
||||
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
</form>
|
||||
{% endfor %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in global_variables %}
|
||||
<tr>
|
||||
<td><code>{{ v.name }}</code></td>
|
||||
<td>
|
||||
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
|
||||
{% for key, t in global_variable_types.items() %}
|
||||
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" placeholder="Valeur" aria-label="Valeur de « {{ v.name }} »"></td>
|
||||
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non (partagée)' }}</span></td>
|
||||
<td class="fieldsTableActions">
|
||||
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small" title="Enregistrer">💾</button>
|
||||
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
|
||||
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition/action qui la référence encore cessera de fonctionner.');">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucune variable pour l'instant — crée la première ci-dessus.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if game.onboarding_type != 'rpg' %}
|
||||
@@ -311,9 +339,9 @@
|
||||
screens/scenes/set_scene_object_name.py) — n'importe quel kind
|
||||
(personnage, décor, fond), pas seulement un personnage — c'est
|
||||
CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
|
||||
de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/
|
||||
quest-editor.js) : un dialogue peut mettre en scène n'importe
|
||||
quel objet, pas seulement un personnage. -->
|
||||
de dialogue d'un déclencheur (voir "Déclencheurs", static/js/
|
||||
triggers/trigger-editor.js) : un dialogue peut mettre en scène
|
||||
n'importe quel objet, pas seulement un personnage. -->
|
||||
<details class="propGroup" open>
|
||||
<summary>Informations</summary>
|
||||
<div class="propGroupBody">
|
||||
@@ -602,18 +630,15 @@
|
||||
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
|
||||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||||
var COLLISION_RULE_OBJECTS = {{ collision_rule_objects_json|safe }};
|
||||
var COLLISION_ACTION_TYPES = {{ collision_action_types|tojson }};
|
||||
var COLLISION_TRIGGER_TYPES = {{ collision_trigger_types|tojson }};
|
||||
var COLLISION_DEFAULT_PERIMETRE_PX = {{ collision_default_perimetre_px }};
|
||||
// Éditeur de quêtes (voir static/js/quests/quest-editor.js) — chargé
|
||||
// avant collision-rules-editor.js : l'action "quete" de l'assistant
|
||||
// "+ Action" ouvre directement cet éditeur (renderQuestPickerStep).
|
||||
var QUESTS_JSON = {{ quests_json|safe }};
|
||||
// "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" +
|
||||
// static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe
|
||||
// quel objet de scène nommé, pas seulement un personnage.
|
||||
// static/js/triggers/trigger-editor.js::triggerSpeakerOptions) —
|
||||
// n'importe quel objet de scène nommé, pas seulement un personnage.
|
||||
var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }};
|
||||
// Assistant "condition" d'un déclencheur (voir static/js/scenes/
|
||||
// collision-rules-editor.js::renderCollisionWizardConditionStep) — les
|
||||
// noms des variables globales du jeu (voir l'onglet "Variables"
|
||||
// ci-dessus), pour peupler son sélecteur de variable.
|
||||
var GLOBAL_VARIABLE_NAMES = {{ global_variables | map(attribute='name') | list | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||
@@ -621,6 +646,6 @@
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/quests/quest-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/triggers/trigger-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+294
-99
@@ -1,12 +1,11 @@
|
||||
"""Éditeur de collision (onglet "🧩 Collision", voir
|
||||
screens/rendering/collision_rules.py) : une règle "déclencheur (collision/
|
||||
périmètre) -> action (quête/attaque/événement/interagir)" par carte
|
||||
objet — toujours les AUTRES objets qui réagissent à la présence du
|
||||
joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js
|
||||
construit l'assistant "+ Action" ; ce fichier teste la couche serveur
|
||||
(défauts, validation, exposition dans full_game_payload)."""
|
||||
import re
|
||||
|
||||
"""Règles de collision (voir screens/rendering/collision_rules.py) : une
|
||||
règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
|
||||
par objet de scène — toujours les AUTRES objets qui réagissent à la
|
||||
présence du joueur, jamais l'inverse. Posées depuis l'onglet
|
||||
"Déclencheurs" (voir tests/test_triggers.py pour le parcours complet côté
|
||||
route) — ce fichier teste la couche de validation/lecture elle-même
|
||||
(sanitize_collision_rules/resolve_collision_rules), plus l'exposition
|
||||
dans full_game_payload."""
|
||||
import db
|
||||
import screens
|
||||
|
||||
@@ -19,6 +18,7 @@ def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rul
|
||||
|
||||
|
||||
def _add_personnage(client, slug, screen_id, role=None):
|
||||
import re
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
@@ -27,13 +27,6 @@ def _add_personnage(client, slug, screen_id, role=None):
|
||||
return obj_id
|
||||
|
||||
|
||||
def _add_custom_event(client, slug, name="NEED_HELP"):
|
||||
resp = client.post(f"/game/{slug}/events/new", data={"name": name}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
events = screens.list_custom_events(slug) if hasattr(screens, "list_custom_events") else __import__("db").list_custom_events(slug)
|
||||
return next(e["id"] for e in events if e["name"] == name)
|
||||
|
||||
|
||||
def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
@@ -41,93 +34,293 @@ def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_clea
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "attaque"}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "attaque"}}]
|
||||
obj = screens.get_scene_object(slug, enemy_id)
|
||||
assert screens.resolve_collision_rules(obj) == [{"trigger": "collision", "action": {"type": "attaque"}}]
|
||||
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "perimetre", "perimetre_px": 40, "action": {"type": "attaque"}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
rules = resp.get_json()["rules"]
|
||||
assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}]
|
||||
|
||||
|
||||
def test_rules_route_persists_a_quest_action_referencing_a_quest_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": ' + str(quest["id"]) + '}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": quest["id"]}}]
|
||||
|
||||
|
||||
def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup):
|
||||
def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting():
|
||||
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
|
||||
à l'appui — un seul niveau d'imbrication autorisé (voir
|
||||
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == [
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}},
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
||||
])
|
||||
assert rules == [
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
||||
]
|
||||
|
||||
|
||||
def test_rules_route_rejects_interagir_nested_inside_interagir(client, tmp_game_slug_cleanup):
|
||||
def test_sanitize_collision_rules_rejects_interagir_nested_inside_interagir():
|
||||
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
|
||||
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "attaque"}}}}]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == [] # la règle entière est rejetée
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}},
|
||||
])
|
||||
assert rules == [] # la règle entière est rejetée
|
||||
|
||||
|
||||
def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
|
||||
data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["rules"] == []
|
||||
def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches():
|
||||
""""condition" (losange à embranchement, maquette validée) : Vrai/Faux
|
||||
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue"}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue"}, "si_faux": None,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_rules_route_404s_for_missing_object(client, game):
|
||||
resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json")
|
||||
assert resp.status_code == 404
|
||||
def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "condition", "variable": "", "operateur": "superieur", "valeur": "0", "si_vrai": None, "si_faux": None}},
|
||||
{"trigger": "collision", "action": {"type": "condition", "variable": "score", "operateur": "sorcellerie", "valeur": "0", "si_vrai": None, "si_faux": None}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_rejects_interagir_nested_inside_a_condition_branch():
|
||||
"""Une branche Vrai/Faux reste une FEUILLE — jamais une nouvelle
|
||||
"interagir"/"condition" imbriquée (voir maquette validée : chaque
|
||||
branche n'a que "Déclencher ce dialogue" ou "Ne rien faire")."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "interagir", "sub_action": {"type": "dialogue"}}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": None, "si_faux": None,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_interagir_wrapping_a_condition():
|
||||
"""interagir -> condition -> dialogue (maquette validée : "Interagir avec
|
||||
une touche" PUIS un test avant de déclencher le dialogue)."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "interagir",
|
||||
"sub_action": {
|
||||
"type": "condition", "variable": "score", "operateur": "superieur_egal", "valeur": "10",
|
||||
"si_vrai": {"type": "dialogue"}, "si_faux": {"type": "dialogue"}},
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["sub_action"]["type"] == "condition"
|
||||
assert rules[0]["action"]["sub_action"]["si_vrai"] == {"type": "dialogue"}
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_valid_dialogue_and_mark_completed():
|
||||
"""Chaque occurrence de "Déclencher ce dialogue" porte SON PROPRE
|
||||
dialogue ({id, lines}, voir maquette validée : "un dialogue est un
|
||||
ensemble de répliques regroupées par un id")."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "mark_completed": True,
|
||||
"dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}, {"speaker": "x", "text": " "}]},
|
||||
},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "mark_completed": True,
|
||||
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_dialogue_without_a_valid_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": " ", "lines": [{"speaker": "pnj", "text": "Salut !"}]}},
|
||||
}])
|
||||
assert "dialogue" not in rules[0]["action"]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_dialogue_on_a_condition_branch():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["si_vrai"]["dialogue"] == {"id": "d_vrai", "lines": []}
|
||||
|
||||
|
||||
def test_set_dialogue_lines_in_rules_updates_the_matching_dialogue_wherever_it_is():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "interagir",
|
||||
"sub_action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": None, "si_faux": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
},
|
||||
}])
|
||||
updated = screens.set_dialogue_lines_in_rules(rules, "d_1", [{"speaker": "pnj", "text": "Trouvé !"}])
|
||||
assert updated is True
|
||||
assert rules[0]["action"]["sub_action"]["si_faux"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
|
||||
|
||||
|
||||
def test_set_dialogue_lines_in_rules_returns_false_for_an_unknown_dialogue_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.set_dialogue_lines_in_rules(rules, "d_absent", [{"speaker": "pnj", "text": "x"}]) is False
|
||||
assert rules[0]["action"]["dialogue"]["lines"] == [] # inchangé
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_variable_action():
|
||||
""""Modifier une variable" (même vocabulaire que l'action du graphe de
|
||||
logique document, voir screens/labels/data_operations.py) — utilisable
|
||||
partout où une feuille est attendue : ici directement après une
|
||||
collision."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_variable_action_without_a_target_or_with_an_unknown_operation():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "variable", "target_variable": "", "data_operation": "incrementer"}},
|
||||
{"trigger": "collision", "action": {"type": "variable", "target_variable": "score", "data_operation": "sorcellerie"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_omits_data_value_for_operations_that_need_none():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "target_variable": "actif", "data_operation": "basculer_bool", "data_value": "peu importe"},
|
||||
}])
|
||||
assert "data_value" not in rules[0]["action"]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_variable_action_as_a_condition_branch():
|
||||
"""Une branche Vrai/Faux peut être une feuille "variable" tout autant
|
||||
qu'une feuille "dialogue" (voir maquette validée : chaque branche
|
||||
reste une feuille, peu importe laquelle)."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["si_vrai"]["type"] == "variable"
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves():
|
||||
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
||||
lui ajouter des actions") : dialogue -> then variable -> then dialogue,
|
||||
plusieurs actions à la suite sur le MÊME déclencheur."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {
|
||||
"type": "variable", "id": "d_2", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}},
|
||||
},
|
||||
},
|
||||
}])
|
||||
action = rules[0]["action"]
|
||||
assert action["then"]["type"] == "variable"
|
||||
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}}
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
||||
"""_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la
|
||||
main est coupée plutôt que rejetée entièrement (même tolérance que le
|
||||
reste de ce moteur)."""
|
||||
chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
node = chain
|
||||
for _ in range(10): # largement au-delà de _MAX_THEN_CHAIN (4)
|
||||
node["then"] = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
node = node["then"]
|
||||
rules = screens.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
|
||||
depth = 0
|
||||
node = rules[0]["action"]
|
||||
while node:
|
||||
depth += 1
|
||||
node = node.get("then")
|
||||
assert depth == 4 # _MAX_THEN_CHAIN, jamais plus
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"})
|
||||
assert attached is True
|
||||
assert rules[0]["action"]["then"] == {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_addresses_a_variable_leaf_by_its_own_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "v_1", {"type": "dialogue", "dialogue": {"id": "d_2", "lines": []}})
|
||||
assert attached is True
|
||||
assert rules[0]["action"]["then"]["dialogue"]["id"] == "d_2"
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.set_then_action_in_rules(rules, "d_absent", {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}) is False
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_never_overwrites_an_existing_then():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"})
|
||||
assert attached is False
|
||||
assert rules[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "diagonale", "action": {"type": "dialogue"}},
|
||||
{"trigger": "collision", "action": {"type": "sorcellerie"}},
|
||||
{"trigger": "perimetre", "action": {"type": "dialogue"}}, # "perimetre" retiré (demande explicite)
|
||||
"pas un objet",
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup):
|
||||
@@ -148,17 +341,19 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje
|
||||
assert enemy_payload["collision_rules"] == []
|
||||
|
||||
|
||||
def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup):
|
||||
def test_build_collision_rule_objects_excludes_the_player(client, tmp_game_slug_cleanup):
|
||||
"""screens.build_collision_rule_objects (voir screens/rendering/
|
||||
trigger_graph.py pour son autre appelant, le listage de tous les
|
||||
déclencheurs du jeu) : "Joueur" (le seul rôle exclu) est désormais
|
||||
toujours un choix explicite."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
|
||||
# "Joueur" (le seul rôle exclu de "🧩 Collision") est désormais toujours
|
||||
# un choix explicite.
|
||||
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
idx = html.find("var COLLISION_RULE_OBJECTS")
|
||||
end = html.find(";", idx)
|
||||
snippet = html[idx:end]
|
||||
assert f'"id": {enemy_id}' in snippet
|
||||
assert f'"id": {player_id}' not in snippet
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
rule_objects = screens.build_collision_rule_objects(objects)
|
||||
ids = [o["id"] for o in rule_objects]
|
||||
assert enemy_id in ids
|
||||
assert player_id not in ids
|
||||
|
||||
+13
-10
@@ -1,11 +1,11 @@
|
||||
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
|
||||
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
|
||||
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
|
||||
cours d'une quête déclenchée par une règle de collision "quete" (voir
|
||||
cours d'un dialogue déclenché par une règle de collision "dialogue" (voir
|
||||
static/js/play/dialogue-box-controller.js) — header/body/footer
|
||||
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
|
||||
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
|
||||
l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py)."""
|
||||
l'éditeur de dialogue d'un déclencheur (screens/rendering/scene_object_names.py)."""
|
||||
import db
|
||||
import screens
|
||||
|
||||
@@ -44,10 +44,8 @@ def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
|
||||
assert f'data-object-id="{obj_id}"' in html
|
||||
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
||||
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
||||
# (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le
|
||||
# cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de
|
||||
# dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait
|
||||
# silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons.
|
||||
# (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js)
|
||||
# le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
|
||||
assert 'data-dialogue-role="header"' in html
|
||||
assert 'data-dialogue-role="body"' in html
|
||||
assert 'data-dialogue-role="footer"' in html
|
||||
@@ -95,11 +93,16 @@ def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanu
|
||||
assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
|
||||
|
||||
|
||||
def test_full_game_payload_exposes_quests_game_wide(tmp_game_slug_cleanup):
|
||||
def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
db.create_quest(slug, "Trouver la clé")
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules))
|
||||
payload = screens.full_game_payload(slug)
|
||||
assert [q["title"] for q in payload["quests"]] == ["Trouver la clé"]
|
||||
assert payload["completable_dialogue_ids"] == ["d_1"]
|
||||
|
||||
|
||||
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
|
||||
@@ -179,7 +182,7 @@ 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 # "Quête : <titre>", rempli en jeu
|
||||
assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, 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
|
||||
|
||||
@@ -6,9 +6,11 @@ vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE,
|
||||
JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le
|
||||
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
|
||||
automatisée)."""
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
import db
|
||||
import screens
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
@@ -125,7 +127,7 @@ def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game):
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
|
||||
"""Sans quête/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
|
||||
"""Sans déclencheur/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
|
||||
barémer, voir static/js/play/offline/scorm-api.js)."""
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
@@ -133,20 +135,62 @@ def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
|
||||
|
||||
quest = db.create_quest(game, "Une quête")
|
||||
db.set_quest_dialogues(game, quest["id"], {
|
||||
"nouvelle": [], "terminee": [],
|
||||
"en_cours": [
|
||||
# Les dialogues vivent dans les règles de collision d'un objet de
|
||||
# scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue).
|
||||
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
],
|
||||
})
|
||||
]},
|
||||
},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_max_quiz_score_across_a_then_chain(client, game):
|
||||
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
||||
lui ajouter des actions") : un dialogue -> then variable -> then
|
||||
dialogue — les DEUX dialogues de la chaîne comptent pour le score max,
|
||||
la feuille "variable" au milieu n'en interrompt pas la collecte."""
|
||||
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
]},
|
||||
"then": {
|
||||
"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_2", "lines": [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
]},
|
||||
},
|
||||
},
|
||||
},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
||||
|
||||
|
||||
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Onglet "Variables" de l'éditeur de scène (templates/scene_edit.html) —
|
||||
CRUD classique (formulaires POST/redirect, voir l'onglet "Événements"
|
||||
pour le même patron) sur les fonctions déjà existantes de db/global_vars/
|
||||
(list/create/update/delete). Le nom est la clé stable lue par
|
||||
static/js/play/conditions.js::readVariableValue — jamais modifiable après
|
||||
création (voir db/global_vars/update_global_variable.py)."""
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/new",
|
||||
data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
variables = db.list_global_variables(game)
|
||||
assert [v["name"] for v in variables] == ["score_total"]
|
||||
assert variables[0]["type"] == "nombre_entier"
|
||||
assert variables[0]["value"] == "0"
|
||||
|
||||
|
||||
def test_create_global_var_is_idempotent_by_name(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
assert len(db.list_global_variables(game)) == 1
|
||||
|
||||
|
||||
def test_global_var_edit_route_updates_type_and_value(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/edit",
|
||||
data={"type": "texte", "value": "beaucoup", "screen_id": screen_id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
variable = db.list_global_variables(game)[0]
|
||||
assert variable["type"] == "texte"
|
||||
assert variable["value"] == "beaucoup"
|
||||
assert variable["name"] == "score_total" # le nom ne change jamais
|
||||
|
||||
|
||||
def test_global_var_delete_route_removes_it(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/variables/{var_id}/delete", data={"screen_id": screen_id}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
assert db.list_global_variables(game) == []
|
||||
|
||||
|
||||
def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "5", "screen_id": screen_id})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "score_total" in html
|
||||
assert 'data-tab="variables"' in html
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes"
|
||||
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),
|
||||
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
|
||||
jeu, jamais par écran (mêmes conventions que les événements
|
||||
personnalisés, db/custom_events/)."""
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _make_game(tmp_game_slug_cleanup, name="pytest_quests"):
|
||||
return tmp_game_slug_cleanup(db.create_game(name))
|
||||
|
||||
|
||||
def test_create_quest_has_sensible_defaults(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
assert quest["title"] == "Trouver la clé"
|
||||
assert quest["objectif"] == ""
|
||||
assert quest["recompense_score"] == 0
|
||||
assert quest["status"] == "nouvelle"
|
||||
assert quest["resultat"] is None
|
||||
assert quest["dialogues"] == {}
|
||||
|
||||
|
||||
def test_create_quest_falls_back_to_default_title_when_blank(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, " ")
|
||||
assert quest["title"] == "Nouvelle quête"
|
||||
|
||||
|
||||
def test_list_quests_orders_most_recent_first(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
first = db.create_quest(slug, "Première")
|
||||
second = db.create_quest(slug, "Seconde")
|
||||
ids = [q["id"] for q in db.list_quests(slug)]
|
||||
assert ids[:2] == [second["id"], first["id"]]
|
||||
|
||||
|
||||
def test_update_quest_only_applies_known_valid_fields(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
updated = db.update_quest(slug, quest["id"], {
|
||||
"title": "Trouver la clé dorée", "objectif": "Fouiller le donjon",
|
||||
"recompense_score": 50, "status": "en_cours", "resultat": "reussi",
|
||||
"unknown_field": "ignoré", "id": 999999,
|
||||
})
|
||||
assert updated["title"] == "Trouver la clé dorée"
|
||||
assert updated["objectif"] == "Fouiller le donjon"
|
||||
assert updated["recompense_score"] == 50
|
||||
assert updated["status"] == "en_cours"
|
||||
assert updated["resultat"] == "reussi"
|
||||
assert updated["id"] == quest["id"] # "id" dans fields est ignoré, jamais modifiable
|
||||
|
||||
|
||||
def test_update_quest_rejects_invalid_status_and_blank_title(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
updated = db.update_quest(slug, quest["id"], {"status": "en_pause", "title": " "})
|
||||
assert updated["status"] == "nouvelle" # inchangé, valeur invalide ignorée
|
||||
assert updated["title"] == "Trouver la clé" # inchangé, titre vide ignoré
|
||||
|
||||
|
||||
def test_update_quest_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
assert db.update_quest(slug, 999999, {"title": "x"}) is None
|
||||
|
||||
|
||||
def test_delete_quest_removes_it(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.delete_quest(slug, quest["id"])
|
||||
assert db.get_quest(slug, quest["id"]) is None
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
|
||||
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
|
||||
screens/rendering/scene_object_names.py) — "Le Sorcier" est donc un nom
|
||||
valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
|
||||
élément mal formé est rejeté."""
|
||||
raw = {
|
||||
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
|
||||
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
|
||||
"terminee": "pas une liste",
|
||||
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
assert cleaned == {
|
||||
"nouvelle": [{"type": "dialogue", "speaker": "Le Sorcier", "text": "Bonjour !"}],
|
||||
"en_cours": [],
|
||||
"terminee": [],
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_accepts_a_valid_question_and_rejects_malformed_ones():
|
||||
""""❓ Question" (voir static/js/quests/quest-editor.js) : une bulle
|
||||
jaune avec plusieurs choix, une seule bonne réponse, une récompense —
|
||||
même chaîne qu'une réplique de dialogue, un autre "type" de ligne."""
|
||||
raw = {
|
||||
"nouvelle": [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
{"type": "question", "text": "Une seule option", "choices": ["seule"], "correct_index": 0}, # < 2 choix -> rejetée
|
||||
{"type": "question", "text": "index hors bornes", "choices": ["a", "b"], "correct_index": 5}, # rejetée
|
||||
{"type": "question", "text": " ", "choices": ["a", "b"], "correct_index": 0}, # texte vide -> rejetée
|
||||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "vie"},
|
||||
],
|
||||
"en_cours": [], "terminee": [],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
assert cleaned["nouvelle"] == [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
# reward_type invalide -> repli sur le premier type connu ("score"), jamais rejetée pour ça seul
|
||||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "score", "reward_amount": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_set_quest_dialogues_persists_all_three_columns(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
dialogues = {
|
||||
"nouvelle": [
|
||||
{"type": "dialogue", "speaker": "pnj", "text": "Peux-tu m'aider ?"},
|
||||
{"type": "dialogue", "speaker": "joueur", "text": "Bien sûr !"},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
],
|
||||
"en_cours": [{"type": "dialogue", "speaker": "pnj", "text": "Alors, cette clé ?"}],
|
||||
"terminee": [{"type": "dialogue", "speaker": "pnj", "text": "Merci beaucoup !"}],
|
||||
}
|
||||
db.set_quest_dialogues(slug, quest["id"], dialogues)
|
||||
reloaded = db.get_quest(slug, quest["id"])
|
||||
assert reloaded["dialogues"] == dialogues
|
||||
|
||||
|
||||
def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
assert db.set_quest_dialogues(slug, 999999, {}) is None
|
||||
|
||||
|
||||
# ---------- Routes ----------
|
||||
|
||||
def test_quest_new_route_creates_and_returns_json(client, 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")
|
||||
assert resp.status_code == 200
|
||||
quest = resp.get_json()["quest"]
|
||||
assert quest["title"] == "Nouvelle quête du donjon"
|
||||
assert db.get_quest(slug, quest["id"]) is not None
|
||||
|
||||
|
||||
def test_quests_list_json_route_returns_all_quests(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
db.create_quest(slug, "A")
|
||||
db.create_quest(slug, "B")
|
||||
resp = client.get(f"/game/{slug}/quests/list.json")
|
||||
titles = {q["title"] for q in resp.get_json()["quests"]}
|
||||
assert titles == {"A", "B"}
|
||||
|
||||
|
||||
def test_quest_update_route_persists_fields(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(f"/game/{slug}/quests/{quest['id']}", data='{"recompense_score": 100, "status": "terminee"}', content_type="application/json")
|
||||
assert resp.get_json()["quest"]["recompense_score"] == 100
|
||||
assert resp.get_json()["quest"]["status"] == "terminee"
|
||||
|
||||
|
||||
def test_quest_update_route_404s_for_missing_quest(client, game):
|
||||
resp = client.post(f"/game/{game}/quests/999999", data="{}", content_type="application/json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_quest_delete_route_removes_it(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(f"/game/{slug}/quests/{quest['id']}/delete")
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.get_quest(slug, quest["id"]) is None
|
||||
|
||||
|
||||
def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||||
assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||||
|
||||
|
||||
def test_quest_dialogues_route_404s_for_missing_quest(client, game):
|
||||
resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def _question_line(reward_amount, correct_index=0):
|
||||
return {
|
||||
"type": "question", "text": "2+2 ?", "choices": ["3", "4"],
|
||||
"correct_index": correct_index, "reward_type": "score", "reward_amount": reward_amount,
|
||||
}
|
||||
|
||||
|
||||
def test_quest_dialogues_route_rejects_questions_totaling_more_than_the_reward(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(3), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "pts" in resp.get_json()["error"]
|
||||
# Rien n'a été enregistré — la répartition invalide n'est jamais persistée.
|
||||
assert db.get_quest(slug, quest["id"])["dialogues"] == {}
|
||||
|
||||
|
||||
def test_quest_dialogues_route_accepts_questions_totaling_exactly_the_reward(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(2), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_quest_update_route_rejects_lowering_reward_below_already_allocated_questions(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 10})
|
||||
db.set_quest_dialogues(slug, quest["id"], {"nouvelle": [_question_line(6)], "en_cours": [], "terminee": []})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}",
|
||||
data=json.dumps({"recompense_score": 3}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "pts" in resp.get_json()["error"]
|
||||
assert db.get_quest(slug, quest["id"])["recompense_score"] == 10 # inchangé
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_caps_choices_at_four():
|
||||
raw = {
|
||||
"nouvelle": [{"type": "question", "text": "Q", "choices": ["a", "b", "c", "d", "e"], "correct_index": 4, "reward_type": "score", "reward_amount": 1}],
|
||||
"en_cours": [], "terminee": [],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
# Coupé à 4 choix -> l'index 4 (qui pointait sur le 5e, retiré) sort des bornes -> ligne rejetée.
|
||||
assert cleaned["nouvelle"] == []
|
||||
|
||||
|
||||
def test_quest_dialogues_total_reward_sums_questions_across_all_statuses():
|
||||
dialogues = {
|
||||
"nouvelle": [_question_line(2)],
|
||||
"en_cours": [_question_line(3), {"type": "dialogue", "speaker": "pnj", "text": "x"}],
|
||||
"terminee": [],
|
||||
}
|
||||
assert db.quest_dialogues_total_reward(dialogues) == 5
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Liste "🧩 Collision" à jour en AJAX (voir routes/scenes/
|
||||
scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans
|
||||
static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un
|
||||
objet de scène ou changement de rôle/nom, pour ne jamais recharger la page
|
||||
alors que cette liste doit refléter le changement (un objet apparaît/
|
||||
disparaît selon son rôle, ou change de libellé affiché)."""
|
||||
"""screens.build_collision_rule_objects (voir screens/scenes/
|
||||
build_collision_rule_objects.py) : la liste d'objets éligibles à un
|
||||
déclencheur de collision, réutilisée par le listage de TOUS les
|
||||
déclencheurs du jeu (screens/rendering/trigger_graph.py, voir
|
||||
tests/test_triggers.py) — un objet apparaît/disparaît selon son
|
||||
rôle, ou change de libellé affiché selon son nom. L'ancien endpoint AJAX
|
||||
dédié (routes/scenes/scene_collision_rule_objects.py) a été retiré avec
|
||||
l'onglet "🧩 Collision" séparé — ce fichier teste directement les
|
||||
fonctions Python plutôt qu'une route HTTP désormais inexistante."""
|
||||
import screens
|
||||
|
||||
|
||||
@@ -21,16 +24,17 @@ def _add_ajax(client, slug, screen_id, **data):
|
||||
).get_json()
|
||||
|
||||
|
||||
def _list(client, slug, screen_id):
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()
|
||||
def _list(slug, screen_id):
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
return screens.build_collision_rule_objects(objects)
|
||||
|
||||
|
||||
def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == obj_id
|
||||
assert entries[0]["role"] == "pnj"
|
||||
@@ -40,7 +44,7 @@ def test_role_change_to_joueur_removes_it_from_the_list(client, tmp_game_slug_cl
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
assert _list(client, slug, screen_id) == []
|
||||
assert _list(slug, screen_id) == []
|
||||
|
||||
|
||||
def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cleanup):
|
||||
@@ -48,7 +52,7 @@ def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cle
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["role"] == "ennemie"
|
||||
|
||||
@@ -57,7 +61,7 @@ def test_name_change_is_reflected_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert entries[0]["name"] == "Le Sorcier"
|
||||
|
||||
|
||||
@@ -65,11 +69,11 @@ def test_deleted_object_is_removed_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", headers={"X-Requested-With": "fetch"})
|
||||
assert _list(client, slug, screen_id) == []
|
||||
assert _list(slug, screen_id) == []
|
||||
|
||||
|
||||
def test_decor_has_no_role(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_ajax(client, slug, screen_id, kind="decor")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert entries[0]["role"] is None
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Déclencheurs de collision — onglet "Déclencheurs" (voir
|
||||
screens/rendering/trigger_graph.py, routes/triggers/triggers.py) : TOUS
|
||||
les déclencheurs du jeu, plus aucune notion de quête pour les regrouper —
|
||||
un créateur peut faire un simple quiz sans jamais poser de personnage.
|
||||
Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue
|
||||
({id, lines}, voir screens/rendering/collision_rules.py::"dialogue".dialogue),
|
||||
édité en ligne (voir routes POST .../triggers/<object_id>/dialogue
|
||||
ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute
|
||||
règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via
|
||||
screens.resolve_collision_rules directement sur l'objet."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def _add_personnage(client, slug, screen_id, role=None):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
if role:
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json")
|
||||
return obj_id
|
||||
|
||||
|
||||
def _set_rules(slug, object_id, rules):
|
||||
"""Pose des règles directement (sans passer par une route HTTP —
|
||||
l'ancienne route de sauvegarde, routes/scenes/scene_object_collision_rules.py,
|
||||
a été retirée avec l'éditeur de collision séparé) pour préparer un
|
||||
scénario de test."""
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules))
|
||||
|
||||
|
||||
def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": True}}])
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["mark_completed"] is True
|
||||
|
||||
|
||||
def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": False}}])
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert "mark_completed" not in rules[0]["action"]
|
||||
|
||||
|
||||
def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
nested_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
|
||||
_set_rules(slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}])
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
ids = [o["object_id"] for o in result]
|
||||
assert direct_id in ids
|
||||
assert nested_id in ids
|
||||
direct_entry = next(o for o in result if o["object_id"] == direct_id)
|
||||
assert direct_entry["has_interagir"] is False
|
||||
nested_entry = next(o for o in result if o["object_id"] == nested_id)
|
||||
assert nested_entry["has_interagir"] is True
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(client, tmp_game_slug_cleanup):
|
||||
"""Losange à embranchement (maquette validée, lecture verticale) :
|
||||
Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue,
|
||||
indépendamment l'un de l'autre — plus de "branche qui mène à telle
|
||||
quête" à choisir (voir static/js/triggers/trigger-editor.js::
|
||||
triggerConditionColumnHtml pour son affichage)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
|
||||
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
|
||||
},
|
||||
}])
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
entry = next(o for o in result if o["object_id"] == obj_id)
|
||||
assert entry["condition"]["variable"] == "inventaire_cle"
|
||||
assert entry["condition"]["si_vrai"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}]}
|
||||
assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]}
|
||||
|
||||
|
||||
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
entry = next(o for o in result if o["object_id"] == obj_id)
|
||||
assert entry["condition"]["si_faux"] is None
|
||||
|
||||
|
||||
def test_triggers_delete_removes_all_rules_on_the_object(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers",
|
||||
json={
|
||||
"object_id": obj_id,
|
||||
"rule": {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Même donnée, visible directement via la couche collision (source unique).
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
|
||||
}]
|
||||
|
||||
resp = client.get(f"/game/{slug}/triggers")
|
||||
result = resp.get_json()
|
||||
assert [o["object_id"] for o in result] == [obj_id]
|
||||
assert result[0]["has_interagir"] is True
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]}},
|
||||
}])
|
||||
result = screens.list_all_triggers(slug)
|
||||
assert result[0]["chain"] == [{"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}}]
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_a_chain_of_actions(client, tmp_game_slug_cleanup):
|
||||
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
||||
lui ajouter des actions") : dialogue -> then variable, la chaîne
|
||||
ENTIÈRE est aplatie dans l'ordre d'exécution."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
},
|
||||
}])
|
||||
result = screens.list_all_triggers(slug)
|
||||
assert result[0]["chain"] == [
|
||||
{"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
{"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
cond_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
other_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, direct_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
|
||||
}])
|
||||
_set_rules(slug, cond_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
|
||||
"si_faux": None,
|
||||
},
|
||||
}])
|
||||
_set_rules(slug, other_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
|
||||
}])
|
||||
|
||||
ids = screens.list_completable_dialogue_ids(slug)
|
||||
assert set(ids) == {"d_direct", "d_cond"}
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/dialogue",
|
||||
json={"dialogue_id": "d_absent", "lines": [{"speaker": "pnj", "text": "x"}]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
|
||||
resp = client.post(
|
||||
f"/game/{game}/triggers/999999/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": []},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
},
|
||||
}])
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
||||
resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
resp = client.get(f"/game/{slug}/triggers/objects-catalog")
|
||||
assert resp.status_code == 200
|
||||
catalog = resp.get_json()
|
||||
assert len(catalog) == 1
|
||||
assert catalog[0]["thumbnail_html"]
|
||||
|
||||
|
||||
def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour ces routes."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
||||
|
||||
_register(anon_client, "triggersisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
try:
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers", json={})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/dialogue", json={})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
_cleanup_project("triggersisolation@example.com")
|
||||
Reference in New Issue
Block a user