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.create_custom_event import create_custom_event
|
||||||
from .custom_events.update_custom_event import update_custom_event
|
from .custom_events.update_custom_event import update_custom_event
|
||||||
|
|
||||||
from .quests.constants import (
|
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
|
||||||
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
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||||
@@ -113,7 +103,5 @@ __all__ = [
|
|||||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||||
"delete_global_variable_by_id",
|
"delete_global_variable_by_id",
|
||||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||||
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
|
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
|
||||||
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
|
||||||
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
from .constants import QUEST_STATUS_CHOICES
|
|
||||||
|
|
||||||
_MAX_LINES_PER_COLUMN = 200
|
_MAX_LINES_PER_COLUMN = 200
|
||||||
_MAX_SPEAKER_LENGTH = 60
|
_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
|
# 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.
|
# 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_*/
|
# 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)
|
return _sanitize_dialogue_line(raw_line)
|
||||||
|
|
||||||
|
|
||||||
def sanitize_quest_dialogues(raw_dialogues):
|
def sanitize_dialogue_lines(raw_lines):
|
||||||
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
|
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
|
||||||
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
|
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
|
||||||
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
|
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
|
||||||
voir screens/rendering/scene_object_names.py) ET/OU de questions à
|
ET/OU de questions à choix ({type: "question", text, choices,
|
||||||
choix ({type: "question", text, choices, correct_index, reward_type,
|
correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
|
||||||
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit
|
ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
|
||||||
que resolve_collision_rules.sanitize_collision_rules : rejette tout
|
rejette tout élément invalide plutôt que de lever. Les lignes d'UN
|
||||||
élément invalide plutôt que de lever."""
|
dialogue embarqué dans une action "dialogue" (voir
|
||||||
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
screens/rendering/collision_rules.py) : chaque occurrence de
|
||||||
if not isinstance(raw_dialogues, dict):
|
"Déclencher ce dialogue" porte le sien."""
|
||||||
return result
|
if not isinstance(raw_lines, list):
|
||||||
for status in QUEST_STATUS_CHOICES:
|
return []
|
||||||
raw_lines = raw_dialogues.get(status)
|
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
|
||||||
if not isinstance(raw_lines, list):
|
return [line for line in cleaned if line]
|
||||||
continue
|
|
||||||
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
|
|
||||||
result[status] = [line for line in cleaned if line]
|
def sum_question_rewards(lines):
|
||||||
return result
|
"""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"
|
else "static/js/play/offline/scorm-api.js"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Score max possible (voir db/quests/reward_budget.py) : sans lui, un
|
# Score max possible (voir db/dialogue_lines.py::sum_question_rewards) :
|
||||||
# LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de points brut,
|
# sans lui, un LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de
|
||||||
# sans barème) et l'affiche à tort avec un "%" — demande utilisateur
|
# points brut, sans barème) et l'affiche à tort avec un "%" — demande
|
||||||
# "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, c'est un
|
# utilisateur "5% pour 1.2 mais 100% pour 2004 avec 1 seule question,
|
||||||
# défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant aussi
|
# c'est un défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant
|
||||||
# min/max (voir static/js/play/offline/scorm-api.js), le LMS peut
|
# 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
|
# calculer raw/max lui-même, un vrai pourcentage au lieu du score brut
|
||||||
# mal étiqueté.
|
# mal étiqueté. Les dialogues vivent dans les règles de collision des
|
||||||
max_quiz_score = sum(
|
# objets de scène (voir screens/rendering/collision_rules.py::
|
||||||
db.quest_dialogues_total_reward(quest["dialogues"]) for quest in db.list_quests(slug)
|
# "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,
|
# gameData.icon_data_uris (voir forgeRenderIcone,
|
||||||
# static/js/play/offline/render-special-widgets.js) : ajouté au
|
# 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"])
|
@app.route("/game/<slug>/variables/new", methods=["POST"])
|
||||||
def create_global_var(slug):
|
def create_global_var(slug):
|
||||||
"""Depuis la page de gestion des variables (game_variables.html) —
|
"""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
|
— idempotent par nom (voir db.create_global_variable), donc sans
|
||||||
cas de double soumission."""
|
risque en cas de double soumission."""
|
||||||
name = (request.form.get("name") or "").strip()
|
name = (request.form.get("name") or "").strip()
|
||||||
var_type = request.form.get("type") or "texte"
|
var_type = request.form.get("type") or "texte"
|
||||||
default_value = request.form.get("default_value") or ""
|
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
|
# É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
|
# explicitement rend la variable partagée par tous les joueurs, voir
|
||||||
# db/global_vars/create_global_variable.py) — absent du formulaire
|
# 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"
|
per_player = request.form.get("per_player", "1") == "1"
|
||||||
if name:
|
if name:
|
||||||
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
|
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
|
import db
|
||||||
|
|
||||||
@@ -7,5 +7,8 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||||
def global_var_delete(slug, var_id):
|
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)
|
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):
|
def global_var_edit(slug, var_id):
|
||||||
var_type = request.form.get("type") or "texte"
|
var_type = request.form.get("type") or "texte"
|
||||||
value = request.form.get("value") or ""
|
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)
|
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_commands,
|
||||||
scene_object_role,
|
scene_object_role,
|
||||||
scene_object_collision,
|
scene_object_collision,
|
||||||
scene_object_collision_rules,
|
|
||||||
scene_object_name,
|
scene_object_name,
|
||||||
scene_object_dialogue_box_style,
|
scene_object_dialogue_box_style,
|
||||||
scene_collision_rule_objects,
|
|
||||||
scene_object_names_list,
|
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 db
|
||||||
import screens
|
import screens
|
||||||
|
|
||||||
from .collision_rule_objects import build_collision_rule_objects
|
|
||||||
|
|
||||||
|
|
||||||
def render_scene_edit(slug, screen_id, game):
|
def render_scene_edit(slug, screen_id, game):
|
||||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
"""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"
|
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_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||||
flow_edges = screens.list_flow_edges(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,
|
flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view,
|
||||||
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
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),
|
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=custom_events, custom_event_usages=custom_event_usages,
|
||||||
custom_events_map_json=json.dumps(custom_events_map),
|
custom_events_map_json=json.dumps(custom_events_map),
|
||||||
definitions=db.list_definitions(slug),
|
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_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)}),
|
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),
|
# "qui parle" dans un dialogue de déclencheur (voir "ℹ️
|
||||||
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES,
|
# Informations", static/js/triggers/trigger-editor.js) : les noms
|
||||||
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX,
|
# donnés à N'IMPORTE QUEL objet de scène (personnage, décor,
|
||||||
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que
|
# fond), toutes scènes du jeu confondues.
|
||||||
# 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.
|
|
||||||
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
|
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):
|
def scene_object_names_list(slug):
|
||||||
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
||||||
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
||||||
templates/scene_edit.html, questSpeakerOptions() dans static/js/
|
templates/scene_edit.html, triggerSpeakerOptions() dans static/js/
|
||||||
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
|
triggers/trigger-editor.js) — cette variable n'est posée qu'une fois
|
||||||
chargement complet de la page (HORS de #builder3, jamais rafraîchie
|
au chargement complet de la page (HORS de #builder3, jamais
|
||||||
par une sélection d'objet sans rechargement, voir goToSelected()) :
|
rafraîchie par une sélection d'objet sans rechargement, voir
|
||||||
sans cet endpoint, renommer un personnage puis rouvrir la modale de
|
goToSelected()) : sans cet endpoint, renommer un personnage puis
|
||||||
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
|
rouvrir un dialogue de déclencheur dans la MÊME session ne faisait
|
||||||
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
|
jamais apparaître son nouveau nom, tant qu'aucun vrai rechargement de
|
||||||
temps — appelée en AJAX depuis saveSceneObjectName()."""
|
page n'avait eu lieu entre temps — appelée en AJAX depuis
|
||||||
|
saveSceneObjectName()."""
|
||||||
return jsonify(screens.list_named_scene_object_names(slug))
|
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.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_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
|
||||||
from .rendering.collision_rules import (
|
from .rendering.collision_rules import (
|
||||||
resolve_collision_rules, sanitize_collision_rules,
|
resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
|
||||||
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
|
||||||
)
|
)
|
||||||
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
||||||
from .rendering.scene_object_names import list_named_scene_object_names
|
from .rendering.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_name import set_scene_object_name
|
||||||
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||||
from .scenes.render_scene_object import render_scene_object
|
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 .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||||
|
|
||||||
from .flow.ensure_flow_schema import ensure_flow_schema
|
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_settings import resolve_collision_settings
|
||||||
from ..rendering.collision_rules import resolve_collision_rules
|
from ..rendering.collision_rules import resolve_collision_rules
|
||||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
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.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
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.
|
# ci-dessus sont déjà indexés par id, jamais par nom.
|
||||||
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
||||||
|
|
||||||
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
|
# Déclencheurs "terminés" (voir screens/rendering/collision_rules.py::
|
||||||
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
|
# "dialogue".mark_completed) : GAME-WIDE, la liste des id de dialogue
|
||||||
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
|
# que le créateur a marqués comme concluant la partie — nécessaire à
|
||||||
# à l'exécution d'une action "quete" (collision-rules-controller.js),
|
# static/js/play/dialogue-box-controller.js::
|
||||||
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
|
# forgeSyncAllDialoguesCompletionToScorm pour savoir quand TOUS ont
|
||||||
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
|
# été joués, sans plus aucune notion de quête.
|
||||||
quests = db.list_quests(slug)
|
completable_dialogue_ids = list_completable_dialogue_ids(slug)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||||
"data": data, "fields_meta": fields_meta,
|
"data": data, "fields_meta": fields_meta,
|
||||||
"variables": variables, "personnage_animations": personnage_animations,
|
"variables": variables, "personnage_animations": personnage_animations,
|
||||||
"definition_names": definition_names,
|
"definition_names": definition_names,
|
||||||
"quests": quests,
|
"completable_dialogue_ids": completable_dialogue_ids,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,166 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir
|
from db.dialogue_lines import sanitize_dialogue_lines
|
||||||
# templates/scene_edit.html) : chaque objet de scène (hors le personnage
|
from screens.flow.constants import CONDITION_OPERATORS
|
||||||
# "joueur" — voir screens/rendering/personnage_role.py — et un "fond",
|
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||||
# jamais une cible/source de collision) porte une LISTE de règles
|
|
||||||
# "déclencheur -> action", construites carte par carte dans l'éditeur.
|
# É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
|
# Déclencheur : "collision" (chevauchement direct, voir
|
||||||
# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le
|
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
|
||||||
# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet).
|
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
|
||||||
|
# demande explicite).
|
||||||
#
|
#
|
||||||
# Action (une seule par règle) :
|
# Action :
|
||||||
# - "quete" : quete_id (id technique d'une quête — voir db/quests/,
|
# - "dialogue" : la logique n'est plus portée par une "quête" mais
|
||||||
# choisie/créée via l'assistant "+ Action" qui ouvre
|
# DIRECTEMENT par le déclencheur — cette action EST un
|
||||||
# directement l'éditeur de quête, static/js/quests/
|
# dialogue, pas une référence à autre chose. `dialogue`
|
||||||
# quest-editor.js) — une quête inexistante/supprimée
|
# (optionnel) : {id, lines} — `id` généré côté client
|
||||||
# depuis reste une référence orpheline plutôt qu'une
|
# (voir static/js/scenes/collision-rules-editor.js),
|
||||||
# contrainte SQL, comme le reste de ce moteur (voir
|
# `lines` une liste de répliques/questions (voir
|
||||||
# evenement_id ci-dessous, même principe).
|
# db/dialogue_lines.py) — chaque occurrence de
|
||||||
# - "attaque" : joue la pose "attack" du personnage si disponible (voir
|
# "Déclencher ce dialogue" (directe, sous "interagir",
|
||||||
# personnage-controller.js) — pas de système de combat
|
# ou dans une branche de "condition") porte SON PROPRE
|
||||||
# (dégâts/PV...) pour l'instant, effet visuel seul.
|
# dialogue, édité en ligne dans l'onglet "Déclencheurs".
|
||||||
# - "evenement" : evenement_id — réutilise TEL QUEL le système
|
# `mark_completed` (optionnel, bool) : le créateur
|
||||||
# d'événements personnalisés existant (db.list_custom_events,
|
# marque explicitement ce déclencheur comme "terminé"
|
||||||
# window.dispatchGameEvent), la logique de flow d'un écran
|
# une fois ce dialogue joué (voir static/js/play/
|
||||||
# document peut donc déjà écouter ce déclenchement.
|
# dialogue-box-controller.js::
|
||||||
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du
|
# forgeSyncAllDialoguesCompletionToScorm).
|
||||||
# joueur, voir personnage_commands.py) tant que la
|
# - "variable" : modifie une variable globale — MÊME vocabulaire que
|
||||||
# collision/le périmètre est actif, puis exécute
|
# l'action "Modifier une variable" du graphe de logique
|
||||||
# `sub_action` (même forme qu'une action, SAUF
|
# document (voir screens/data_actions/apply_variable_action.py,
|
||||||
# "interagir" à nouveau — UN seul niveau d'imbrication,
|
# screens/labels/data_operations.py::DATA_OPERATION_LABELS,
|
||||||
# pas une récursion illimitée) à l'appui de la touche.
|
# réutilisé tel quel plutôt que réinventé) :
|
||||||
ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"]
|
# `target_variable` (nom), `data_operation` (une clé de
|
||||||
TRIGGER_TYPES = ["collision", "perimetre"]
|
# DATA_OPERATION_LABELS), `data_value` (optionnel, absent
|
||||||
DEFAULT_PERIMETRE_PX = 5
|
# 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
|
# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une
|
||||||
# structure sans fin construite par un JSON posté à la main.
|
# structure sans fin construite par un JSON posté à la main.
|
||||||
_MAX_ACTION_DEPTH = 2
|
_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):
|
def _sanitize_action(raw_action, depth=1):
|
||||||
@@ -47,31 +171,33 @@ def _sanitize_action(raw_action, depth=1):
|
|||||||
return None
|
return None
|
||||||
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
|
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
|
||||||
return None # un seul niveau d'imbrication autorisé
|
return None # un seul niveau d'imbrication autorisé
|
||||||
action = {"type": action_type}
|
if action_type in LEAF_ACTION_TYPES:
|
||||||
if action_type == "quete":
|
return _sanitize_leaf_action(raw_action)
|
||||||
try:
|
if action_type == "interagir":
|
||||||
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":
|
|
||||||
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
|
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
|
||||||
if not sub_action:
|
if not sub_action:
|
||||||
return None
|
return None
|
||||||
action["sub_action"] = sub_action
|
return {"type": "interagir", "sub_action": sub_action}
|
||||||
# "attaque" : aucun champ supplémentaire.
|
# "condition"
|
||||||
return action
|
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):
|
def sanitize_collision_rules(raw_rules):
|
||||||
"""Valide/nettoie une liste de règles postée par le client (voir
|
"""Valide/nettoie une liste de règles postée par le client (voir
|
||||||
routes/scenes/scene_object_collision_rules.py) — une règle invalide
|
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
|
||||||
(déclencheur/action inconnus, imbrication trop profonde...) est
|
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
|
||||||
IGNORÉE plutôt que de faire planter la sauvegarde entière."""
|
la sauvegarde entière."""
|
||||||
if not isinstance(raw_rules, list):
|
if not isinstance(raw_rules, list):
|
||||||
return []
|
return []
|
||||||
cleaned = []
|
cleaned = []
|
||||||
@@ -84,16 +210,96 @@ def sanitize_collision_rules(raw_rules):
|
|||||||
action = _sanitize_action(raw_rule.get("action"))
|
action = _sanitize_action(raw_rule.get("action"))
|
||||||
if not action:
|
if not action:
|
||||||
continue
|
continue
|
||||||
rule = {"trigger": trigger, "action": action}
|
cleaned.append({"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)
|
|
||||||
return cleaned
|
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):
|
def resolve_collision_rules(obj):
|
||||||
"""Décode attributes._collision_rules en liste Python, jamais None —
|
"""Décode attributes._collision_rules en liste Python, jamais None —
|
||||||
un attribut absent/corrompu retombe sur aucune règle (comportement
|
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",
|
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
|
||||||
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
|
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
|
||||||
# "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours
|
# "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
|
# static/js/play/dialogue-box-controller.js) — header (nom du
|
||||||
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
|
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
|
||||||
# entièrement stylisable par l'auteur.
|
# entièrement stylisable par l'auteur.
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ _EXCLUDED_KINDS = {"dialogue_box"}
|
|||||||
def list_named_scene_object_names(slug):
|
def list_named_scene_object_names(slug):
|
||||||
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
|
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
|
||||||
Informations", templates/scene_edit.html) — n'importe quel kind
|
Informations", templates/scene_edit.html) — n'importe quel kind
|
||||||
(personnage, décor, fond), toutes scènes 2D du jeu confondues : une
|
(personnage, décor, fond), toutes scènes 2D du jeu confondues : un
|
||||||
quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre
|
déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un
|
||||||
en scène un objet posé sur une autre scène que celle où la règle de
|
objet posé sur une autre scène que celle où la règle de collision se
|
||||||
collision se déclenche. Ordre alphabétique, jamais de doublon."""
|
déclenche. Ordre alphabétique, jamais de doublon."""
|
||||||
names = set()
|
names = set()
|
||||||
for s in list_screens(slug):
|
for s in list_screens(slug):
|
||||||
if s["kind"] != "jeu_2d":
|
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
|
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||||
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
||||||
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
||||||
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
|
scene_edit_view.py), par l'endpoint AJAX scene_collision_rule_objects.py
|
||||||
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
|
(appelé après ajout/suppression/changement de rôle ou de nom, voir
|
||||||
scene-editor.js) pour reconstruire la liste SANS recharger la page.
|
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
|
`objects` doit déjà porter "rendered_html" (voir
|
||||||
screens.render_scene_object) sur chaque élément.
|
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
|
# body/footer sont remplis EN JEU (data-dialogue-role sert de
|
||||||
# sélecteur), masqué par défaut au jeu (voir static/style.css,
|
# sélecteur), masqué par défaut au jeu (voir static/style.css,
|
||||||
# `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de
|
# `.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
|
# dans l'éditeur (avec un texte d'exemple) pour pouvoir le
|
||||||
# positionner/styliser.
|
# positionner/styliser.
|
||||||
style = resolve_dialogue_box_style(obj)
|
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 "ℹ️
|
partout ailleurs, mais jamais éditable avant le panneau "ℹ️
|
||||||
Informations", templates/scene_edit.html). Pour un personnage, ce nom
|
Informations", templates/scene_edit.html). Pour un personnage, ce nom
|
||||||
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
|
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
|
||||||
de quête (voir screens/rendering/scene_object_names.py, static/js/
|
d'un déclencheur (voir screens/rendering/scene_object_names.py,
|
||||||
quests/quest-editor.js)."""
|
static/js/triggers/trigger-editor.js)."""
|
||||||
ensure_scene_schema(slug)
|
ensure_scene_schema(slug)
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id))
|
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
|
// 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
|
// — pas seulement chaque brique testée isolément (voir
|
||||||
// collision-rules-controller.test.js et dialogue-box-controller.test.js).
|
// collision-rules-controller.test.js et dialogue-box-controller.test.js).
|
||||||
// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle
|
// 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
|
// "à la collision -> déclencher ce dialogue (avec mark_completed)", un
|
||||||
// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur
|
// widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran
|
||||||
// la même scène.
|
// d'offre automatique, le dialogue s'affiche directement et se marque
|
||||||
|
// terminé à la fin.
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
@@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); //
|
|||||||
const {
|
const {
|
||||||
forgeCollisionRuleTick,
|
forgeCollisionRuleTick,
|
||||||
} = require('../collision-rules-controller.js');
|
} = require('../collision-rules-controller.js');
|
||||||
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
|
|
||||||
|
|
||||||
function fakeEl(style) {
|
function fakeEl(style) {
|
||||||
const classes = new Set();
|
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 player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||||
const mrPinguin = {
|
const mrPinguin = {
|
||||||
id: 2, kind: 'personnage',
|
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||||
collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }],
|
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' };
|
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: '' };
|
boxWidget._footer = { innerHTML: '' };
|
||||||
|
|
||||||
global.window.currentScreenId = 1;
|
global.window.currentScreenId = 1;
|
||||||
|
global.window.forgeCompletedDialogueIds = {};
|
||||||
global.gameData = {
|
global.gameData = {
|
||||||
personnage_animations: {},
|
personnage_animations: {},
|
||||||
quests: [{
|
completable_dialogue_ids: ['d_intro'],
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
@@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
|||||||
try {
|
try {
|
||||||
const screenData = { elements: [player, mrPinguin, box] };
|
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._header.textContent, 'Mr. Pinguin');
|
||||||
assert.equal(boxWidget._body.textContent, 'Bonjour !');
|
assert.equal(boxWidget._body.textContent, 'Bonjour !');
|
||||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
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
|
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique
|
||||||
|
|
||||||
forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget)
|
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._body.textContent, 'Salut !');
|
||||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||||
|
|
||||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué
|
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||||
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie');
|
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||||
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
|
|
||||||
} finally { global.document = previousDoc; }
|
} 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
|
// 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
|
// 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
|
// la condition reste vraie — et gère "interagir" à part (bulle + attente
|
||||||
@@ -14,6 +14,7 @@ global.heldKeys = new Set();
|
|||||||
const actionsMod = require('../actions.js');
|
const actionsMod = require('../actions.js');
|
||||||
Object.assign(global, actionsMod);
|
Object.assign(global, actionsMod);
|
||||||
require('../personnage-controller.js');
|
require('../personnage-controller.js');
|
||||||
|
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||||
@@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour
|
|||||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
|
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => {
|
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 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)', () => {
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const previousShow = global.forgeShowDialogueBox;
|
const previousShow = global.forgeShowDialogueBox;
|
||||||
global.forgeShowDialogueBox = id => calls.push(id);
|
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||||
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
|
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;
|
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', () => {
|
test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => {
|
||||||
global.window.dispatchGameEvent = () => {};
|
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 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 playerEl = fakeEl();
|
||||||
const enemyEl = fakeEl();
|
const npcEl = fakeEl();
|
||||||
enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||||
|
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = fakeDocument({ 1: playerEl, 2: enemyEl });
|
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||||
global.gameData = { personnage_animations: {} };
|
global.gameData = { personnage_animations: {} };
|
||||||
|
const previousShow = global.forgeShowDialogueBox;
|
||||||
|
const calls = [];
|
||||||
|
global.forgeShowDialogueBox = id => calls.push(id);
|
||||||
try {
|
try {
|
||||||
const screenData = { elements: [player, enemy] };
|
const screenData = { elements: [player, npc] };
|
||||||
let attackCalls = 0;
|
|
||||||
const originalRun = global.runSpriteAnimation;
|
|
||||||
global.runSpriteAnimation = () => { attackCalls++; };
|
|
||||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||||
global.runSpriteAnimation = originalRun;
|
assert.deepEqual(calls, ['d_7']);
|
||||||
// "attaque" ne joue une animation que si le personnage a une pose
|
} finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; }
|
||||||
// "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; }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => {
|
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
|
// 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).
|
// (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: { 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 parent = fakeParentEl();
|
||||||
const playerEl = fakeEl(parent);
|
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', () => {
|
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 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 parentNear = fakeParentEl();
|
||||||
const parentFar = fakeParentEl();
|
const parentFar = fakeParentEl();
|
||||||
const playerElNear = fakeEl(parentNear);
|
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é)', () => {
|
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', 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
|
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
|
||||||
// static/js/play/dialogue-box-controller.js) : affiche la réplique en
|
// static/js/play/dialogue-box-controller.js) : affiche les répliques
|
||||||
// cours du dialogue d'une quête (gameData.quests) correspondant à son
|
// D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules-
|
||||||
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin.
|
// 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 test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
@@ -9,10 +13,9 @@ global.window = global.window || {};
|
|||||||
global.gameData = {};
|
global.gameData = {};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
|
||||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
||||||
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm,
|
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
|
||||||
forgeComputeFinalScormStatus,
|
forgeComputeFinalScormStatus,
|
||||||
} = require('../dialogue-box-controller.js');
|
} = require('../dialogue-box-controller.js');
|
||||||
|
|
||||||
@@ -43,50 +46,37 @@ test.beforeEach(() => {
|
|||||||
// fuiraient sur le calcul de statut final du test suivant.
|
// fuiraient sur le calcul de statut final du test suivant.
|
||||||
global.window.forgeQuizTotalAnswered = 0;
|
global.window.forgeQuizTotalAnswered = 0;
|
||||||
global.window.forgeQuizCorrectAnswered = 0;
|
global.window.forgeQuizCorrectAnswered = 0;
|
||||||
global.gameData = {
|
global.window.forgeCompletedDialogueIds = {};
|
||||||
quests: [
|
global.gameData = { completable_dialogue_ids: [] };
|
||||||
{ 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 !' }] } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeQuestById retrouve une quête par id', () => {
|
test('forgeShowDialogueBox — sans dialogueId : aucun effet (pas de plantage)', () => {
|
||||||
assert.equal(forgeQuestById(5).id, 5);
|
|
||||||
assert.equal(forgeQuestById(999), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
|
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||||
try {
|
try {
|
||||||
assert.doesNotThrow(() => forgeShowDialogueBox(999));
|
assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', []));
|
||||||
} finally { global.document = previousDoc; }
|
} 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 widget = fakeWidget(42);
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => widget };
|
global.document = { querySelector: () => widget };
|
||||||
try {
|
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(widget._classes.has('is-active'), false);
|
||||||
|
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||||
} finally { global.document = previousDoc; }
|
} finally { global.document = previousDoc; }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => {
|
test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => {
|
||||||
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
|
|
||||||
const widget = fakeWidget(42);
|
const widget = fakeWidget(42);
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => widget };
|
global.document = { querySelector: () => widget };
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(7);
|
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||||
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu');
|
assert.equal(widget._classes.has('is-active'), false);
|
||||||
assert.equal(widget._roles.body.textContent, 'Trouver le trésor');
|
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
|
||||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
|
||||||
assert.equal(widget._classes.has('is-active'), true);
|
|
||||||
} finally { global.document = previousDoc; }
|
} finally { global.document = previousDoc; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
|||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => null };
|
global.document = { querySelector: () => null };
|
||||||
try {
|
try {
|
||||||
assert.doesNotThrow(() => forgeShowDialogueBox(5));
|
assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]));
|
||||||
} finally { global.document = previousDoc; }
|
} 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 widget = fakeWidget(42);
|
||||||
const previousDoc = global.document;
|
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: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||||
try {
|
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.header.textContent, 'PNJ');
|
||||||
assert.equal(widget._roles.body.textContent, 'Merci !');
|
assert.equal(widget._roles.body.textContent, 'Merci !');
|
||||||
assert.equal(widget._classes.has('is-active'), true);
|
assert.equal(widget._classes.has('is-active'), true);
|
||||||
@@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
|||||||
} finally { global.document = previousDoc; }
|
} 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 widget = fakeWidget(42);
|
||||||
const previousDoc = global.document;
|
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 {
|
try {
|
||||||
forgeShowDialogueBox(5);
|
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||||
forgeDialogueBoxAdvance(42);
|
forgeDialogueBoxAdvance(42);
|
||||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué
|
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||||
assert.equal(widget._classes.has('is-active'), true);
|
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||||
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');
|
|
||||||
assert.equal(widget._classes.has('is-active'), false);
|
assert.equal(widget._classes.has('is-active'), false);
|
||||||
} finally { global.document = previousDoc; }
|
} 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);
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => widget };
|
global.document = { querySelector: () => widget };
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(5);
|
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||||
widget._classes.add('is-active');
|
forgeDialogueBoxAdvance(42);
|
||||||
forgeDialogueBoxDeclineQuest(42);
|
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||||
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début
|
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||||
assert.equal(widget._classes.has('is-active'), false);
|
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; }
|
} 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', () => {
|
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => null };
|
global.document = { querySelector: () => null };
|
||||||
@@ -206,36 +216,30 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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é !' },
|
||||||
|
];
|
||||||
|
|
||||||
test.beforeEach(() => {
|
test.beforeEach(() => {
|
||||||
global.gameData.quests.push({
|
|
||||||
id: 10, title: 'Le quiz du sorcier', status: 'en_cours',
|
|
||||||
dialogues: {
|
|
||||||
nouvelle: [],
|
|
||||||
en_cours: [
|
|
||||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
|
||||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
|
||||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
|
||||||
],
|
|
||||||
terminee: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
window.forgeQuizScore = 0;
|
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);
|
const dialogueWidget = fakeWidget(42);
|
||||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||||
const quizWidget = fakeQuizWidget(99, 2);
|
const quizWidget = fakeQuizWidget(99, 2);
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(10);
|
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||||
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
|
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
|
||||||
|
|
||||||
forgeDialogueBoxAdvance(42); // -> ligne "question"
|
forgeDialogueBoxAdvance(42); // -> ligne "question"
|
||||||
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la 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._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.equal(quizWidget._roles.question.textContent, '2+2 ?');
|
||||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
|
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
|
||||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(10);
|
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||||
forgeDialogueBoxAdvance(42); // -> question
|
forgeDialogueBoxAdvance(42); // -> question
|
||||||
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
||||||
assert.equal(window.forgeQuizScore, 0); // aucun point
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(10);
|
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||||
forgeDialogueBoxAdvance(42);
|
forgeDialogueBoxAdvance(42);
|
||||||
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
|
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
|
||||||
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(10);
|
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true);
|
||||||
forgeDialogueBoxAdvance(42); // -> question
|
forgeDialogueBoxAdvance(42); // -> question
|
||||||
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
||||||
assert.equal(window.forgeQuizScore, 5);
|
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._classes.has('is-active'), true);
|
||||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||||
|
|
||||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
|
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||||
assert.equal(forgeQuestById(10).status, 'terminee');
|
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||||
} finally { global.document = previousDoc; }
|
} 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;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
||||||
try {
|
try {
|
||||||
forgeShowDialogueBox(10);
|
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||||
forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement
|
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(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante
|
||||||
assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse
|
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) ----------
|
// ---------- 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
|
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
|
||||||
// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu
|
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
|
||||||
// construit uniquement avec des quêtes/quiz ne touchait jamais
|
// un jeu construit uniquement avec des dialogues/quiz ne touchait jamais
|
||||||
// gameData.scoring, laissant Score/Completion perpétuellement "unknown"
|
// gameData.scoring, laissant Score/Completion perpétuellement "unknown"
|
||||||
// dans le LMS même après avoir fini le jeu.
|
// 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; }
|
} 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);
|
const widget = fakeWidget(42);
|
||||||
global.gameData.scoring = undefined;
|
global.gameData.scoring = undefined;
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = { querySelector: () => widget };
|
global.document = { querySelector: () => widget };
|
||||||
try {
|
try {
|
||||||
forgeDialogueBoxAcceptQuest(5, 42);
|
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]);
|
||||||
assert.equal(gameData.scoring.status, 'en_cours');
|
assert.equal(gameData.scoring.status, 'en_cours');
|
||||||
} finally { global.document = previousDoc; }
|
} finally { global.document = previousDoc; }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => {
|
test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => {
|
||||||
global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }];
|
global.gameData.completable_dialogue_ids = ['d_1', 'd_2'];
|
||||||
|
window.forgeCompletedDialogueIds = { d_1: true };
|
||||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||||
forgeSyncAllQuestsCompletionToScorm();
|
forgeSyncAllDialoguesCompletionToScorm();
|
||||||
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
|
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
|
||||||
|
|
||||||
gameData.quests[1].status = 'terminee';
|
window.forgeCompletedDialogueIds.d_2 = true;
|
||||||
forgeSyncAllQuestsCompletionToScorm();
|
forgeSyncAllDialoguesCompletionToScorm();
|
||||||
assert.equal(gameData.scoring.status, 'termine');
|
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', () => {
|
test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => {
|
||||||
const widget = fakeWidget(42);
|
const calls = [];
|
||||||
global.gameData.quests = [
|
global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name });
|
||||||
{ id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } },
|
global.gameData.completable_dialogue_ids = ['d_20'];
|
||||||
];
|
|
||||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
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 {
|
try {
|
||||||
forgeShowDialogueBox(20);
|
forgeCompleteDialogue('d_20', 'Crokine');
|
||||||
forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee"
|
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||||
assert.equal(forgeQuestById(20).status, 'terminee');
|
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||||
assert.equal(gameData.scoring.status, 'termine');
|
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) ----------
|
// ---------- 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');
|
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => {
|
test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => {
|
||||||
global.gameData.quests = [{ id: 1, status: 'terminee' }];
|
global.gameData.completable_dialogue_ids = ['d_1'];
|
||||||
|
window.forgeCompletedDialogueIds = { d_1: true };
|
||||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||||
global.window.forgeQuizTotalAnswered = 4;
|
global.window.forgeQuizTotalAnswered = 4;
|
||||||
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
|
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
|
||||||
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||||
forgeSyncAllQuestsCompletionToScorm();
|
forgeSyncAllDialoguesCompletionToScorm();
|
||||||
assert.equal(gameData.scoring.status, 'echoue');
|
assert.equal(gameData.scoring.status, 'echoue');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ----------
|
// ---------- Granularité xAPI (dialogue terminé, 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; }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
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: '' }),
|
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = {
|
global.document = {
|
||||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
};
|
};
|
||||||
const calls = [];
|
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 {
|
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)
|
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||||
assert.equal(calls.length, 1);
|
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].correct, true);
|
||||||
assert.equal(calls[0].score, 5);
|
assert.equal(calls[0].score, 5);
|
||||||
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
|
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: '' }),
|
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
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;
|
const previousDoc = global.document;
|
||||||
global.document = {
|
global.document = {
|
||||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
};
|
};
|
||||||
const calls = [];
|
const calls = [];
|
||||||
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) =>
|
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||||
calls.push({ quest, index, text, correct, choiceIndex, correctIndex });
|
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||||
try {
|
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)
|
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||||
assert.equal(calls.length, 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].correct, false);
|
||||||
assert.equal(calls[0].choiceIndex, 1);
|
assert.equal(calls[0].choiceIndex, 1);
|
||||||
assert.equal(calls[0].correctIndex, 0);
|
assert.equal(calls[0].correctIndex, 0);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
|
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
|
||||||
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
|
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
|
||||||
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
|
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
|
||||||
// porter des règles "déclencheur -> action" posées dans l'onglet
|
// porter des règles "déclencheur -> action", posées depuis l'onglet
|
||||||
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier
|
// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
|
||||||
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre
|
// Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque
|
||||||
// la position du joueur, déclenche l'action une seule fois par entrée
|
// règle contre la position du joueur, déclenche l'action une seule fois
|
||||||
// (pas en continu tant que la condition reste vraie), même cadence que
|
// par entrée (pas en continu tant que la condition reste vraie), même
|
||||||
// personnage-controller.js (50ms) et réutilise ses mêmes briques
|
// cadence que personnage-controller.js (50ms) et réutilise ses mêmes
|
||||||
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
// briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||||
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
|
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
|
||||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||||
// branchement FORGE_OFFLINE nécessaire ici.
|
// 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
|
// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
|
||||||
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
|
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
|
||||||
// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes
|
// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
|
||||||
// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant
|
// servait à rien pour l'instant, demande explicite).
|
||||||
// pour une portée approximative, cohérent avec le reste du moteur qui ne
|
|
||||||
// vise pas une physique précise).
|
|
||||||
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||||
if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un
|
// 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';
|
if (bubble) bubble.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais
|
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||||
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick
|
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||||
// qui gère "interagir" à part (affichage de bulle + attente de touche).
|
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||||
|
// (affichage de bulle + attente de touche).
|
||||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||||
if (action.type === 'evenement') {
|
if (action.type === 'dialogue') {
|
||||||
window.dispatchGameEvent(action.evenement_id);
|
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||||
return;
|
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||||
}
|
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
||||||
if (action.type === 'attaque') {
|
// de dialogue" posé sur l'écran (voir static/js/play/
|
||||||
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null;
|
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
||||||
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||||
return;
|
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||||
}
|
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||||
if (action.type === 'quete') {
|
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||||
// Affiche la réplique en cours du dialogue de cette quête, selon son
|
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||||
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
|
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||||
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
|
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||||
// effet si aucun widget n'est posé ou si la quête n'a aucune
|
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||||
// réplique pour son statut, comme le reste de ce moteur.
|
// controller.js une fois le dialogue épuisé.
|
||||||
forgeShowDialogueBox(action.quete_id);
|
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||||
return;
|
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');
|
const conditionsMod = require('./conditions.js');
|
||||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||||
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
||||||
|
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
|
||||||
const personnageMod = require('./personnage-controller.js');
|
const personnageMod = require('./personnage-controller.js');
|
||||||
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
||||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||||
|
|||||||
@@ -1,55 +1,58 @@
|
|||||||
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
// ---------- 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
|
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||||||
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||||
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la
|
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
|
||||||
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne —
|
// (action.dialogue.lines, transmises telles quelles par
|
||||||
// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton
|
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
|
||||||
// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir
|
// dialogue" porte son propre dialogue, voir screens/rendering/
|
||||||
// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si
|
// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
|
||||||
// posée sur la scène — sinon ignorée silencieusement, la conversation
|
// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
|
||||||
// avance sans elle) : cliquer la BONNE réponse crédite le score du
|
// "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
|
||||||
// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la
|
// s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
|
||||||
// question reste affichée (le joueur peut réessayer).
|
// 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
|
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
|
||||||
// d'OFFRE ("Quête : <titre>" / objectif / boutons Accepter-Refuser) au
|
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
|
||||||
// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser
|
// globales et conditions (voir screens/rendering/collision_rules.py::
|
||||||
// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle",
|
// "condition") — le dialogue choisi s'affiche directement. Une action
|
||||||
// la prochaine interaction rejoue exactement le même dialogue). Une fois
|
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
|
||||||
// le dialogue "en_cours" épuisé (donc, s'il contenait des questions,
|
// (ou immédiatement s'il est vide), son id est ajouté à
|
||||||
// toutes répondues correctement), la quête passe "terminee" — le joueur
|
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
|
||||||
// peut alors quitter la scène normalement (aucun blocage, ces widgets ne
|
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
|
||||||
// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours
|
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
|
||||||
// (gameData.quests n'est qu'un instantané par joueur, voir
|
// dialogues `mark_completed` du jeu joués, voir
|
||||||
// full_game_payload.py) : jamais persisté en base, qui garde le statut
|
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
|
||||||
// de DÉPART pour toute nouvelle partie.
|
// 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é
|
// 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
|
// document (db/scoring/, action de flow "Modifier un score"), qui
|
||||||
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
|
// 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
|
// de propos pour ce sous-système entièrement client (voir
|
||||||
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
||||||
// serveur").
|
// serveur").
|
||||||
//
|
//
|
||||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||||
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
// l'écran — même esprit que le reste du moteur.
|
||||||
|
|
||||||
var forgeDialogueBoxState = {};
|
var forgeDialogueBoxState = {};
|
||||||
var forgeQuizBoxState = {};
|
var forgeQuizBoxState = {};
|
||||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||||
// uniquement à calculer un taux de réussite global (voir
|
// 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.
|
// qui ne compte que les points gagnés.
|
||||||
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
||||||
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
||||||
|
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
|
||||||
function forgeQuestById(questId) {
|
// forgeSyncAllDialoguesCompletionToScorm.
|
||||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||||
}
|
|
||||||
|
|
||||||
function forgeEscapeHtml(str) {
|
function forgeEscapeHtml(str) {
|
||||||
return String(str == null ? '' : 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
|
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||||
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer
|
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||||
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer
|
// affichée (voir forgeShowCurrentConversationLine).
|
||||||
// un nouveau dialogue dans la même boîte.
|
|
||||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||||
var footer = forgeDialogueBoxFooterEl(widget);
|
var footer = forgeDialogueBoxFooterEl(widget);
|
||||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
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/
|
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||||
// apply-actions.js, même forme {score, status}) — bug signalé : "les
|
// 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
|
// 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
|
// 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
|
// 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
|
// l'action de flow "Modifier un score") laissait Score/Completion
|
||||||
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
||||||
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
||||||
@@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() {
|
|||||||
gameData.scoring.score = window.forgeQuizScore;
|
gameData.scoring.score = window.forgeQuizScore;
|
||||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
// 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
|
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
|
||||||
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
|
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||||
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
|
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||||
|
// SCORM.
|
||||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
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
|
// encore gagné — sans cet appel, Score restait "0" mais Completion
|
||||||
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
|
// 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' };
|
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
|
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
|
||||||
// sont "terminee" — règle simple et prévisible plutôt que de deviner
|
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
|
||||||
// laquelle des quêtes créées "compte" pour la fin du jeu.
|
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
|
||||||
// Réussi/Échoué (voir db/games/get_success_threshold.py,
|
// é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/
|
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
|
||||||
// build_scorm_package.py) : demande utilisateur — "il manque
|
// build_scorm_package.py) : demande utilisateur — "il manque
|
||||||
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
||||||
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
||||||
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les
|
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
|
||||||
// quêtes terminées" qui est déjà la seule condition de fin de partie.
|
// les dialogues terminés" qui est déjà la seule condition de fin de
|
||||||
// Aucune question posée du tout (jeu fait uniquement de dialogues) :
|
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||||
// reste "termine" (ni réussi ni échoué, rien à évaluer).
|
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||||
function forgeComputeFinalScormStatus() {
|
function forgeComputeFinalScormStatus() {
|
||||||
var total = window.forgeQuizTotalAnswered || 0;
|
var total = window.forgeQuizTotalAnswered || 0;
|
||||||
if (total === 0) return 'termine';
|
if (total === 0) return 'termine';
|
||||||
@@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() {
|
|||||||
return percent >= threshold ? 'reussi' : 'echoue';
|
return percent >= threshold ? 'reussi' : 'echoue';
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeSyncAllQuestsCompletionToScorm() {
|
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||||
var quests = gameData.quests || [];
|
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||||
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
|
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' };
|
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||||
var finalStatus = forgeComputeFinalScormStatus();
|
var finalStatus = forgeComputeFinalScormStatus();
|
||||||
gameData.scoring.status = finalStatus;
|
gameData.scoring.status = finalStatus;
|
||||||
@@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
|||||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||||
if (line.type === 'question') {
|
if (line.type === 'question') {
|
||||||
if (widget) widget.classList.remove('is-active');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
forgeHideQuizBoxOnCurrentScreen();
|
forgeHideQuizBoxOnCurrentScreen();
|
||||||
@@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
|||||||
widget.classList.add('is-active');
|
widget.classList.add('is-active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
|
||||||
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
|
||||||
// comme le reste du moteur 2D).
|
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
|
||||||
function forgeShowDialogueBox(questId) {
|
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
|
||||||
var quest = forgeQuestById(questId);
|
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
|
||||||
if (!quest) return;
|
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
|
||||||
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
|
// 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();
|
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
var objectId = widget.getAttribute('data-object-id');
|
var objectId = widget.getAttribute('data-object-id');
|
||||||
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose
|
forgeSyncDialogueStartedToScorm();
|
||||||
// quand même directement l'offre (accepter/refuser), les autres
|
|
||||||
// statuts restent silencieux (rien à montrer).
|
|
||||||
if (!lines.length) {
|
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;
|
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);
|
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
|
// Avance d'une ligne (fin de dialogue -> question, fin de question
|
||||||
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
|
// 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) {
|
function forgeAdvanceConversation(dialogueObjectId) {
|
||||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
@@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
|||||||
if (state.index >= state.lines.length) {
|
if (state.index >= state.lines.length) {
|
||||||
forgeHideQuizBoxOnCurrentScreen();
|
forgeHideQuizBoxOnCurrentScreen();
|
||||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||||
var quest = forgeQuestById(state.questId);
|
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||||
if (quest && quest.status === 'nouvelle') {
|
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||||
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 (widget) widget.classList.remove('is-active');
|
if (widget) widget.classList.remove('is-active');
|
||||||
delete forgeDialogueBoxState[dialogueObjectId];
|
delete forgeDialogueBoxState[dialogueObjectId];
|
||||||
return;
|
return;
|
||||||
@@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||||
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
|
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
|
||||||
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz,
|
// Informations"), corps = la question + ses choix — si l'auteur n'a posé
|
||||||
// la question est ignorée (avance directement) plutôt que de bloquer la
|
// aucune boîte à quiz, la question est ignorée (avance directement)
|
||||||
// conversation pour toujours en attendant une réponse qu'aucun widget ne
|
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||||
// peut recueillir.
|
// réponse qu'aucun widget ne peut recueillir.
|
||||||
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) {
|
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||||
var quizObjectId = widget.getAttribute('data-object-id');
|
var quizObjectId = widget.getAttribute('data-object-id');
|
||||||
forgeQuizBoxState[quizObjectId] = {
|
forgeQuizBoxState[quizObjectId] = {
|
||||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
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 header = widget.querySelector('[data-quiz-role="header"]');
|
||||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
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 (questionEl) questionEl.textContent = line.text;
|
||||||
if (choicesEl) {
|
if (choicesEl) {
|
||||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||||
@@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
window.forgeQuizCorrectAnswered += 1;
|
window.forgeQuizCorrectAnswered += 1;
|
||||||
forgeAddQuizScore(state.rewardAmount);
|
forgeAddQuizScore(state.rewardAmount);
|
||||||
}
|
}
|
||||||
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
|
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||||
forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||||
}
|
}
|
||||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) {
|
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||||
forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
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
|
// 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
|
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||||
@@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) {
|
|||||||
forgeAdvanceConversation(state.dialogueObjectId);
|
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
|
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||||
// contrôleurs de ce dossier.
|
// contrôleurs de ce dossier.
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
forgeResetDialogueBoxFooter,
|
||||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||||
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
|
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||||
forgeComputeFinalScormStatus,
|
forgeComputeFinalScormStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
|||||||
global.window.API_1484_11 = api;
|
global.window.API_1484_11 = api;
|
||||||
try {
|
try {
|
||||||
forgeScorm2004Init();
|
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]]));
|
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.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.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 ?');
|
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;
|
global.window.API_1484_11 = api;
|
||||||
try {
|
try {
|
||||||
forgeScorm2004Init();
|
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]]));
|
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.result'], 'incorrect');
|
||||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
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
|
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
|
||||||
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
||||||
// ceci lèverait en tentant d'appeler .GetValue sur null.
|
// 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
|
// 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
|
// granularité par dialogue/question demandée par l'utilisateur (dialogue
|
||||||
// acceptée/terminée, réponse à une question), en plus du score/statut
|
// terminé, réponse à une question), en plus du score/statut global déjà
|
||||||
// global déjà couvert par dialogue-box-controller.test.js.
|
// couvert par dialogue-box-controller.test.js.
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
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'));
|
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||||
forgeXapiQuestObject, forgeXapiQuestionObject,
|
forgeXapiDialogueObject, forgeXapiQuestionObject,
|
||||||
} = require('../xapi-client.js');
|
} = require('../xapi-client.js');
|
||||||
|
|
||||||
test.beforeEach(() => {
|
test.beforeEach(() => {
|
||||||
@@ -21,45 +21,35 @@ test.beforeEach(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
|
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 = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
|
const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
|
||||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
|
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||||
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
|
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', () => {
|
test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
|
||||||
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
|
const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
|
||||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
|
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
|
||||||
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
|
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 = [];
|
const calls = [];
|
||||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
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');
|
||||||
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' });
|
|
||||||
const body = JSON.parse(calls[0].opts.body);
|
const body = JSON.parse(calls[0].opts.body);
|
||||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
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);
|
assert.equal(body.result.completion, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
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);
|
const body = JSON.parse(calls[0].opts.body);
|
||||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
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.success, true);
|
||||||
assert.equal(body.result.score.raw, 5);
|
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', () => {
|
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
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);
|
const body = JSON.parse(calls[0].opts.body);
|
||||||
assert.equal(body.result.success, false);
|
assert.equal(body.result.success, false);
|
||||||
assert.equal(body.result.score.raw, 0);
|
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)', () => {
|
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
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);
|
const body = JSON.parse(calls[0].opts.body);
|
||||||
assert.equal(body.verb.display['en-US'], 'attempted');
|
assert.equal(body.verb.display['en-US'], 'completed');
|
||||||
assert.equal(body.verb.display['fr-FR'], 'commencé');
|
assert.equal(body.verb.display['fr-FR'], 'terminé');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
||||||
global.window.FORGE_XAPI_CONFIG = null;
|
global.window.FORGE_XAPI_CONFIG = null;
|
||||||
let called = false;
|
let called = false;
|
||||||
global.fetch = () => { called = true; return Promise.resolve(); };
|
global.fetch = () => { called = true; return Promise.resolve(); };
|
||||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
|
forgeXapiNotifyDialogueCompleted('d_5', 'x');
|
||||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
|
forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
|
||||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
|
|
||||||
assert.equal(called, false);
|
assert.equal(called, false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function forgeScormInit() {
|
|||||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||||
forgeScormApi.LMSInitialize("");
|
forgeScormApi.LMSInitialize("");
|
||||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
// 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
|
// (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
|
// 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.
|
// 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$/, "");
|
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;
|
if (!forgeScorm2004Api) return;
|
||||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||||
if (isNaN(n)) n = 0;
|
if (isNaN(n)) n = 0;
|
||||||
var prefix = "cmi.interactions." + n + ".";
|
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 + "type", "choice");
|
||||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
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) :
|
// 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
|
// (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
|
||||||
// id dérivé — assez pour que le LRS les distingue sans configuration
|
// id dérivé — assez pour que le LRS les distingue sans configuration
|
||||||
// supplémentaire côté créateur du jeu.
|
// supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet
|
||||||
function forgeXapiQuestObject(quest) {
|
// 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;
|
var config = window.FORGE_XAPI_CONFIG;
|
||||||
return {
|
return {
|
||||||
objectType: "Activity",
|
objectType: "Activity",
|
||||||
id: config.activity_id + ":quest:" + quest.id,
|
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||||
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
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;
|
var config = window.FORGE_XAPI_CONFIG;
|
||||||
return {
|
return {
|
||||||
objectType: "Activity",
|
objectType: "Activity",
|
||||||
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
|
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||||
definition: {
|
definition: {
|
||||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
|
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||||
description: { "fr-FR": questionText || "" },
|
description: { "fr-FR": questionText || "" },
|
||||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||||
interactionType: "choice",
|
interactionType: "choice",
|
||||||
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande
|
// Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande
|
||||||
// explicite "il faut savoir quand une quête a été acceptée, à telle
|
// explicite "il faut savoir [...] à telle question quel point obtenu,
|
||||||
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ
|
// bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du
|
||||||
// par évènement, distinct du score/statut global (forgeXapiNotify
|
// score/statut global (forgeXapiNotifyScoreChanged/StatusChanged
|
||||||
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de
|
// ci-dessus, qui restent l'activité de premier niveau).
|
||||||
// premier niveau).
|
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||||
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) {
|
|
||||||
if (!window.FORGE_XAPI_CONFIG) return;
|
if (!window.FORGE_XAPI_CONFIG) return;
|
||||||
forgeXapiSendStatement(
|
forgeXapiSendStatement(
|
||||||
"http://adlnet.gov/expapi/verbs/completed",
|
"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;
|
if (!window.FORGE_XAPI_CONFIG) return;
|
||||||
forgeXapiSendStatement(
|
forgeXapiSendStatement(
|
||||||
"http://adlnet.gov/expapi/verbs/answered",
|
"http://adlnet.gov/expapi/verbs/answered",
|
||||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
{ 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) {
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||||
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
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") ----------
|
// ---------- Assistant "déclencheur -> action" (fusionné dans l'onglet "Déclencheurs") ----------
|
||||||
// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage
|
// L'onglet "🧩 Collision" séparé a été retiré (redondant une fois la
|
||||||
// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/
|
// vue/gestion des déclencheurs intégrée à l'onglet "Déclencheurs", voir
|
||||||
// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà
|
// static/js/triggers/trigger-editor.js::openAddTriggerModal) — ce
|
||||||
// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais
|
// fichier ne porte plus que le MOTEUR de l'assistant lui-même
|
||||||
// un vrai formulaire à plusieurs champs) : un clic choisit une option, la
|
// (déclencheur -> action, un clic choisit une option, la carte suivante
|
||||||
// carte suivante s'emboîte aussitôt. Voir screens/rendering/
|
// s'emboîte aussitôt), invoqué exclusivement via openTriggerWizard()
|
||||||
// collision_rules.py pour la forme exacte d'une règle (côté serveur, où
|
// ci-dessous. Voir screens/rendering/collision_rules.py pour la forme
|
||||||
// elle est validée une seconde fois avant d'être enregistrée).
|
// 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 = {
|
var COLLISION_ACTION_LABELS = {
|
||||||
quete: 'Déclencher une quête', attaque: 'Attaquer la cible',
|
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||||
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche',
|
interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
|
||||||
};
|
};
|
||||||
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le
|
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
|
||||||
// moment, demande explicite) — server-side (screens/rendering/
|
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
|
||||||
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles,
|
// Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||||
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux.
|
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||||
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir'];
|
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' };
|
// pour celles-ci.
|
||||||
|
var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||||
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>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||||
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
|
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
|
||||||
// dans la scène (left/top de plusieurs centaines de px) ressortait donc
|
// 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
|
// hors du petit cadre de la miniature : la règle CSS `.questTriggerObjectCard
|
||||||
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le
|
// img`/`.questTriggerObjectThumb img` (static/style.css) est censée le
|
||||||
// neutraliser (position:static !important etc.), mais s'appuyer sur un
|
// neutraliser, mais s'appuyer sur un forçage CSS pour annuler un style
|
||||||
// forçage CSS pour annuler un style inline entier reste fragile. Plus
|
// inline entier reste fragile. Plus robuste : reconstruire ici une balise
|
||||||
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style
|
// <img> "propre", sans AUCUN style hérité du canevas — seul le src
|
||||||
// hérité du canevas — seul le src (l'image elle-même) est repris.
|
// (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) {
|
function collisionRuleThumbHtml(obj) {
|
||||||
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||||||
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function collisionRuleCardHtml(obj) {
|
// Id court généré CÔTÉ CLIENT pour une NOUVELLE feuille ("dialogue" ou
|
||||||
var rulesHtml = obj.rules.length
|
// "variable", voir screens/rendering/collision_rules.py) — jamais
|
||||||
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
|
// recalculé/réattribué côté serveur. Pour un dialogue, sert de
|
||||||
: '<p class="hint">Aucune règle pour l\'instant.</p>';
|
// dialogue.id (édité en ligne, voir trigger-editor.js) ; pour une
|
||||||
return (
|
// variable, sert d'id à la feuille elle-même — dans les deux cas,
|
||||||
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
|
// permet d'adresser CETTE feuille précise pour y attacher un "then"
|
||||||
'<div class="collisionRuleCardHeader">' +
|
// plus tard (voir "+ Ajouter une action", trigger-editor.js).
|
||||||
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' +
|
function collisionGenerateActionId() {
|
||||||
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '<') + '</h3>' +
|
return 'd_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
'</div>' +
|
|
||||||
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
|
|
||||||
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
|
|
||||||
'</div>'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollisionRuleCards() {
|
// ---------- Assistant "déclencheur -> action" ----------
|
||||||
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" ----------
|
|
||||||
// État du parcours en cours — un seul à la fois (la modale n'autorise
|
// É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,
|
// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi,
|
||||||
// et l'action en cours de construction (posée directement dans
|
// et l'action en cours de construction (posée directement dans
|
||||||
// `action`, ou dans `action.sub_action` au second niveau pour "interagir").
|
// `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;
|
var _collisionWizard = null;
|
||||||
|
|
||||||
function openCollisionRuleModal(objectId) {
|
// Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
|
||||||
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX };
|
// (voir triggerSelectObject, trigger-editor.js).
|
||||||
document.getElementById('collisionRuleModal').classList.add('is-active');
|
function openTriggerWizard(objectId, bodyEl) {
|
||||||
|
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||||
renderCollisionWizardTriggerStep();
|
renderCollisionWizardTriggerStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCollisionRuleModal() {
|
|
||||||
document.getElementById('collisionRuleModal').classList.remove('is-active');
|
|
||||||
_collisionWizard = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collisionWizardBodyEl() {
|
function collisionWizardBodyEl() {
|
||||||
return document.getElementById('collisionRuleModalBody');
|
return _collisionWizard.bodyEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
|
// Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
|
||||||
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
|
// 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() {
|
function renderCollisionWizardTriggerStep() {
|
||||||
collisionWizardBodyEl().innerHTML =
|
collisionWizardBodyEl().innerHTML =
|
||||||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
'<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\')">' +
|
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||||||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||||||
'</button>' +
|
'</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>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function collisionWizardChooseTrigger(trigger) {
|
function collisionWizardChooseTrigger(trigger) {
|
||||||
_collisionWizard.trigger = trigger;
|
_collisionWizard.trigger = trigger;
|
||||||
if (trigger === 'perimetre') {
|
|
||||||
var pxEl = document.getElementById('collisionWizardPerimetrePx');
|
|
||||||
_collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX;
|
|
||||||
}
|
|
||||||
renderCollisionWizardActionStep(false);
|
renderCollisionWizardActionStep(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,54 +121,233 @@ function collisionWizardFinalizeAction(action, nested) {
|
|||||||
collisionWizardSaveRule(action);
|
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) {
|
function collisionWizardSaveRule(action) {
|
||||||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||||||
if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx;
|
closeTriggerModal();
|
||||||
var obj = collisionRuleObjectById(_collisionWizard.objectId);
|
fetch('/game/' + GAME_SLUG + '/triggers', {
|
||||||
obj.rules.push(rule);
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
var objectId = _collisionWizard.objectId;
|
body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
|
||||||
closeCollisionRuleModal();
|
}).then(function () { triggerLoadAll(); });
|
||||||
saveCollisionRules(objectId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chaque type d'action demande éventuellement une précision avant de
|
// "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
|
||||||
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
|
// collision_rules.py) enchaînent sur "Ensuite ?" (voir
|
||||||
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
|
// renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
|
||||||
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
|
// action, demande explicite : "pouvoir modifier un déclencheur pour lui
|
||||||
// plus, se finalise directement.
|
// 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) {
|
function collisionWizardChooseAction(type, nested) {
|
||||||
if (type === 'attaque' || type === 'interagir') {
|
if (type === 'interagir') {
|
||||||
collisionWizardFinalizeAction({ type: type }, nested);
|
collisionWizardFinalizeAction({ type: type }, nested);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'quete') {
|
if (type === 'dialogue') {
|
||||||
renderQuestPickerStep(function (quest) {
|
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||||
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
|
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'variable') {
|
||||||
|
renderCollisionWizardVariableStep(function (action) {
|
||||||
|
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'evenement') {
|
if (type === 'condition') {
|
||||||
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
|
renderCollisionWizardConditionStep(nested);
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
// ---------- Étape "Modifier une variable" ----------
|
||||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
// Même vocabulaire EXACT que l'action "Modifier une variable" du graphe
|
||||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
// de logique document (GLOBAL_VARIABLE_NAMES et DATA_OPERATION_LABELS_MAP
|
||||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
// déjà posés par templates/scene_edit.html — ce dernier n'était utilisé
|
||||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
// nulle part avant cette fonctionnalité) : variable ciblée, opération,
|
||||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
// valeur (masquée pour les opérations qui n'en ont pas besoin, voir
|
||||||
// l'onglet "🧩 Collision" restait vide après un remplacement de <main>
|
// COLLISION_NO_VALUE_OPERATIONS). `onChoose(action)`.
|
||||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
function renderCollisionWizardVariableStep(onChoose) {
|
||||||
// ne redéclenche jamais cet événement.
|
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||||
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|
.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="canvasElementInner">' + json.rendered_html + '</div>' +
|
||||||
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
|
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
|
||||||
canvas.appendChild(el);
|
canvas.appendChild(el);
|
||||||
refreshCollisionRuleObjects();
|
|
||||||
if (json.animations) {
|
if (json.animations) {
|
||||||
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
||||||
restartPersonnageIdlePreview(json.id, json.animations);
|
restartPersonnageIdlePreview(json.id, json.animations);
|
||||||
@@ -87,7 +86,6 @@ function deleteSceneObject(id) {
|
|||||||
var el = document.getElementById('el-' + id);
|
var el = document.getElementById('el-' + id);
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||||
refreshCollisionRuleObjects();
|
|
||||||
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
|
// 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
|
// est affiché) — referme le panneau de propriétés et l'URL, sans
|
||||||
// requête supplémentaire au serveur.
|
// requête supplémentaire au serveur.
|
||||||
@@ -355,25 +353,7 @@ function saveSceneObjectRole(role) {
|
|||||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ role: role }),
|
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boîte de collision (voir screens/rendering/collision_settings.py,
|
// Boîte de collision (voir screens/rendering/collision_settings.py,
|
||||||
@@ -579,26 +559,24 @@ function addPersonnageCommandExtraRow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
|
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
|
||||||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de
|
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
|
||||||
// quête (voir static/js/quests/quest-editor.js).
|
// déclencheur (voir static/js/triggers/trigger-editor.js).
|
||||||
function saveSceneObjectName(objectId, name) {
|
function saveSceneObjectName(objectId, name) {
|
||||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: name }),
|
body: JSON.stringify({ name: name }),
|
||||||
}).then(function () {
|
}).then(refreshSceneObjectNames);
|
||||||
refreshCollisionRuleObjects();
|
|
||||||
refreshSceneObjectNames();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
|
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
|
||||||
// posée UNE SEULE FOIS au chargement complet de la page (<script> inline
|
// 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
|
// en bas, HORS de #builder3, jamais rafraîchie par une simple sélection
|
||||||
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
|
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
|
||||||
// renommer un personnage puis ouvrir la modale de dialogue dans la MÊME
|
// renommer un personnage puis ouvrir un dialogue de déclencheur dans la
|
||||||
// session ne faisait jamais apparaître son nouveau nom dans "qui parle"
|
// MÊME session ne faisait jamais apparaître son nouveau nom dans "qui
|
||||||
// (voir questSpeakerOptions(), static/js/quests/quest-editor.js) tant
|
// parle" (voir triggerSpeakerOptions(), static/js/triggers/
|
||||||
// qu'aucun vrai rechargement de page n'avait eu lieu entre temps.
|
// trigger-editor.js) tant qu'aucun vrai rechargement de page n'avait eu
|
||||||
|
// lieu entre temps.
|
||||||
function refreshSceneObjectNames() {
|
function refreshSceneObjectNames() {
|
||||||
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
||||||
fetch('/game/' + GAME_SLUG + '/scene-object-names')
|
fetch('/game/' + GAME_SLUG + '/scene-object-names')
|
||||||
@@ -721,21 +699,16 @@ initPersonnageCommandsPanel();
|
|||||||
// viennent d'être recréés par le même remplacement, voir
|
// viennent d'être recréés par le même remplacement, voir
|
||||||
// startAllPersonnagePreviews()/personnage-preview.js).
|
// startAllPersonnagePreviews()/personnage-preview.js).
|
||||||
//
|
//
|
||||||
// .builderCanvasArea (les onglets "🖥️ Scène"/"🧩 Collision"/"🗺️ Quêtes"/
|
// .builderCanvasArea (les onglets "🖥️ Scène"/"Déclencheurs"/"📣 Événements")
|
||||||
// "📣 Événements") est ENTIÈREMENT à l'intérieur de #builder3 (voir
|
// est ENTIÈREMENT à l'intérieur de #builder3 (voir templates/scene_edit.html)
|
||||||
// templates/scene_edit.html) — le remplacer réinjecte donc aussi
|
// — le remplacer réinjecte donc aussi le conteneur des déclencheurs tel
|
||||||
// #collisionRulesCards/le conteneur des quêtes tels que le SERVEUR les
|
// que le SERVEUR le rend, c'est-à-dire VIDE (rendu entièrement en JS,
|
||||||
// rend, c'est-à-dire VIDES (ces listes sont rendues entièrement en JS,
|
// jamais en Jinja, voir trigger-editor.js) : sans le redessiner ici,
|
||||||
// jamais en Jinja, voir collision-rules-editor.js/quest-editor.js) : sans
|
// l'onglet "Déclencheurs" semblait se "vider" à chaque sélection d'un
|
||||||
// les redessiner ici, les onglets "🧩 Collision"/"🗺️ Quêtes" semblaient se
|
// objet de scène.
|
||||||
// "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.
|
|
||||||
function initSceneBuilderPanel() {
|
function initSceneBuilderPanel() {
|
||||||
initScenePosFields();
|
initScenePosFields();
|
||||||
initPersonnageCommandsPanel();
|
initPersonnageCommandsPanel();
|
||||||
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
||||||
if (typeof renderCollisionRuleCards === 'function') renderCollisionRuleCards();
|
if (typeof initTriggersTab === 'function') initTriggersTab();
|
||||||
if (typeof renderQuestListPage === 'function') renderQuestListPage();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
static/js/play/dialogue-box-controller.js) : header/body/footer,
|
||||||
toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/
|
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
|
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{
|
.dialogueBoxWidget{
|
||||||
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
||||||
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
|
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;
|
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
|
||||||
}
|
}
|
||||||
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
|
.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,
|
/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py,
|
||||||
static/js/play/dialogue-box-controller.js) : même squelette que
|
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: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; }
|
.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,
|
/* Bulle "Appuie sur X" (voir forgeShowCollisionInteractBubble,
|
||||||
collision-rules-controller.js) : SŒUR de l'objet dans son parent
|
collision-rules-controller.js) : SŒUR de l'objet dans son parent
|
||||||
(.sceneWorld/.sceneUI, déjà position:relative) plutôt qu'ENFANT
|
(.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;
|
white-space:nowrap; pointer-events:none; z-index:20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Éditeur de quêtes (onglet "🗺️ Quêtes" à côté de "🧩 Collision",
|
/* ---- Reliquat de l'ancien onglet "🗺️ Quêtes" (retiré, voir onglet
|
||||||
templates/scene_edit.html + modale de dialogue partagée avec
|
"Déclencheurs" et static/js/triggers/trigger-editor.js) — règles
|
||||||
l'assistant "+ Action" de l'éditeur de collision, voir
|
orphelines gardées telles quelles, aucune ne s'applique plus. ---- */
|
||||||
static/js/quests/quest-editor.js) ---- */
|
|
||||||
.questListRow{ cursor:pointer; }
|
.questListRow{ cursor:pointer; }
|
||||||
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
|
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
|
||||||
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
|
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
|
||||||
@@ -984,33 +983,166 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
.questDialogueModal{ z-index:61; }
|
.questDialogueModal{ z-index:61; }
|
||||||
.questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; }
|
.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
|
/* 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
|
overflow:hidden ici) : le header reste fixe en haut, le panneau de
|
||||||
colonnes de répliques se partagent TOUT le reste de la hauteur
|
déclencheurs prend TOUT le reste de la hauteur. */
|
||||||
(demandé explicitement) et défilent CHACUNE indépendamment (voir
|
|
||||||
.questDialogueColumnBubbles plus bas), jamais toute la page. */
|
|
||||||
.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; }
|
.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.questDialogueModalContent{ width:100%; height:100%; max-width:100%; }
|
.questDialogueModalContent{ width:100%; height:100%; max-width:100%; }
|
||||||
#questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
|
#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; }
|
.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; }
|
.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{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; }
|
||||||
.questField-wide{ grid-column:span 2; }
|
.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{
|
.questDialogueColumn{
|
||||||
background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px;
|
width:90%; margin:0 auto; height:100%; min-height:0; display:flex; flex-direction:column; gap:8px;
|
||||||
display:flex; flex-direction:column; gap:8px; height:100%; min-height:0;
|
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; }
|
.questDialogueColumnBody{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; gap:14px; padding-top:4px; }
|
||||||
/* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un
|
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; flex-wrap:wrap; }
|
||||||
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; }
|
|
||||||
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
|
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
|
||||||
.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); }
|
.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{ display:flex; justify-content:center; width:100%; position:relative; }
|
||||||
.questBubbleRow:not(:first-child)::before{
|
.questBubbleRow:not(:first-child)::before{
|
||||||
content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%);
|
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-pnj .questBubbleBody{ background:rgba(47,111,237,.16); }
|
||||||
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
|
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
|
||||||
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
|
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
|
||||||
/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/
|
/* "❓ Question" (voir triggerQuestionBubbleHtml, static/js/triggers/
|
||||||
quest-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
trigger-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
||||||
même chaîne/alignement vertical (.questBubbleRow). */
|
même chaîne/alignement vertical (.questBubbleRow). */
|
||||||
.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; }
|
.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; }
|
||||||
.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); }
|
.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); }
|
||||||
.questQuestionHeaderField{
|
.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{
|
.questQuestionHeaderField input{
|
||||||
width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px;
|
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;
|
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; }
|
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||||
|
|
||||||
/* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut,
|
/* 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
|
sous-conteneur dédié au défilement — sans ça, une liste plus haute que
|
||||||
l'espace disponible restait simplement coupée par .builderCanvasArea
|
l'espace disponible restait simplement coupée par .builderCanvasArea
|
||||||
(overflow:hidden), au lieu de défiler. */
|
(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
|
// 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).
|
// que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js).
|
||||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }};
|
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
|
// 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
|
// (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é "%".
|
// 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('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 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>
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,15 +124,16 @@
|
|||||||
<div class="builderCanvasArea">
|
<div class="builderCanvasArea">
|
||||||
<div class="builderTabs" id="builderTabs">
|
<div class="builderTabs" id="builderTabs">
|
||||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button>
|
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="collision-rules" onclick="switchBuilderTab('collision-rules')">Collision</button>
|
<button type="button" class="builderTabBtn" data-tab="triggers" onclick="switchBuilderTab('triggers')">Déclencheurs</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="quests" onclick="switchBuilderTab('quests')">Quêtes</button>
|
<button type="button" class="builderTabBtn" data-tab="variables" onclick="switchBuilderTab('variables')">Variables</button>
|
||||||
{% if game.onboarding_type != 'rpg' %}
|
{% if game.onboarding_type != 'rpg' %}
|
||||||
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
||||||
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant —
|
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
|
||||||
l'assistant "+ Action" de l'onglet Collision exclut
|
seul point d'entrée qui le permettait (l'action "evenement" de
|
||||||
délibérément le type "evenement" (voir COLLISION_WIZARD_
|
l'ancien éditeur de collision) a été retiré (demande explicite,
|
||||||
ACTION_TYPES, static/js/scenes/collision-rules-editor.js) —
|
ne servait à rien). game.onboarding_type est de toute façon
|
||||||
demande explicite : cet onglet n'a donc aucune utilité ici. -->
|
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>
|
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">Événements</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Éditeur de collision (voir screens/rendering/collision_rules.py) :
|
<!-- Onglet "Déclencheurs" (voir static/js/triggers/trigger-editor.js) :
|
||||||
une carte par objet ÉLIGIBLE (jamais un "fond", jamais le
|
un graphe de logique lu par déclencheur — TOUS les déclencheurs du
|
||||||
personnage "joueur" — ce sont toujours les AUTRES objets qui
|
jeu, plus aucune notion de quête pour les regrouper (un créateur
|
||||||
réagissent à SA présence), listant ses règles "déclencheur ->
|
peut faire un simple quiz sans jamais poser de personnage). Chaque
|
||||||
action" déjà posées + un bouton "+ Action" qui ouvre l'assistant
|
déclencheur (objet -> collision -> action) porte SON PROPRE
|
||||||
(modale plus bas). Rendu ENTIÈREMENT en JS (voir
|
dialogue, édité en ligne (voir openTriggerWizard(), static/js/
|
||||||
static/js/scenes/collision-rules-editor.js) à partir de
|
scenes/collision-rules-editor.js). -->
|
||||||
COLLISION_RULE_OBJECTS/CUSTOM_EVENTS_MAP — jamais en Jinja, pour
|
<div class="builderTabPanel" id="triggersTabPanel" data-tab-panel="triggers">
|
||||||
pouvoir se redessiner à chaud après chaque ajout/suppression de
|
<div id="triggersTabPanelBody"></div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assistant "+ Action" — un seul écran affiché à la fois
|
<!-- Variables globales (voir db/global_vars/) : accessibles à TOUT le
|
||||||
(#collisionRuleModalBody, reconstruit à chaque étape), jamais un
|
jeu par leur NOM (immuable après création — condition de
|
||||||
vrai formulaire à plusieurs pages : chaque clic choisit une
|
visibilité, action "Modifier une variable", voir
|
||||||
option et avance directement à la suivante. -->
|
static/js/play/conditions.js::readVariableValue), pas seulement à
|
||||||
<div class="modal" id="collisionRuleModal">
|
un écran. Même patron que l'onglet Événements ci-dessous (table +
|
||||||
<div class="modal-background" onclick="closeCollisionRuleModal()"></div>
|
formulaires classiques, POST/redirect — pas de rendu JS). -->
|
||||||
<div class="modal-content">
|
<div class="builderTabPanel" id="variablesTabPanel" data-tab-panel="variables">
|
||||||
<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">
|
|
||||||
<div class="dashPanelHeader">
|
<div class="dashPanelHeader">
|
||||||
<h2>Quêtes</h2>
|
<h2>Variables</h2>
|
||||||
<button type="button" class="button is-small primary" onclick="createQuestFromListPage()">+ Créer une quête</button>
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Titre, objectif, récompense, statut, résultat — clique sur une ligne pour écrire son enchaînement de dialogue.</p>
|
<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>
|
||||||
<div id="questsListWrap"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if game.onboarding_type != 'rpg' %}
|
{% if game.onboarding_type != 'rpg' %}
|
||||||
@@ -311,9 +339,9 @@
|
|||||||
screens/scenes/set_scene_object_name.py) — n'importe quel kind
|
screens/scenes/set_scene_object_name.py) — n'importe quel kind
|
||||||
(personnage, décor, fond), pas seulement un personnage — c'est
|
(personnage, décor, fond), pas seulement un personnage — c'est
|
||||||
CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
|
CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
|
||||||
de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/
|
de dialogue d'un déclencheur (voir "Déclencheurs", static/js/
|
||||||
quest-editor.js) : un dialogue peut mettre en scène n'importe
|
triggers/trigger-editor.js) : un dialogue peut mettre en scène
|
||||||
quel objet, pas seulement un personnage. -->
|
n'importe quel objet, pas seulement un personnage. -->
|
||||||
<details class="propGroup" open>
|
<details class="propGroup" open>
|
||||||
<summary>Informations</summary>
|
<summary>Informations</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
@@ -602,18 +630,15 @@
|
|||||||
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
|
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
|
||||||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_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" +
|
// "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" +
|
||||||
// static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe
|
// static/js/triggers/trigger-editor.js::triggerSpeakerOptions) —
|
||||||
// quel objet de scène nommé, pas seulement un personnage.
|
// n'importe quel objet de scène nommé, pas seulement un personnage.
|
||||||
var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }};
|
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>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></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>
|
<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/builder-tabs.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.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/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>
|
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+294
-99
@@ -1,12 +1,11 @@
|
|||||||
"""Éditeur de collision (onglet "🧩 Collision", voir
|
"""Règles de collision (voir screens/rendering/collision_rules.py) : une
|
||||||
screens/rendering/collision_rules.py) : une règle "déclencheur (collision/
|
règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
|
||||||
périmètre) -> action (quête/attaque/événement/interagir)" par carte
|
par objet de scène — toujours les AUTRES objets qui réagissent à la
|
||||||
objet — toujours les AUTRES objets qui réagissent à la présence du
|
présence du joueur, jamais l'inverse. Posées depuis l'onglet
|
||||||
joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js
|
"Déclencheurs" (voir tests/test_triggers.py pour le parcours complet côté
|
||||||
construit l'assistant "+ Action" ; ce fichier teste la couche serveur
|
route) — ce fichier teste la couche de validation/lecture elle-même
|
||||||
(défauts, validation, exposition dans full_game_payload)."""
|
(sanitize_collision_rules/resolve_collision_rules), plus l'exposition
|
||||||
import re
|
dans full_game_payload."""
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import screens
|
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):
|
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)
|
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
|
assert resp.status_code == 302, resp.data
|
||||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
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
|
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):
|
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)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
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) == []
|
assert screens.resolve_collision_rules(obj) == []
|
||||||
|
|
||||||
|
|
||||||
def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup):
|
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup):
|
||||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
|
||||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
rules = screens.sanitize_collision_rules([
|
||||||
|
{"trigger": "collision", "action": {"type": "dialogue"}},
|
||||||
resp = client.post(
|
])
|
||||||
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
|
assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
|
||||||
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_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup):
|
def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting():
|
||||||
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):
|
|
||||||
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
|
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
|
||||||
à l'appui — un seul niveau d'imbrication autorisé (voir
|
à l'appui — un seul niveau d'imbrication autorisé (voir
|
||||||
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
|
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
|
||||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
rules = screens.sanitize_collision_rules([
|
||||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
||||||
|
])
|
||||||
resp = client.post(
|
assert rules == [
|
||||||
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
||||||
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}}},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
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
|
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
|
||||||
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
|
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)
|
rules = screens.sanitize_collision_rules([
|
||||||
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}},
|
||||||
|
])
|
||||||
resp = client.post(
|
assert rules == [] # la règle entière est rejetée
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup):
|
def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches():
|
||||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
""""condition" (losange à embranchement, maquette validée) : Vrai/Faux
|
||||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
|
||||||
|
rules = screens.sanitize_collision_rules([{
|
||||||
resp = client.post(
|
"trigger": "collision",
|
||||||
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
|
"action": {
|
||||||
data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}',
|
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
||||||
content_type="application/json",
|
"si_vrai": {"type": "dialogue"}, "si_faux": None,
|
||||||
)
|
},
|
||||||
assert resp.get_json()["rules"] == []
|
}])
|
||||||
|
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):
|
def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator():
|
||||||
resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json")
|
rules = screens.sanitize_collision_rules([
|
||||||
assert resp.status_code == 404
|
{"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):
|
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"] == []
|
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)
|
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")
|
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
||||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
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)
|
objects = screens.list_scene_objects(slug, screen_id)
|
||||||
idx = html.find("var COLLISION_RULE_OBJECTS")
|
for o in objects:
|
||||||
end = html.find(";", idx)
|
o["rendered_html"] = screens.render_scene_object(o)
|
||||||
snippet = html[idx:end]
|
rule_objects = screens.build_collision_rule_objects(objects)
|
||||||
assert f'"id": {enemy_id}' in snippet
|
ids = [o["id"] for o in rule_objects]
|
||||||
assert f'"id": {player_id}' not in snippet
|
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
|
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
|
||||||
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
|
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
|
||||||
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
|
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
|
static/js/play/dialogue-box-controller.js) — header/body/footer
|
||||||
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
|
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
|
||||||
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
|
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 db
|
||||||
import screens
|
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
|
assert f'data-object-id="{obj_id}"' in html
|
||||||
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
||||||
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
||||||
# (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le
|
# (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js)
|
||||||
# cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de
|
# le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
|
||||||
# dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait
|
|
||||||
# silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons.
|
|
||||||
assert 'data-dialogue-role="header"' in html
|
assert 'data-dialogue-role="header"' in html
|
||||||
assert 'data-dialogue-role="body"' in html
|
assert 'data-dialogue-role="body"' in html
|
||||||
assert 'data-dialogue-role="footer"' 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
|
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)
|
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)
|
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") ----------
|
# ---------- 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_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||||
obj = screens.get_scene_object(slug, obj_id)
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
html = screens.render_scene_object(obj)
|
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="question"' in html # le texte de la question, dans le corps
|
||||||
assert 'data-quiz-role="choices"' in html
|
assert 'data-quiz-role="choices"' in html
|
||||||
assert f'data-object-id="{obj_id}"' 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
|
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
|
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
|
||||||
automatisée)."""
|
automatisée)."""
|
||||||
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
import screens
|
||||||
from tests.test_auth import anon_client # noqa: F401
|
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):
|
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)."""
|
barémer, voir static/js/play/offline/scorm-api.js)."""
|
||||||
resp = client.post(f"/game/{game}/export-scorm")
|
resp = client.post(f"/game/{game}/export-scorm")
|
||||||
import io
|
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")
|
html = zf.read("index.html").decode("utf-8")
|
||||||
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
|
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
|
||||||
|
|
||||||
quest = db.create_quest(game, "Une quête")
|
# Les dialogues vivent dans les règles de collision d'un objet de
|
||||||
db.set_quest_dialogues(game, quest["id"], {
|
# scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue).
|
||||||
"nouvelle": [], "terminee": [],
|
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
|
||||||
"en_cours": [
|
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
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")
|
resp = client.post(f"/game/{game}/export-scorm")
|
||||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||||
html = zf.read("index.html").decode("utf-8")
|
html = zf.read("index.html").decode("utf-8")
|
||||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
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):
|
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) —
|
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||||
aucune vérification supplémentaire écrite pour cette route."""
|
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/
|
"""screens.build_collision_rule_objects (voir screens/scenes/
|
||||||
scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans
|
build_collision_rule_objects.py) : la liste d'objets éligibles à un
|
||||||
static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un
|
déclencheur de collision, réutilisée par le listage de TOUS les
|
||||||
objet de scène ou changement de rôle/nom, pour ne jamais recharger la page
|
déclencheurs du jeu (screens/rendering/trigger_graph.py, voir
|
||||||
alors que cette liste doit refléter le changement (un objet apparaît/
|
tests/test_triggers.py) — un objet apparaît/disparaît selon son
|
||||||
disparaît selon son rôle, ou change de libellé affiché)."""
|
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
|
import screens
|
||||||
|
|
||||||
|
|
||||||
@@ -21,16 +24,17 @@ def _add_ajax(client, slug, screen_id, **data):
|
|||||||
).get_json()
|
).get_json()
|
||||||
|
|
||||||
|
|
||||||
def _list(client, slug, screen_id):
|
def _list(slug, screen_id):
|
||||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects")
|
objects = screens.list_scene_objects(slug, screen_id)
|
||||||
assert resp.status_code == 200
|
for o in objects:
|
||||||
return resp.get_json()
|
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):
|
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)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
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 len(entries) == 1
|
||||||
assert entries[0]["id"] == obj_id
|
assert entries[0]["id"] == obj_id
|
||||||
assert entries[0]["role"] == "pnj"
|
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)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
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": "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):
|
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"]
|
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": "joueur"}', content_type="application/json")
|
||||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', 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 len(entries) == 1
|
||||||
assert entries[0]["role"] == "ennemie"
|
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)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
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")
|
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"
|
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)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
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"})
|
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):
|
def test_decor_has_no_role(client, tmp_game_slug_cleanup):
|
||||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
_add_ajax(client, slug, screen_id, kind="decor")
|
_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
|
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