Merge pull request 'Dev' (#15) from dev into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
+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. */
|
||||||
|
|||||||
+2
-2
@@ -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é "%".
|
||||||
@@ -163,9 +163,9 @@
|
|||||||
</script>
|
</script>
|
||||||
<script src="{{ asset_url('js/play/screens.js') }}"></script>
|
<script src="{{ asset_url('js/play/screens.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/conditions.js') }}"></script>
|
<script src="{{ asset_url('js/play/conditions.js') }}"></script>
|
||||||
{% if offline_mode %}
|
|
||||||
<script src="{{ asset_url('js/play/offline/compute-operation.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/compute-operation.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/offline/apply-actions.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/apply-actions.js') }}"></script>
|
||||||
|
{% if offline_mode %}
|
||||||
<script src="{{ asset_url('js/play/offline/filter-repeater-rows.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/filter-repeater-rows.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/offline/visibility-condition.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/visibility-condition.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/offline/render-jauge.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/render-jauge.js') }}"></script>
|
||||||
|
|||||||
+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 %}
|
||||||
|
|||||||
+309
-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,34 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(client, tmp_game_slug_cleanup):
|
||||||
|
"""Bug corrigé : compute-operation.js/apply-actions.js (qui portent
|
||||||
|
forgeApplyVariableActionOffline, appelée par collision-rules-controller.js
|
||||||
|
pour l'action "Modifier une variable") n'étaient chargés QUE derrière
|
||||||
|
`{% if offline_mode %}` dans templates/play.html — en aperçu créateur
|
||||||
|
(/game/<slug>/play, jamais offline_mode), l'action ne faisait donc
|
||||||
|
RIEN (fonction absente). Les deux scripts doivent être chargés
|
||||||
|
inconditionnellement (voir aussi test_export_scorm.py, qui vérifie
|
||||||
|
qu'ils restent bien présents dans le paquet exporté)."""
|
||||||
|
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
html = client.get(f"/game/{slug}/play").data.decode()
|
||||||
|
assert 'src="/static/js/play/offline/compute-operation.js"' in html
|
||||||
|
assert 'src="/static/js/play/offline/apply-actions.js"' in html
|
||||||
|
|||||||
+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