Merge pull request 'Dev' (#2) from dev into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
+7
-1
@@ -18,10 +18,16 @@ FIELD_TYPES = {
|
||||
# Types disponibles pour une variable globale (voir db/global_vars/) — un
|
||||
# sous-ensemble de FIELD_TYPES : ni "relation" (une variable globale n'a pas
|
||||
# d'objet à pointer) ni "texte_long"/"date" (pas gérés par
|
||||
# apply_variable_action.py, qui ne coerce que ces 4 types).
|
||||
# apply_variable_action.py, qui ne coerce que ces 4 types). "objet"/
|
||||
# "tableau" sont, eux, propres aux variables (pas de colonne SQL réelle,
|
||||
# contrairement à un champ d'objet) : la valeur est stockée telle quelle
|
||||
# (colonne TEXT) sous forme de JSON, lue via un chemin ".champ"/"[index]"
|
||||
# (voir _resolve_variable_path dans screens/rendering/filter_repeater_rows.py).
|
||||
GLOBAL_VARIABLE_TYPES = {
|
||||
"texte": FIELD_TYPES["texte"],
|
||||
"nombre_entier": FIELD_TYPES["nombre_entier"],
|
||||
"nombre_decimal": FIELD_TYPES["nombre_decimal"],
|
||||
"booleen": FIELD_TYPES["booleen"],
|
||||
"objet": {"label": "Objet (JSON)"},
|
||||
"tableau": {"label": "Tableau (JSON)"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
|
||||
|
||||
def coerce_structured_value(var_type, value):
|
||||
"""Pour une variable "objet"/"tableau" (voir db/constants.py), la
|
||||
valeur stockée doit rester du JSON analysable — sinon la prochaine
|
||||
lecture (_resolve_variable_path, screens/rendering/
|
||||
filter_repeater_rows.py) échouerait silencieusement à chaque fois.
|
||||
Un JSON invalide retombe donc sur un défaut sûr ("{}"/"[]") plutôt que
|
||||
d'être stocké tel quel. Les autres types ne sont jamais touchés ici."""
|
||||
if var_type not in ("objet", "tableau"):
|
||||
return value
|
||||
text = (value or "").strip()
|
||||
fallback = "{}" if var_type == "objet" else "[]"
|
||||
if not text:
|
||||
return fallback
|
||||
try:
|
||||
json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return text
|
||||
@@ -1,5 +1,6 @@
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
from .coerce_structured_value import coerce_structured_value
|
||||
|
||||
|
||||
def create_global_variable(slug, name, var_type="texte", default_value=""):
|
||||
@@ -11,6 +12,7 @@ def create_global_variable(slug, name, var_type="texte", default_value=""):
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
default_value = coerce_structured_value(var_type, default_value)
|
||||
ensure_global_vars_schema(slug)
|
||||
conn = connect(slug)
|
||||
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
from .coerce_structured_value import coerce_structured_value
|
||||
|
||||
|
||||
def update_global_variable(slug, var_id, var_type, value):
|
||||
"""Modifie le type et la valeur d'une variable existante depuis la page
|
||||
de gestion (voir game_variables.html) — le NOM reste volontairement
|
||||
immuable après création : c'est par ce nom qu'une condition de
|
||||
visibilité ou une action "Modifier une variable" la référence (voir
|
||||
get_global_variable.py), la renommer casserait ces réglages en
|
||||
silence."""
|
||||
"""Modifie le type et la valeur d'une variable existante depuis le
|
||||
tableau de bord (onglet Variables, game_dashboard.html) — le NOM reste
|
||||
volontairement immuable après création : c'est par ce nom qu'une
|
||||
condition de visibilité ou une action "Modifier une variable" la
|
||||
référence (voir get_global_variable.py), la renommer casserait ces
|
||||
réglages en silence."""
|
||||
value = coerce_structured_value(var_type, str(value))
|
||||
ensure_global_vars_schema(slug)
|
||||
conn = connect(slug)
|
||||
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, str(value), var_id))
|
||||
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, value, var_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -9,4 +9,4 @@ from core.flask_app import app
|
||||
def element_type_delete(slug, element_type_id):
|
||||
if screens.element_type_usage_count(slug, element_type_id) == 0:
|
||||
screens.delete_element_type(slug, element_type_id)
|
||||
return redirect(url_for("element_types", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
|
||||
@@ -13,4 +13,4 @@ def element_type_edit(slug, element_type_id):
|
||||
name = request.form.get("name", "").strip() or None
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
|
||||
return redirect(url_for("element_types", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
@@ -8,18 +7,16 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
|
||||
def element_types(slug):
|
||||
game = db.game_meta(slug)
|
||||
"""La création/gestion d'un élément de jeu se fait désormais depuis
|
||||
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
|
||||
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
|
||||
création (barre de création repliable de cet onglet). Un GET direct
|
||||
redirige vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
icon = request.form.get("icon", "").strip() or "🧩"
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
if name:
|
||||
screens.create_element_type(slug, name, icon, bound_definition_id)
|
||||
return redirect(url_for("element_types", slug=slug))
|
||||
types = screens.list_element_types(slug)
|
||||
usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in types}
|
||||
definitions = db.list_definitions(slug)
|
||||
return render_template(
|
||||
"element_types.html", game=game, types=types, usage=usage, widgets=screens.WIDGETS,
|
||||
definitions=definitions,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
from flask import render_template
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>")
|
||||
def game_dashboard(slug):
|
||||
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
|
||||
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
|
||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
||||
base.html) permet déjà de sauter directement vers chaque éditeur, cette
|
||||
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
|
||||
liens."""
|
||||
game = db.game_meta(slug)
|
||||
definitions = db.list_definitions(slug)
|
||||
return render_template("game_dashboard.html", game=game, definitions=definitions)
|
||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
||||
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
||||
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
|
||||
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
|
||||
# l'ouverture (voir openObjectEditPanel()).
|
||||
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
|
||||
|
||||
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
|
||||
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
|
||||
# objet, les libellés lisibles de ses champs relation (pour l'affichage
|
||||
# du tableau) et les options possibles de ces mêmes champs relation
|
||||
# (pour le formulaire "Ajouter une entrée").
|
||||
rows_by_definition = {}
|
||||
relation_labels_by_definition = {}
|
||||
relation_options_by_definition = {}
|
||||
referenced_by_definition = {}
|
||||
for d in definitions:
|
||||
full = definitions_full[d["id"]]
|
||||
rows_by_definition[d["id"]] = db.list_rows(slug, full)
|
||||
relation_labels = {}
|
||||
relation_options = {}
|
||||
for f in full["fields"]:
|
||||
if f["type"] == "relation":
|
||||
options = db.relation_options(slug, f["relation_definition_id"])
|
||||
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
||||
relation_options[f["id"]] = options
|
||||
relation_labels_by_definition[d["id"]] = relation_labels
|
||||
relation_options_by_definition[d["id"]] = relation_options
|
||||
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
|
||||
|
||||
screen_list = screens.list_screens(slug)
|
||||
element_types = screens.list_element_types(slug)
|
||||
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
|
||||
variables = db.list_global_variables(slug)
|
||||
return render_template(
|
||||
"game_dashboard.html", game=game, definitions=definitions,
|
||||
definitions_full=definitions_full,
|
||||
rows_by_definition=rows_by_definition,
|
||||
relation_labels_by_definition=relation_labels_by_definition,
|
||||
relation_options_by_definition=relation_options_by_definition,
|
||||
referenced_by_definition=referenced_by_definition,
|
||||
screen_list=screen_list, element_types=element_types,
|
||||
element_type_usage=element_type_usage, variables=variables,
|
||||
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from .create_global_var import create_global_var
|
||||
from .game_variables import game_variables
|
||||
from .global_var_edit import global_var_edit
|
||||
from .global_var_delete import global_var_delete
|
||||
|
||||
@@ -15,4 +15,4 @@ def create_global_var(slug):
|
||||
default_value = request.form.get("default_value") or ""
|
||||
if name:
|
||||
db.create_global_variable(slug, name, var_type, default_value)
|
||||
return redirect(url_for("game_variables", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from flask import render_template
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/variables")
|
||||
def game_variables(slug):
|
||||
"""Page de gestion des variables globales (voir db/global_vars/) : un
|
||||
objet "jeu" implicite dont les propriétés/valeurs sont visibles partout
|
||||
(condition de visibilité, action "Modifier une variable"), plutôt
|
||||
qu'attachées à un objet de données précis."""
|
||||
game = db.game_meta(slug)
|
||||
variables = db.list_global_variables(slug)
|
||||
return render_template(
|
||||
"game_variables.html", game=game, variables=variables,
|
||||
variable_types=db.GLOBAL_VARIABLE_TYPES,
|
||||
)
|
||||
@@ -8,4 +8,4 @@ from core.flask_app import app
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||
def global_var_delete(slug, var_id):
|
||||
db.delete_global_variable_by_id(slug, var_id)
|
||||
return redirect(url_for("game_variables", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
|
||||
@@ -10,4 +10,4 @@ def global_var_edit(slug, var_id):
|
||||
var_type = request.form.get("type") or "texte"
|
||||
value = request.form.get("value") or ""
|
||||
db.update_global_variable(slug, var_id, var_type, value)
|
||||
return redirect(url_for("game_variables", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
|
||||
|
||||
@@ -13,8 +13,9 @@ def data_delete(slug, definition_id, row_id):
|
||||
referencing = db.rows_referencing(slug, definition_id, row_id)
|
||||
if referencing:
|
||||
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
|
||||
# une relation. On revient à la liste, qui explique pourquoi via un
|
||||
# paramètre de requête simple plutôt qu'une session/flash message.
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id, blocked_row=row_id))
|
||||
# une relation. On revient au panneau de cet objet (onglet
|
||||
# Données), qui explique pourquoi via un paramètre de requête
|
||||
# simple plutôt qu'une session/flash message.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
|
||||
db.delete_row(slug, definition, row_id)
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
from flask import render_template, request, redirect, url_for, abort
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _data_form(slug, definition_id, row):
|
||||
game = db.game_meta(slug)
|
||||
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
|
||||
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
|
||||
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
|
||||
(data_form.html, retirée) — cette fonction ne fait donc plus que
|
||||
traiter la soumission de ce formulaire. Un GET direct redirige vers le
|
||||
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
|
||||
Données."""
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
|
||||
# Pour chaque champ de type relation, on prépare les options du <select>
|
||||
# à partir des lignes déjà enregistrées de l'objet lié.
|
||||
relation_options = {}
|
||||
for f in definition["fields"]:
|
||||
if f["type"] == "relation":
|
||||
relation_options[f["id"]] = db.relation_options(slug, f["relation_definition_id"])
|
||||
|
||||
if request.method == "POST":
|
||||
if row is None:
|
||||
db.insert_row(slug, definition, request.form)
|
||||
else:
|
||||
db.update_row(slug, definition, row["id"], request.form)
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
|
||||
return render_template(
|
||||
"data_form.html",
|
||||
game=game,
|
||||
definition=definition,
|
||||
relation_options=relation_options,
|
||||
row=row,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
|
||||
@@ -10,9 +10,10 @@ def object_delete(slug, definition_id):
|
||||
referenced_by = db.definitions_referencing(slug, definition_id)
|
||||
if referenced_by:
|
||||
# Suppression bloquée : d'autres objets ont une relation vers
|
||||
# celui-ci. On revient à l'écran de l'objet, qui affiche déjà cet
|
||||
# avertissement (referenced_by), plutôt que de casser ces relations
|
||||
# silencieusement.
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
# celui-ci. On revient à son panneau, qui affiche déjà cet
|
||||
# avertissement (referenced_by_definition, voir game_dashboard.py)
|
||||
# en permanence, pas seulement après une tentative — plutôt que de
|
||||
# casser ces relations silencieusement.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
db.delete_definition(slug, definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import render_template, request, redirect, url_for, abort
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,22 +7,18 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
|
||||
def object_edit(slug, definition_id):
|
||||
game = db.game_meta(slug)
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
existing_definitions = [d for d in db.list_definitions(slug) if d["id"] != definition_id]
|
||||
|
||||
"""Renommer un objet + gérer ses champs se fait désormais depuis un
|
||||
panneau déplaçable/redimensionnable sur le tableau de bord (voir
|
||||
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
|
||||
qu'une page dédiée — cette route ne fait donc plus que traiter le
|
||||
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
|
||||
le panneau de cet objet (?edit=<id>)."""
|
||||
if request.method == "POST":
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
new_name = request.form.get("object_name", "").strip()
|
||||
if new_name and new_name != definition["name"]:
|
||||
db.rename_definition(slug, definition_id, new_name)
|
||||
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
|
||||
|
||||
return render_template(
|
||||
"object_edit.html",
|
||||
game=game,
|
||||
definition=definition,
|
||||
existing_definitions=existing_definitions,
|
||||
field_types=db.FIELD_TYPES,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
|
||||
@@ -17,4 +17,4 @@ def object_field_add(slug, definition_id):
|
||||
"min_value": request.form.get("field_min") or None,
|
||||
"max_value": request.form.get("field_max") or None,
|
||||
})
|
||||
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
|
||||
@@ -8,4 +8,4 @@ from core.flask_app import app
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
|
||||
def object_field_delete(slug, definition_id, field_id):
|
||||
db.delete_field(slug, definition_id, field_id)
|
||||
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
|
||||
@@ -17,4 +17,4 @@ def object_field_edit(slug, definition_id, field_id):
|
||||
min_value=request.form.get("field_min") or None,
|
||||
max_value=request.form.get("field_max") or None,
|
||||
)
|
||||
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
@@ -8,20 +8,16 @@ from .parse_field_rows import _parse_field_rows
|
||||
|
||||
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
|
||||
def object_new(slug):
|
||||
game = db.game_meta(slug)
|
||||
existing_definitions = db.list_definitions(slug)
|
||||
|
||||
"""La création d'un objet se fait désormais depuis un panneau déplaçable
|
||||
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
|
||||
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
|
||||
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
|
||||
externe obsolète) redirige simplement vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("object_name", "").strip()
|
||||
fields = _parse_field_rows(request.form)
|
||||
|
||||
if name and fields:
|
||||
definition_id = db.create_definition(slug, name, fields)
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
|
||||
return render_template(
|
||||
"object_form.html",
|
||||
game=game,
|
||||
existing_definitions=existing_definitions,
|
||||
field_types=db.FIELD_TYPES,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
from flask import render_template, request, abort
|
||||
|
||||
import db
|
||||
from flask import redirect, url_for
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>")
|
||||
def object_view(slug, definition_id):
|
||||
game = db.game_meta(slug)
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
rows = db.list_rows(slug, definition)
|
||||
|
||||
# Pour l'affichage : traduit chaque id de relation en un libellé lisible
|
||||
# (ex. "Salle serveur (#1)") au lieu d'un identifiant numérique brut.
|
||||
relation_labels = {}
|
||||
for f in definition["fields"]:
|
||||
if f["type"] == "relation":
|
||||
options = db.relation_options(slug, f["relation_definition_id"])
|
||||
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
||||
|
||||
referenced_by = db.definitions_referencing(slug, definition_id)
|
||||
|
||||
blocked_row_id = request.args.get("blocked_row", type=int)
|
||||
blocked_row_refs = db.rows_referencing(slug, definition_id, blocked_row_id) if blocked_row_id else None
|
||||
|
||||
return render_template(
|
||||
"data_list.html", game=game, definition=definition, rows=rows,
|
||||
relation_labels=relation_labels, referenced_by=referenced_by,
|
||||
blocked_row_id=blocked_row_id, blocked_row_refs=blocked_row_refs,
|
||||
)
|
||||
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
|
||||
retirée : les données vivent désormais dans l'onglet "Données" du
|
||||
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
|
||||
route reste néanmoins en place, comme pur redirecteur, pour deux
|
||||
raisons : un lien externe/ancien vers cette URL continue de mener
|
||||
quelque part de sensé, et surtout beaucoup de tests (et certains
|
||||
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
|
||||
segment du chemin renvoyé par le header Location d'une redirection
|
||||
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
|
||||
termine par l'id), qui redirige ensuite vers le tableau de bord."""
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import screens_list, screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect
|
||||
|
||||
@@ -9,6 +9,6 @@ from core.flask_app import app
|
||||
def screen_delete(slug, screen_id):
|
||||
referencing = screens.screens_referencing(slug, screen_id)
|
||||
if referencing:
|
||||
return redirect(url_for("screens_list", slug=slug, blocked_screen=screen_id))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens", blocked_screen=screen_id))
|
||||
screens.delete_screen(slug, screen_id)
|
||||
return redirect(url_for("screens_list", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
||||
|
||||
@@ -9,4 +9,4 @@ from core.flask_app import app
|
||||
def screen_move(slug, screen_id):
|
||||
direction = -1 if request.form.get("direction") == "up" else 1
|
||||
screens.move_screen(slug, screen_id, direction)
|
||||
return redirect(url_for("screens_list", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from flask import render_template
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens")
|
||||
def screens_list(slug):
|
||||
game = db.game_meta(slug)
|
||||
return render_template("screens_list.html", game=game, screen_list=screens.list_screens(slug))
|
||||
@@ -11,4 +11,4 @@ def screens_new(slug):
|
||||
if name:
|
||||
screen_id = screens.create_screen(slug, name)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
return redirect(url_for("screens_list", slug=slug))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
||||
|
||||
@@ -7,12 +7,21 @@ from ..rendering.render_element_html import render_element_html
|
||||
|
||||
def list_elements(slug, screen_id, enforce_visibility=False):
|
||||
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
|
||||
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
|
||||
les éléments de premier niveau (parent_id NULL) reçoivent un
|
||||
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
|
||||
déjà, en le générant récursivement, le HTML de tout ce qui est posé à
|
||||
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
|
||||
deuxième fois séparément.
|
||||
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
|
||||
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
|
||||
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
|
||||
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
|
||||
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
|
||||
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
|
||||
HTML initial de la page ; une fois dans le sien propre) : c'est ce
|
||||
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
|
||||
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
|
||||
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
|
||||
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
|
||||
changement, seuls les éléments de premier niveau recevaient un
|
||||
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
|
||||
retombait donc toujours sur la régénération de son ancêtre de premier
|
||||
niveau, quelle que soit la finesse du ciblage côté JS.
|
||||
|
||||
enforce_visibility : True uniquement pour le mode jouable (voir
|
||||
full_game_payload.py) — applique la "condition de visibilité" de chaque
|
||||
@@ -66,6 +75,5 @@ def list_elements(slug, screen_id, enforce_visibility=False):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
|
||||
for d in result:
|
||||
if not d.get("parent_id"):
|
||||
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
|
||||
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
|
||||
return result
|
||||
|
||||
@@ -5,6 +5,7 @@ from ..elements.list_elements import list_elements
|
||||
from ..flow.list_flow_nodes import list_flow_nodes
|
||||
from ..flow.list_flow_edges import list_flow_edges
|
||||
from ..animations.list_animation_clips import list_animation_clips
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
|
||||
|
||||
def full_game_payload(slug):
|
||||
@@ -23,6 +24,21 @@ def full_game_payload(slug):
|
||||
for s in screens_:
|
||||
elements = list_elements(slug, s["id"], enforce_visibility=True)
|
||||
payload_screens.append({**s, "elements": elements})
|
||||
|
||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||
# écrans, écrans-modèles compris (include_templates=True) — pas
|
||||
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
|
||||
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
|
||||
# animation posée dans l'éditeur de SON écran-modèle (voir
|
||||
# screen_edit.html) : sans ses entrées ici, gameData.flows/
|
||||
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
|
||||
# client, et findTriggerNode()/collectAnimationClips() (templates/
|
||||
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
|
||||
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
|
||||
# écran-modèle ne doit jamais être rendu comme un vrai <div
|
||||
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
|
||||
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
|
||||
for s in list_screens(slug, include_templates=True):
|
||||
flows[str(s["id"])] = {
|
||||
"nodes": list_flow_nodes(slug, s["id"]),
|
||||
"edges": list_flow_edges(slug, s["id"]),
|
||||
@@ -42,4 +58,16 @@ def full_game_payload(slug):
|
||||
]
|
||||
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
|
||||
|
||||
return {"screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta}
|
||||
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
|
||||
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
|
||||
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
|
||||
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
|
||||
# aussi être joués à l'affichage de cet écran — sans quoi une animation
|
||||
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
|
||||
# modèle est utilisé ailleurs.
|
||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
@@ -12,8 +13,41 @@ _REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
|
||||
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
|
||||
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
|
||||
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
|
||||
# toujours un point ("Objet.champ").
|
||||
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}]+)\s*\}\}$")
|
||||
# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN
|
||||
# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}},
|
||||
# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable
|
||||
# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans
|
||||
# effet sur une variable scalaire (chemin vide dans ce cas).
|
||||
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$")
|
||||
|
||||
# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]"
|
||||
# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le
|
||||
# contrôle "Chemin dans la variable" de la Condition de visibilité
|
||||
# (visibility_condition_controls.py).
|
||||
_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
def _resolve_variable_path(raw_value, path):
|
||||
"""Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT)
|
||||
selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin
|
||||
vide -> valeur brute inchangée (utile pour une variable scalaire, où le
|
||||
chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur
|
||||
intermédiaire pas navigable -> None — même convention que le reste de
|
||||
ce fichier (objet/variable introuvable -> ne matche rien plutôt que
|
||||
planter)."""
|
||||
if not path:
|
||||
return raw_value
|
||||
try:
|
||||
current = json.loads(raw_value) if raw_value else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for m in _PATH_SEGMENT_RE.finditer(path):
|
||||
key, index = m.group(1), m.group(2)
|
||||
try:
|
||||
current = current[key] if key is not None else current[int(index)]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _field_column(definition, field_name):
|
||||
@@ -45,7 +79,9 @@ def _resolve_filter_value(slug, raw_value):
|
||||
var_match = _VAR_REF_PATTERN.match(stripped)
|
||||
if var_match:
|
||||
variable = db.get_global_variable(slug, var_match.group(1).strip())
|
||||
return variable.get("value") if variable else None # variable supprimée depuis : ne matche rien plutôt que planter
|
||||
if not variable:
|
||||
return None # variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return _resolve_variable_path(variable.get("value"), var_match.group(2))
|
||||
|
||||
m = _REF_PATTERN.match(stripped)
|
||||
if not m:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import db
|
||||
|
||||
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
|
||||
from .filter_repeater_rows import _resolve_filter_value, _resolve_variable_path, _compare, _field_column
|
||||
|
||||
|
||||
def is_element_visible(slug, attrs, ctx=None):
|
||||
@@ -39,7 +39,15 @@ def is_element_visible(slug, attrs, ctx=None):
|
||||
variable = db.get_global_variable(slug, var_name)
|
||||
if not variable:
|
||||
return True # variable supprimée depuis : ne bloque jamais l'affichage
|
||||
return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte")
|
||||
value = variable.get("value")
|
||||
# "Chemin dans la variable" (voir visibility_condition_controls.py)
|
||||
# : nécessaire pour comparer un CHAMP d'une variable "objet" ou un
|
||||
# ÉLÉMENT d'une variable "tableau", pas seulement la variable
|
||||
# entière — même syntaxe ".champ"/"[index]" que {{$var.chemin}}.
|
||||
path = attrs.get("_visibilite_cond_variable_chemin")
|
||||
if path:
|
||||
value = _resolve_variable_path(value, path)
|
||||
return _compare(value, operator, expected, variable.get("type") or "texte")
|
||||
|
||||
definition_id = attrs.get("_visibilite_cond_definition_id")
|
||||
field_name = attrs.get("_visibilite_cond_champ")
|
||||
|
||||
@@ -17,6 +17,12 @@ VISIBILITY_CONDITION_CONTROLS = [
|
||||
default="", group=_GROUP),
|
||||
c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable",
|
||||
[("", "(sélectionner une variable)")], default="", group=_GROUP),
|
||||
# Utile seulement si la variable choisie ci-dessus est de type
|
||||
# "objet"/"tableau" (db/constants.py) : sans chemin, c'est la variable
|
||||
# ENTIÈRE (son JSON tel quel) qui est comparée — voir
|
||||
# _resolve_variable_path dans filter_repeater_rows.py pour la syntaxe.
|
||||
c_text("visibilite_cond_variable_chemin", "Chemin dans la variable (optionnel)",
|
||||
"attr:_visibilite_cond_variable_chemin", placeholder="ex. .nom ou [0].valeur", default="", group=_GROUP),
|
||||
c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id",
|
||||
[("", "(sélectionner un objet)")], default="", group=_GROUP),
|
||||
c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ",
|
||||
|
||||
+20
-4
@@ -17,12 +17,28 @@
|
||||
var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal'];
|
||||
|
||||
function wireRow(row) {
|
||||
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
||||
// existants" dans object_edit.html) : "Objet lié" et "Mini"/"Maxi"
|
||||
// gardent toujours leur colonne (jamais masquée, sinon les colonnes
|
||||
// des différentes lignes ne s'alignent plus) — seul leur CONTENU
|
||||
// bascule entre le vrai champ de saisie et un espace réservé "—"
|
||||
// quand il ne s'applique pas au type choisi.
|
||||
var typeSelect = row.querySelector('.fieldTypeSelect');
|
||||
var relationWrap = row.querySelector('.relationTarget');
|
||||
var boundsWrap = row.querySelector('.boundsTarget');
|
||||
var relationTarget = row.querySelector('.relationTarget');
|
||||
var relationPlaceholder = row.querySelector('.relationPlaceholder');
|
||||
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
||||
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
||||
|
||||
function toggle(target, placeholder, visible) {
|
||||
target.style.display = visible ? '' : 'none';
|
||||
placeholder.style.display = visible ? 'none' : '';
|
||||
}
|
||||
|
||||
function syncFieldTypeVisibility() {
|
||||
relationWrap.style.display = typeSelect.value === 'relation' ? 'block' : 'none';
|
||||
if (boundsWrap) boundsWrap.style.display = NUMERIC_FIELD_TYPES.includes(typeSelect.value) ? 'flex' : 'none';
|
||||
var isRelation = typeSelect.value === 'relation';
|
||||
var isNumeric = NUMERIC_FIELD_TYPES.includes(typeSelect.value);
|
||||
toggle(relationTarget, relationPlaceholder, isRelation);
|
||||
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
||||
}
|
||||
typeSelect.addEventListener('change', syncFieldTypeVisibility);
|
||||
syncFieldTypeVisibility();
|
||||
|
||||
+7
-6
@@ -5,8 +5,9 @@
|
||||
* générées en Jinja) — on intercepte simplement chaque clic sur un lien
|
||||
* interne et chaque soumission de formulaire interne, on va chercher la
|
||||
* page suivante nous-mêmes en arrière-plan (fetch), et on ne remplace que
|
||||
* <title>, la classe du <body>, l'en-tête (fil d'Ariane) et le contenu
|
||||
* principal (<main>) — au lieu de laisser le navigateur tout recharger.
|
||||
* <title>, la classe du <body>, #pageChrome (topbar + barre de navigation
|
||||
* du jeu, voir base.html) et le contenu principal (<main>) — au lieu de
|
||||
* laisser le navigateur tout recharger.
|
||||
* L'URL affichée et l'historique (bouton "précédent") restent corrects
|
||||
* grâce à history.pushState/popstate.
|
||||
*
|
||||
@@ -77,10 +78,10 @@
|
||||
currentMain.className = newMain.className;
|
||||
currentMain.innerHTML = newMain.innerHTML;
|
||||
|
||||
var newHeader = doc.querySelector("header.topbar");
|
||||
var currentHeader = document.querySelector("header.topbar");
|
||||
if (newHeader && currentHeader) {
|
||||
currentHeader.innerHTML = newHeader.innerHTML;
|
||||
var newChrome = doc.querySelector("#pageChrome");
|
||||
var currentChrome = document.querySelector("#pageChrome");
|
||||
if (newChrome && currentChrome) {
|
||||
currentChrome.innerHTML = newChrome.innerHTML;
|
||||
}
|
||||
|
||||
mergeHeadAssets(doc.head);
|
||||
|
||||
+83
-9
@@ -14,12 +14,20 @@ input[type=text], input[type=number], input[type=date], textarea, select{
|
||||
}
|
||||
input[type=checkbox]{ width:auto; margin-top:6px; }
|
||||
|
||||
/* .topbar/.breadcrumbBar : Bulma fournit déjà la mise en page (navbar,
|
||||
breadcrumb) — ces classes ne servent plus qu'à les repérer dans les
|
||||
layouts plein-écran (body.builderBody/objectEditBody, voir plus bas) et à
|
||||
les rattacher au thème sombre. */
|
||||
/* .topbar : Bulma fournit déjà la mise en page (navbar) — cette classe ne
|
||||
sert plus qu'à la repérer dans les layouts plein-écran
|
||||
(body.builderBody/objectEditBody, voir plus bas) et à la rattacher au
|
||||
thème sombre. */
|
||||
.topbar{ border-bottom:1px solid var(--border); }
|
||||
.breadcrumbBar{ padding:10px 20px; background:transparent; border-bottom:1px solid var(--border); margin-bottom:0; }
|
||||
|
||||
/* Barre de navigation d'UN JEU (voir base.html) — toujours visible entre
|
||||
les différents éditeurs d'un même jeu, en ligne (contrairement à
|
||||
.navList/.navRow ci-dessous, empilés verticalement dans le panneau
|
||||
"Créer" du tableau de bord). */
|
||||
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
|
||||
.gameNavLink{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
|
||||
.gameNavLink:hover{ background:var(--panel2); border-color:var(--accent); }
|
||||
.gameNavLink.active{ background:var(--panel2); border-color:var(--accent); color:var(--accent); }
|
||||
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
|
||||
|
||||
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
|
||||
@@ -30,6 +38,11 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
|
||||
.formField{ margin-bottom:6px; }
|
||||
|
||||
button{ background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:9px 14px; cursor:pointer; font-size:13px; }
|
||||
/* Un <button> qui doit se comporter comme un <a> visuellement (ex. ouvrir
|
||||
un panneau plutôt que suivre un href, mais garder l'apparence d'un lien
|
||||
dans un tableau) — annule le style bouton par défaut ci-dessus. */
|
||||
.linkLikeButton{ background:none; border:none; padding:0; color:var(--accent); text-decoration:none; font-size:inherit; }
|
||||
.linkLikeButton:hover{ text-decoration:underline; }
|
||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
|
||||
button.smallBtn{ padding:5px 10px; font-size:11.5px; }
|
||||
button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
|
||||
@@ -65,8 +78,9 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
|
||||
.dataTable td{ max-width:220px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* ---------- Détail d'une entrée (bouton 👁️) ---------- */
|
||||
/* Contenu de la modale Bulma (.modal/.modal-card, voir data_list.html) —
|
||||
affiche chaque champ en entier, sans troncature. */
|
||||
/* Contenu du panneau de détail (voir openRowDetailPanel() dans
|
||||
game_dashboard.html) — affiche chaque champ en entier, sans troncature,
|
||||
contrairement au tableau dense qui les tronque. */
|
||||
.rowDetailField{ padding:10px 0; border-bottom:1px solid var(--border); }
|
||||
.rowDetailField:last-child{ border-bottom:none; }
|
||||
.rowDetailLabel{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; }
|
||||
@@ -96,7 +110,7 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
|
||||
a beaucoup de champs, pour garder une vue d'ensemble sans perdre le
|
||||
formulaire "Ajouter un champ" hors de l'écran. */
|
||||
body.objectEditBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
|
||||
body.objectEditBody > .topbar, body.objectEditBody > .breadcrumbBar{ flex:0 0 auto; }
|
||||
body.objectEditBody > #pageChrome{ flex:0 0 auto; }
|
||||
body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
|
||||
|
||||
.content-objectEdit{ width:100%; display:flex; flex-direction:column; min-height:0; }
|
||||
@@ -131,7 +145,7 @@ body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden
|
||||
ce sont les zones internes (panneaux, écran, panneau de logique) qui
|
||||
défilent chacune de leur côté si besoin. */
|
||||
body.builderBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
|
||||
body.builderBody > .topbar, body.builderBody > .breadcrumbBar{ flex:0 0 auto; }
|
||||
body.builderBody > #pageChrome{ flex:0 0 auto; }
|
||||
body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
|
||||
|
||||
.content-builder{
|
||||
@@ -182,8 +196,22 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55);
|
||||
}
|
||||
.floatPanel.hidden{ display:none; }
|
||||
/* Coin de redimensionnement (voir wireFloatPanelResize() dans
|
||||
game_dashboard.html) — un panneau devient redimensionnable simplement en
|
||||
lui ajoutant ce coin, JS mis à part rien d'autre à faire côté CSS. */
|
||||
.floatPanelResizeHandle{
|
||||
position:absolute; right:0; bottom:0; width:16px; height:16px; cursor:nwse-resize;
|
||||
background:linear-gradient(135deg, transparent 50%, var(--border) 50%, var(--border) 60%, transparent 60%, transparent 70%, var(--border) 70%, var(--border) 80%, transparent 80%);
|
||||
border-radius:0 0 10px 0;
|
||||
}
|
||||
.floatPanel--left{ left:24px; width:280px; }
|
||||
.floatPanel--right{ right:24px; width:340px; }
|
||||
.floatPanel--center{ left:50%; width:560px; max-width:calc(100vw - 48px); transform:translateX(-50%); }
|
||||
/* Plein écran (voir makeFloatPanelFullscreenable() dans game_dashboard.html)
|
||||
— juste un repère visuel (bordure accent) sur le panneau agrandi, tout le
|
||||
positionnement réel est posé directement en style inline par le JS (pour
|
||||
pouvoir revenir exactement à la position/taille d'avant en sortant). */
|
||||
.floatPanel--fullscreen{ border-color:var(--accent); }
|
||||
.floatPanelHeader{
|
||||
display:flex; align-items:center; justify-content:space-between; gap:8px;
|
||||
padding:8px 10px; border-bottom:1px solid var(--border); background:var(--panel2);
|
||||
@@ -421,6 +449,15 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
|
||||
.builderTabPanel.active{ display:flex; }
|
||||
|
||||
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
|
||||
objet" (voir game_dashboard.html) — classes distinctes de .builderTabs/
|
||||
.builderTabBtn/.builderTabPanel (utilisées par le style, réutilisé tel
|
||||
quel) pour que switchDashTab() (onglets du tableau de bord lui-même,
|
||||
ciblage global) n'aille jamais les toucher par erreur. */
|
||||
.objectPanelTabs{ margin:14px 0 10px; }
|
||||
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
||||
.objectPanelTabPanel.active{ display:flex; }
|
||||
|
||||
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
|
||||
|
||||
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
|
||||
@@ -484,3 +521,40 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.animClipResizeHandle{ position:absolute; right:0; top:0; width:8px; height:100%; cursor:ew-resize; }
|
||||
.animKeyframeRow{ align-items:flex-start; }
|
||||
.animKeyframeRow textarea{ font-family:monospace; font-size:11px; }
|
||||
|
||||
/* ---------- Tableau de bord d'un jeu (game_dashboard.html) ---------- */
|
||||
/* Une seule fenêtre, un onglet par catégorie (écrans/objets/éléments de
|
||||
jeu/variables) — réutilise .builderTabs/.builderTabBtn/.builderTabPanel
|
||||
(voir plus bas, éditeur d'écran) pour la même sensation de "fenêtre à
|
||||
onglets" qu'ailleurs dans l'outil, plutôt que d'inventer un 2e système.
|
||||
Contrairement à leur usage dans l'éditeur d'écran, ici la page défile
|
||||
normalement (pas de hauteur figée) : une liste peut être longue, pas
|
||||
besoin d'un défilement interne séparé par panneau. */
|
||||
.dashTabCount{ font-size:10px; color:var(--text-dim); background:var(--panel2); border-radius:999px; padding:1px 6px; margin-left:4px; }
|
||||
.dashPanelHeader{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
|
||||
.dashPanelHeader h2{ margin:0; }
|
||||
.dashCreateBar{ display:flex; gap:8px; align-items:center; margin-bottom:12px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--panel); }
|
||||
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
|
||||
.dashDangerZone{ margin-top:28px; }
|
||||
|
||||
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
|
||||
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
|
||||
beaucoup de champs rend le tableau illisible, ce menu permet d'en
|
||||
masquer certains (voir toggleDataColumn() : agit directement sur
|
||||
[data-col] plutôt que de reconstruire le tableau). */
|
||||
.columnFilterWrap{ position:relative; display:inline-block; }
|
||||
.columnFilterMenu{
|
||||
position:absolute; top:100%; left:0; margin-top:4px; z-index:70;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:8px;
|
||||
padding:8px; box-shadow:0 12px 40px rgba(0,0,0,.55);
|
||||
display:flex; flex-direction:column; gap:2px; min-width:180px; max-height:280px; overflow-y:auto;
|
||||
}
|
||||
.columnFilterMenu.hidden{ display:none; }
|
||||
.columnFilterOption{ display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:4px; font-size:12.5px; white-space:nowrap; }
|
||||
.columnFilterOption:hover{ background:var(--panel2); }
|
||||
|
||||
/* ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ---------- */
|
||||
/* La colonne "Clé" n'a de sens que pour un Objet (paires clé/valeur) — un
|
||||
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
|
||||
game_dashboard.html. */
|
||||
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
|
||||
|
||||
+27
-7
@@ -9,19 +9,39 @@
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<!-- #pageChrome : tout ce qui est en dehors de <main> mais doit quand même
|
||||
changer d'une page à l'autre (barre du jeu comprise) — un seul bloc
|
||||
stable pour que pjax.js puisse le remplacer d'un coup à chaque
|
||||
navigation (voir swapDocument() dans pjax.js). Avant l'ajout de ce
|
||||
conteneur, pjax.js cherchait un <header class="topbar"> qui n'a jamais
|
||||
existé (la barre est un <nav>, pas un <header>) : la barre de
|
||||
navigation du jeu (ajoutée après pjax.js) ne changeait donc jamais
|
||||
pendant la navigation pjax — absente en arrivant sur un jeu tant qu'on
|
||||
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
|
||||
laissée en place en revenant sur l'accueil (liste des jeux) où elle
|
||||
n'a pourtant rien à faire. -->
|
||||
<div id="pageChrome">
|
||||
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
|
||||
</div>
|
||||
</nav>
|
||||
{% block breadcrumb_wrap %}
|
||||
<nav class="breadcrumb breadcrumbBar" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ url_for('index') }}">🛠️ Mes jeux</a></li>
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
</ul>
|
||||
{% if game is defined and game %}
|
||||
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
|
||||
Écrans/Éléments de jeu/Variables/Objets n'ont plus leur propre page —
|
||||
tout ça vit désormais dans les onglets du tableau de bord (voir
|
||||
game_dashboard.html) — donc un seul lien y mène. Remplace le fil
|
||||
d'Ariane (retiré, devenu redondant) : n'apparaît que si la route a mis
|
||||
un `game` dans le contexte du template (déjà fait par toutes les
|
||||
pages d'un jeu) — absente sur l'accueil (liste des jeux). "Jouer" est
|
||||
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
|
||||
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
|
||||
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
||||
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
|
||||
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<main class="content {% block content_class %}{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a></li>
|
||||
<li class="is-active"><a>{{ 'Modifier #' ~ row.id if row else 'Nouvelle entrée' }}</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">{{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}</h1>
|
||||
|
||||
<form method="post" class="formPassthrough">
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Objet</h2>
|
||||
<div class="card">
|
||||
<p class="hint">Formulaire généré automatiquement à partir de la définition de « {{ definition.name }} » — aucun champ n'est codé en dur ici.</p>
|
||||
<div class="actionsRow">
|
||||
<button type="submit" class="primary">{{ 'Enregistrer les modifications' if row else 'Enregistrer en base' }}</button>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:10px;"><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">← Retour à « {{ definition.name }} »</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Champs</h2>
|
||||
<div class="fieldsTableWrap" style="padding:10px;">
|
||||
{% for f in definition.fields %}
|
||||
{% set col = f.name|colname %}
|
||||
{% set current = row[col] if row else none %}
|
||||
<div class="field">
|
||||
<label class="label" for="field_{{ f.id }}">{{ f.name }}{% if f.required %} *{% endif %}</label>
|
||||
<div class="control">
|
||||
|
||||
{% if f.type == 'texte' %}
|
||||
<input class="input" type="text" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
|
||||
|
||||
{% elif f.type == 'texte_long' %}
|
||||
<textarea class="textarea" id="field_{{ f.id }}" name="{{ col }}" rows="4" {{ 'required' if f.required else '' }}>{{ current or '' }}</textarea>
|
||||
|
||||
{% elif f.type == 'nombre_entier' %}
|
||||
<input class="input" type="number" step="1" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
|
||||
|
||||
{% elif f.type == 'nombre_decimal' %}
|
||||
<input class="input" type="number" step="any" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
|
||||
|
||||
{% elif f.type == 'booleen' %}
|
||||
<label class="checkbox"><input type="checkbox" id="field_{{ f.id }}" name="{{ col }}" {{ 'checked' if current else '' }}> Oui</label>
|
||||
|
||||
{% elif f.type == 'date' %}
|
||||
<input class="input" type="date" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
|
||||
|
||||
{% elif f.type == 'relation' %}
|
||||
{% set current_rel = row[col ~ '_id'] if row else none %}
|
||||
<div class="select is-fullwidth">
|
||||
<select id="field_{{ f.id }}" name="{{ col }}_id" {{ 'required' if f.required else '' }}>
|
||||
<option value="">—</option>
|
||||
{% for opt in relation_options.get(f.id, []) %}
|
||||
<option value="{{ opt.id }}" {{ 'selected' if current_rel == opt.id else '' }}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if not relation_options.get(f.id) %}
|
||||
<p class="help">Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,128 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li class="is-active"><a>{{ definition.name }}</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">{{ definition.name }}
|
||||
<a class="button is-small" href="{{ url_for('object_edit', slug=game.slug, definition_id=definition.id) }}" title="Modifier cet objet">✏️</a>
|
||||
</h1>
|
||||
<p class="hint mb-4">Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)</p>
|
||||
|
||||
{% if referenced_by %}
|
||||
<div class="warningBanner">
|
||||
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
|
||||
{{ referenced_by|map(attribute='name')|join(', ') }}.
|
||||
La suppression de cet objet est bloquée tant que ces relations existent.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if blocked_row_id %}
|
||||
<div class="warningBanner">
|
||||
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée par
|
||||
{% for ref in blocked_row_refs %}{{ ref.count }} entrée(s) de « {{ ref.definition_name }} » (champ « {{ ref.field_name }} »){% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
Modifie ou supprime d'abord ces entrées.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Ajouter</h2>
|
||||
<div class="card">
|
||||
<p class="hint">Une entrée, c'est une ligne de la table SQL « {{ definition.table_name }} ».</p>
|
||||
<div class="actionsRow">
|
||||
<a class="button is-primary is-outlined is-fullwidth" href="{{ url_for('data_new', slug=game.slug, definition_id=definition.id) }}">+ Ajouter une entrée</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Données enregistrées</h2>
|
||||
{% if rows %}
|
||||
{% macro row_field_display(f, r) %}
|
||||
{%- if f.type == 'relation' -%}
|
||||
{{ relation_labels.get(f.id, {}).get(r[(f.name|colname) ~ '_id'], '—') }}
|
||||
{%- elif f.type == 'booleen' -%}
|
||||
{{ 'Oui' if r[f.name|colname] else 'Non' }}
|
||||
{%- else -%}
|
||||
{{ r[f.name|colname] }}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable dataTable fieldsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.id }}</td>
|
||||
{% for f in definition.fields %}
|
||||
<td>{{ row_field_display(f, r) }}</td>
|
||||
{% endfor %}
|
||||
<td>
|
||||
<div class="buttons is-inline-flex are-small">
|
||||
<button type="button" class="button is-small" title="Voir cette entrée en détail" onclick="openRowDetail({{ r.id }})">👁️</button>
|
||||
<a class="button is-small" href="{{ url_for('data_edit', slug=game.slug, definition_id=definition.id, row_id=r.id) }}">✏️</a>
|
||||
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=definition.id, row_id=r.id) }}"
|
||||
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');">
|
||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for r in rows %}
|
||||
<div class="modal" id="rowDetail{{ r.id }}">
|
||||
<div class="modal-background" onclick="closeRowDetail({{ r.id }})"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Entrée #{{ r.id }}</p>
|
||||
<button class="delete" aria-label="Fermer" onclick="closeRowDetail({{ r.id }})"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
{% for f in definition.fields %}
|
||||
<div class="rowDetailField">
|
||||
<div class="rowDetailLabel">{{ f.name }}</div>
|
||||
<div class="rowDetailValue">{{ row_field_display(f, r) }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openRowDetail(id) {
|
||||
var modal = document.getElementById('rowDetail' + id);
|
||||
if (modal) modal.classList.add('is-active');
|
||||
}
|
||||
function closeRowDetail(id) {
|
||||
var modal = document.getElementById('rowDetail' + id);
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
if (!window.__forgeRowDetailEscBound) {
|
||||
window.__forgeRowDetailEscBound = true;
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.querySelectorAll('.modal.is-active').forEach(function (m) { m.classList.remove('is-active'); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,73 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Éléments de jeu — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li class="is-active"><a>Éléments de jeu</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">Éléments de jeu</h1>
|
||||
<p class="hint mb-4">Un élément de jeu est un conteneur réutilisable.</p>
|
||||
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Créer</h2>
|
||||
<form method="post" class="card">
|
||||
<div class="fieldRow">
|
||||
<div class="formField" style="flex:0 0 80px;">
|
||||
<label for="icon">Icône</label>
|
||||
<input type="text" id="icon" name="icon" value="🧩" maxlength="4">
|
||||
</div>
|
||||
<div class="formField" style="flex:1;">
|
||||
<label for="name">Nom</label>
|
||||
<input type="text" id="name" name="name" placeholder="ex. Personnage, Outil, Carte mail..." required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formField">
|
||||
<label for="bound_definition_id">Objet lié (optionnel)</label>
|
||||
<select id="bound_definition_id" name="bound_definition_id">
|
||||
<option value="">(aucun — élément décoratif, sans donnée)</option>
|
||||
{% for d in definitions %}
|
||||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="actionsRow"><button type="submit" class="primary">+ Créer</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Catalogue</h2>
|
||||
{% if types %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead><tr><th>Nom</th><th>Usage</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in types %}
|
||||
<tr>
|
||||
<td>{{ t.icon }} {{ t.name }}</td>
|
||||
<td class="has-text-grey">
|
||||
{{ usage.get(t.id, 0) }} instance(s)
|
||||
{% if t.bound_definition_id %}
|
||||
{% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="fieldsTableActions">
|
||||
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=t.template_screen_id) }}" title="Modifier le contenu de cet élément de jeu">✏️</a>
|
||||
<form method="post" action="{{ url_for('element_type_delete', slug=game.slug, element_type_id=t.id) }}"
|
||||
onsubmit="return confirm('Supprimer l\'élément de jeu « {{ t.name }} » du catalogue (et tout son contenu) ?');">
|
||||
<button type="submit" class="button is-small is-danger" {{ 'disabled title="Utilisé sur un écran — retire-le des écrans d\'abord"' if usage.get(t.id, 0) > 0 else '' }}>🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucun élément de jeu défini pour l'instant.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+1104
-51
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Variables — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li class="is-active"><a>Variables</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">Variables globales</h1>
|
||||
<p class="hint mb-4">Des propriétés/valeurs communes à tout le jeu (pas attachées à un objet de données précis) — utile par exemple pour une porte ouverte ou non, un niveau débloqué... Une variable peut être lue dans une "Condition de visibilité" (propriétés d'un élément) et modifiée par une action "Modifier une variable" (Logique de la scène).</p>
|
||||
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Créer</h2>
|
||||
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="card">
|
||||
<div class="formField">
|
||||
<label for="new-var-name">Nom</label>
|
||||
<input type="text" id="new-var-name" name="name" placeholder="ex. porte_ouverte" required autocomplete="off">
|
||||
</div>
|
||||
<div class="formField">
|
||||
<label for="new-var-type">Type</label>
|
||||
<select id="new-var-type" name="type">
|
||||
{% for key, val in variable_types.items() %}
|
||||
<option value="{{ key }}">{{ val.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formField">
|
||||
<label for="new-var-value">Valeur de départ</label>
|
||||
<input type="text" id="new-var-value" name="default_value" placeholder="ex. 0" autocomplete="off">
|
||||
</div>
|
||||
<div class="actionsRow"><button type="submit" class="primary">+ Créer la variable</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Variables définies</h2>
|
||||
{% if variables %}
|
||||
{% for v in variables %}
|
||||
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.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></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in variables %}
|
||||
<tr>
|
||||
<td>{{ v.name }}</td>
|
||||
<td>
|
||||
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
|
||||
{% for key, val in variable_types.items() %}
|
||||
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ val.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »"></td>
|
||||
<td class="fieldsTableActions">
|
||||
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small">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 de visibilité ou action qui la référence cessera de fonctionner.');">
|
||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucune variable pour l'instant — crée la première à gauche.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Forge Engine — Mes jeux{% endblock %}
|
||||
{% block breadcrumb_wrap %}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Modifier {{ definition.name }} — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a></li>
|
||||
<li class="is-active"><a>Modifier</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">Modifier l'objet « {{ definition.name }} »</h1>
|
||||
<p class="help mb-4">Table SQL : {{ definition.table_name }}</p>
|
||||
|
||||
<form method="post" class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<label class="label" for="object_name">Nom de l'objet</label>
|
||||
<input class="input" type="text" id="object_name" name="object_name" value="{{ definition.name }}" required>
|
||||
</div>
|
||||
<div class="control" style="align-self:flex-end;">
|
||||
<button type="submit" class="button">Renommer</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="twoCol">
|
||||
<!-- Colonne de gauche : ajouter un champ + supprimer l'objet -->
|
||||
<div class="colLeft">
|
||||
<h2 class="subtitle">Ajouter un champ</h2>
|
||||
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=definition.id) }}" class="box" id="addFieldForm">
|
||||
<div class="field">
|
||||
<label class="label">Nom du champ</label>
|
||||
<div class="control"><input class="input" type="text" name="field_name" placeholder="ex. nom, description, pv..." required></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_type" id="newFieldType">
|
||||
{% for key, val in field_types.items() %}
|
||||
<option value="{{ key }}">{{ val.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="newFieldRelationWrap" class="field" style="display:none;">
|
||||
<label class="label">Objet lié</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_relation">
|
||||
{% for d in existing_definitions %}
|
||||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if not existing_definitions %}
|
||||
<p class="help">Aucun autre objet défini.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="newFieldBoundsWrap" class="columns" style="display:none;">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Valeur mini (optionnel)</label>
|
||||
<div class="control"><input class="input" type="number" step="any" name="field_min" placeholder="ex. 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Valeur maxi (optionnel)</label>
|
||||
<div class="control"><input class="input" type="number" step="any" name="field_max" placeholder="ex. 100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Obligatoire ?</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_required">
|
||||
<option value="0">Non</option>
|
||||
<option value="1">Oui</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field"><button type="submit" class="button is-primary">+ Ajouter ce champ</button></div>
|
||||
</form>
|
||||
|
||||
<div class="box has-background-danger-dark mt-4">
|
||||
<h3 class="title is-6 has-text-danger-light">Supprimer cet objet</h3>
|
||||
<form method="post" action="{{ url_for('object_delete', slug=game.slug, definition_id=definition.id) }}"
|
||||
onsubmit="return confirm('Supprimer définitivement l\'objet « {{ definition.name }} », sa table et toutes ses données ? Cette action est irréversible.');">
|
||||
<button type="submit" class="button is-danger">🗑️ Supprimer l'objet « {{ definition.name }} »</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne de droite : champs existants, chacun modifiable -->
|
||||
<div class="colRight">
|
||||
<h2 class="subtitle">Champs existants</h2>
|
||||
{% if definition.fields %}
|
||||
{% for f in definition.fields %}
|
||||
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=definition.id, field_id=f.id) }}" id="fieldEditForm{{ f.id }}"></form>
|
||||
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=definition.id, field_id=f.id) }}" id="fieldDeleteForm{{ f.id }}"
|
||||
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');"></form>
|
||||
{% endfor %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom du champ</th>
|
||||
<th>Type</th>
|
||||
<th>Objet lié</th>
|
||||
<th>Mini</th>
|
||||
<th>Maxi</th>
|
||||
<th>Obligatoire ?</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in definition.fields %}
|
||||
<tr>
|
||||
<td><input class="input is-small" type="text" form="fieldEditForm{{ f.id }}" name="field_name" value="{{ f.name }}" aria-label="Nom du champ « {{ f.name }} »" required></td>
|
||||
<td>{{ field_types.get(f.type, {}).get('label', f.type) }}</td>
|
||||
<td>
|
||||
{% if f.type == 'relation' %}
|
||||
<div class="select is-small">
|
||||
<select form="fieldEditForm{{ f.id }}" name="field_relation" aria-label="Objet lié pour « {{ f.name }} »">
|
||||
{% for d in existing_definitions %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if d.id == f.relation_definition_id else '' }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="has-text-grey">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
|
||||
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0" aria-label="Valeur mini pour « {{ f.name }} »">
|
||||
{% else %}
|
||||
<span class="has-text-grey">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
|
||||
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100" aria-label="Valeur maxi pour « {{ f.name }} »">
|
||||
{% else %}
|
||||
<span class="has-text-grey">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="select is-small">
|
||||
<select form="fieldEditForm{{ f.id }}" name="field_required" aria-label="« {{ f.name }} » obligatoire ?">
|
||||
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
|
||||
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fieldsTableActions">
|
||||
<button type="submit" form="fieldEditForm{{ f.id }}" class="button is-small">Enregistrer</button>
|
||||
<button type="submit" form="fieldDeleteForm{{ f.id }}" class="button is-small is-danger">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help">Mini/Maxi (bornage automatique) : laisser vide = pas de limite. S'applique à "Donner cette valeur", "Augmenter de..." et "Diminuer de..." dans le graphe de logique.</p>
|
||||
{% else %}
|
||||
<p class="help">Aucun champ pour l'instant.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var sel = document.getElementById('newFieldType');
|
||||
var relWrap = document.getElementById('newFieldRelationWrap');
|
||||
var boundsWrap = document.getElementById('newFieldBoundsWrap');
|
||||
var NUMERIC_TYPES = ['nombre_entier', 'nombre_decimal'];
|
||||
function sync(){
|
||||
relWrap.style.display = sel.value === 'relation' ? 'block' : 'none';
|
||||
boundsWrap.style.display = NUMERIC_TYPES.indexOf(sel.value) !== -1 ? 'flex' : 'none';
|
||||
}
|
||||
sel.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,104 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Nouvel objet — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li class="is-active"><a>Nouvel objet</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">Définir un nouvel objet</h1>
|
||||
|
||||
<form method="post" id="objectForm" class="formPassthrough">
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Objet</h2>
|
||||
<div class="card">
|
||||
<div class="formField">
|
||||
<label for="object_name">Nom de l'objet (ex. Personnage, Niveau, Question...)</label>
|
||||
<input type="text" id="object_name" name="object_name" required>
|
||||
</div>
|
||||
<div class="actionsRow"><button type="submit" class="primary">Créer l'objet</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Champs</h2>
|
||||
<button type="button" class="button is-outlined is-fullwidth mb-2" id="btnAddField">+ Ajouter un champ</button>
|
||||
<div class="fieldsTableWrap" style="padding:10px;">
|
||||
<div id="fieldsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="fieldRowTemplate">
|
||||
<div class="fieldRowCard box">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Nom du champ</label>
|
||||
<div class="control"><input class="input" type="text" name="field_name[]" placeholder="ex. nom, description, pv..." required></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_type[]" class="fieldTypeSelect">
|
||||
{% for key, val in field_types.items() %}
|
||||
<option value="{{ key }}">{{ val.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relationTarget field" style="display:none;">
|
||||
<label class="label">Objet lié</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_relation[]">
|
||||
{% for d in existing_definitions %}
|
||||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if not existing_definitions %}
|
||||
<p class="help">Aucun autre objet défini.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="boundsTarget columns" style="display:none;">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Valeur mini</label>
|
||||
<div class="control"><input class="input" type="number" step="any" name="field_min[]" placeholder="ex. 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Valeur maxi</label>
|
||||
<div class="control"><input class="input" type="number" step="any" name="field_max[]" placeholder="ex. 100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Obligatoire ?</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="field_required[]">
|
||||
<option value="0">Non</option>
|
||||
<option value="1">Oui</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button is-small is-danger btnRemoveField">🗑️ Retirer ce champ</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="{{ url_for('static', filename='object_form.js') }}"></script>
|
||||
{% endblock %}
|
||||
+279
-80
@@ -163,33 +163,69 @@
|
||||
}
|
||||
|
||||
function applyAnimationClip(clip) {
|
||||
var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]');
|
||||
if (!target) return;
|
||||
// querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
|
||||
// écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
|
||||
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
|
||||
// modèle (rechargé en direct depuis le même écran-modèle, voir
|
||||
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
|
||||
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
|
||||
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
|
||||
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
|
||||
if (!targets.length) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
var infinite = iterationCount === 'infinite';
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.classList.remove('animate__animated', animClass);
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
targets.forEach(function(target) {
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.classList.remove('animate__animated', animClass);
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
|
||||
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
|
||||
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
|
||||
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
|
||||
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
|
||||
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
|
||||
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
|
||||
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
|
||||
function collectAnimationClips(screenId) {
|
||||
var visited = {};
|
||||
var clips = [];
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach(function(el) {
|
||||
if (!el.element_type_id) return;
|
||||
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
visit(screenId);
|
||||
return clips;
|
||||
}
|
||||
|
||||
function runAnimationTimeline(screenId) {
|
||||
@@ -200,7 +236,7 @@
|
||||
// suite sur le même écran avant la fin de sa timeline.
|
||||
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
|
||||
pendingAnimationTimers = [];
|
||||
var clips = (gameData.animations || {})[String(screenId)] || [];
|
||||
var clips = collectAnimationClips(screenId);
|
||||
clips.forEach(function(clip) {
|
||||
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
pendingAnimationTimers.push(t);
|
||||
@@ -233,11 +269,20 @@
|
||||
// fois, sur tous les éléments de tous les écrans (déjà tous présents dans
|
||||
// le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant
|
||||
// d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible.
|
||||
// Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de
|
||||
// jeu réutilisable, ex. "mail content", posé sur une scène) : un
|
||||
// déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un
|
||||
// de SES PROPRES enfants, est donc retrouvé même quand ce modèle est
|
||||
// utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du
|
||||
// nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter
|
||||
// le graphe dans CET écran-là (celui du modèle), jamais dans celui
|
||||
// réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le
|
||||
// graphe utilisé et l'action ne se déclencherait jamais.
|
||||
function findTriggerNode(elementId, event) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
const flow = gameData.flows[screenIdKey];
|
||||
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event);
|
||||
if (found) return found;
|
||||
if (found) return { node: found, screenId: screenIdKey };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -290,7 +335,7 @@
|
||||
const rowEl = ev.target.closest('.repeaterItem');
|
||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
runFlowFrom(trigger.id);
|
||||
runFlowFrom(trigger.node.id, trigger.screenId);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -311,8 +356,8 @@
|
||||
const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
|
||||
if (!enterTrigger && !leaveTrigger) return;
|
||||
elDiv.dataset.hoverTriggerBound = '1';
|
||||
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id));
|
||||
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id));
|
||||
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId));
|
||||
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,46 +384,124 @@
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
if (!row || !screenData || !screenDiv) return;
|
||||
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
|
||||
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
|
||||
if (!wrapper || !el.rendered_html || el.rendered_html.indexOf(PLACEHOLDER_OPEN) === -1) return;
|
||||
|
||||
// Avant de regénérer ce sous-arbre depuis le HTML D'ORIGINE (nécessaire
|
||||
// pour ne pas laisser les valeurs d'un mail précédent affichées, voir
|
||||
// plus haut), sauvegarde l'état visuel TRANSITOIRE posé par une action
|
||||
// "Modifier un élément" (jamais enregistré en base — voir
|
||||
// applyElementProperty) sur cet élément ou l'un de ses descendants,
|
||||
// pour le réappliquer après. Sans ça, un panneau rendu visible par
|
||||
// cette action (ex. "mail content") redevenait invisible à CHAQUE
|
||||
// rafraîchissement de données, y compris pour un changement de donnée
|
||||
// sans rapport avec lui — puisque wrapper.innerHTML = html ci-dessous
|
||||
// recrée entièrement ses nœuds à partir de leur état PAR DÉFAUT.
|
||||
const savedState = {};
|
||||
wrapper.querySelectorAll('[data-element-id]').forEach(function(node) {
|
||||
savedState[node.dataset.elementId] = {
|
||||
style: node.getAttribute('style') || '',
|
||||
className: node.className,
|
||||
dataset: Object.assign({}, node.dataset),
|
||||
};
|
||||
});
|
||||
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
|
||||
// éléments qui portent vraiment un {{champ}} non résolu — et non plus
|
||||
// seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce
|
||||
// correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail
|
||||
// content" posé dans un conteneur, lui-même dans un autre) forçait à
|
||||
// regénérer TOUT son ancêtre de premier niveau — potentiellement
|
||||
// l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un
|
||||
// rechargement de page (flash à vide) à chaque clic sur une ligne.
|
||||
//
|
||||
// Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que
|
||||
// les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de
|
||||
// ses DESCENDANTS sont tous deux candidats, seul le descendant est
|
||||
// régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses
|
||||
// descendants, donc un conteneur "candidat" ne l'est souvent que parce
|
||||
// qu'un descendant à lui l'est — le garder en plus regénérerait
|
||||
// inutilement tout son sous-arbre, à l'exact opposé du but recherché).
|
||||
// ATTENTION : la version précédente de ce filtre vérifiait l'inverse
|
||||
// (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre
|
||||
// est presque toujours "candidat" dès qu'un descendant l'est (son HTML
|
||||
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
|
||||
// de l'écran, annulant complètement l'effet recherché par ce correctif.
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(function(e) {
|
||||
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
});
|
||||
|
||||
let html = el.rendered_html;
|
||||
Object.keys(row).forEach(function(fieldName) {
|
||||
if (fieldName === 'id') return;
|
||||
const value = row[fieldName] == null ? '' : String(row[fieldName]);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.textContent = value;
|
||||
html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML);
|
||||
});
|
||||
wrapper.innerHTML = html;
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
|
||||
// mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici :
|
||||
// - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un
|
||||
// <span data-bind-field="champ"> qui portera la valeur, texte
|
||||
// après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se
|
||||
// contentent de changer le textContent de ce span.
|
||||
// - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le
|
||||
// gabarit d'origine est mémorisé sur un data-bind-attr-<nom> lors du
|
||||
// premier passage, pour pouvoir recalculer et réécrire l'attribut à
|
||||
// chaque fois sans jamais avoir besoin de reconstruire le nœud.
|
||||
// Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé
|
||||
// depuis zéro à chaque clic sur une ligne — ce qui causait un flash
|
||||
// visuel (perte de tout état transitoire posé par "Modifier un
|
||||
// élément", donnant l'impression trompeuse d'un rechargement de page).
|
||||
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
|
||||
// rien à sauvegarder ni à restaurer.
|
||||
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
|
||||
|
||||
wrapper.querySelectorAll('[data-element-id]').forEach(function(node) {
|
||||
const saved = savedState[node.dataset.elementId];
|
||||
if (!saved) return;
|
||||
node.setAttribute('style', saved.style);
|
||||
node.className = saved.className;
|
||||
Object.assign(node.dataset, saved.dataset);
|
||||
function resolveTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const toSplit = [];
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach(function(textNode) {
|
||||
const raw = textNode.nodeValue;
|
||||
placeholderRe.lastIndex = 0;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0, match;
|
||||
while ((match = placeholderRe.exec(raw))) {
|
||||
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
|
||||
const span = document.createElement('span');
|
||||
span.dataset.bindField = match[1];
|
||||
span.textContent = row[match[1]] == null ? '' : String(row[match[1]]);
|
||||
frag.appendChild(span);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex)));
|
||||
textNode.replaceWith(frag);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTextSlots(root) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
|
||||
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
|
||||
if (slot.textContent !== value) slot.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttributes(root) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
|
||||
Array.from(node.attributes).forEach(function(attr) {
|
||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||
const bindKey = 'data-bind-attr-' + attr.name;
|
||||
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
const resolved = template.replace(placeholderRe, function(_, fieldName) {
|
||||
return row[fieldName] == null ? '' : String(row[fieldName]);
|
||||
});
|
||||
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
targets.forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
if (target.querySelector('[data-bind-field]')) {
|
||||
updateTextSlots(target);
|
||||
} else {
|
||||
resolveTextNodes(target);
|
||||
}
|
||||
resolveAttributes(target);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,21 +522,78 @@
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
if (!screenData || !screenDiv) return;
|
||||
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
|
||||
if (!el.rendered_html) return;
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée) et "visibilityGated" (condition de visibilité —
|
||||
// voir render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return;
|
||||
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
|
||||
if (wrapper) wrapper.innerHTML = el.rendered_html;
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée) et "visibilityGated" (condition de visibilité —
|
||||
// voir render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasMarker(e) {
|
||||
return !!(e.rendered_html && (e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1));
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates.filter(function(e) {
|
||||
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
}).forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (newNode) target.replaceWith(newNode);
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
@@ -533,6 +713,18 @@
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
@@ -672,8 +864,15 @@
|
||||
// seulement le premier. Chaque nœud Action produit son effet puis
|
||||
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
|
||||
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
|
||||
function runFlowFrom(nodeId) {
|
||||
const flow = gameData.flows[String(window.currentScreenId)];
|
||||
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
|
||||
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode)
|
||||
// quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un
|
||||
// élément de jeu réutilisable, sur un de ses propres enfants. Sans ce
|
||||
// paramètre, un tel déclencheur était bien retrouvé (findTriggerNode
|
||||
// cherche dans tous les écrans) mais son graphe n'existait jamais dans
|
||||
// l'écran réellement affiché : rien ne se déclenchait, silencieusement.
|
||||
function runFlowFrom(nodeId, flowScreenId) {
|
||||
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
|
||||
if (!flow) return;
|
||||
const nodesById = {};
|
||||
flow.nodes.forEach(n => { nodesById[n.id] = n; });
|
||||
|
||||
+29
-22
@@ -6,10 +6,6 @@
|
||||
<link rel="stylesheet" href="{{ google_fonts_link }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||
{% endblock %}
|
||||
<!-- Pas de fil d'Ariane sur cette page : le canevas doit être monté le
|
||||
plus haut possible (voir .content-builder), et le nom de l'écran est
|
||||
de toute façon déjà éditable juste au-dessus (panneau "Éléments"). -->
|
||||
{% block breadcrumb_wrap %}{% endblock %}
|
||||
{% block content %}
|
||||
{% if screen.is_template %}
|
||||
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
|
||||
@@ -145,7 +141,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('element_types', slug=game.slug) }}">crées-en un</a>.</p>
|
||||
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='element_types') }}">crées-en un</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
@@ -160,20 +156,23 @@
|
||||
<!-- Centre : l'écran (onglet "Écran"), ou la logique de la scène / la
|
||||
timeline d'animation quand leur onglet est actif — un seul onglet
|
||||
visible à la fois, chacun occupant tout l'espace disponible (voir
|
||||
switchBuilderTab() plus bas). Absents sur un écran-modèle (élément
|
||||
de jeu) : ses déclencheurs référenceraient des éléments dont l'id
|
||||
change à chaque copie (voir instantiate_template_tree), donc une
|
||||
logique/animation posée ici ne fonctionnerait plus une fois
|
||||
l'élément réutilisé — seul l'onglet "Écran" a alors un sens. -->
|
||||
switchBuilderTab() plus bas). Présents aussi sur un écran-modèle
|
||||
(élément de jeu réutilisable, ex. "mail content") : ses enfants
|
||||
gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur
|
||||
n'importe quelle scène (rechargés en direct depuis ce modèle à
|
||||
chaque affichage, voir _render_element_type_children côté serveur —
|
||||
l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose,
|
||||
instantiate_template_tree, a été retiré). Un déclencheur/une
|
||||
animation posé ici, sur un enfant du modèle, s'exécute donc bien
|
||||
partout où ce modèle est utilisé (voir findTriggerNode/
|
||||
collectAnimationClips dans templates/play.html). -->
|
||||
<div class="builderCanvasArea">
|
||||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||||
{% if not screen.is_template %}
|
||||
<div class="builderTabs" id="builderTabs">
|
||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="builderTabPanel active" data-tab-panel="screen">
|
||||
<div class="canvasFrame">
|
||||
@@ -190,7 +189,6 @@
|
||||
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
|
||||
</div>
|
||||
|
||||
{% if not screen.is_template %}
|
||||
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
|
||||
<div class="flowToolbar">
|
||||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||||
@@ -222,7 +220,7 @@
|
||||
|
||||
<div id="nodeFormCondition" style="display:none;">
|
||||
{% if not definitions %}
|
||||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
|
||||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
|
||||
{% else %}
|
||||
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
|
||||
{% for d in definitions %}
|
||||
@@ -315,7 +313,7 @@
|
||||
|
||||
<div id="nf-action-data-fields" style="display:none;">
|
||||
{% if not definitions %}
|
||||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
|
||||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
|
||||
{% else %}
|
||||
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
|
||||
{% for d in definitions %}
|
||||
@@ -449,7 +447,6 @@
|
||||
<div id="animTracks" class="animTracks"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
|
||||
@@ -649,7 +646,7 @@
|
||||
{% set ns.started = True %}
|
||||
{% endif %}
|
||||
<div class="field controlRow{{ ' visCondRow' if c.key.startswith('visibilite_cond_') and c.key != 'visibilite_cond_mode' else '' }}"
|
||||
{% if c.key == 'visibilite_cond_variable' %}data-vis-cond-scope="variable"
|
||||
{% if c.key in ('visibilite_cond_variable', 'visibilite_cond_variable_chemin') %}data-vis-cond-scope="variable"
|
||||
{% elif c.key in ('visibilite_cond_definition_id', 'visibilite_cond_champ') %}data-vis-cond-scope="objet"
|
||||
{% elif c.key in ('visibilite_cond_operateur', 'visibilite_cond_valeur') %}data-vis-cond-scope="any"
|
||||
{% endif %}>
|
||||
@@ -714,8 +711,12 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Utile seulement si la variable choisie est de type
|
||||
"objet"/"tableau" : sans chemin, c'est toute la variable
|
||||
(son JSON) qui est utilisée comme valeur de comparaison. -->
|
||||
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
|
||||
{% if not global_variables %}
|
||||
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_variables', slug=game.slug) }}" target="_blank">crées-en une ↗</a></p>
|
||||
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -811,7 +812,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if c.key == 'visibilite_cond_variable' %}
|
||||
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_variables', slug=game.slug) }}" target="_blank">Gérer les variables du jeu ↗</a></p>
|
||||
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">Gérer les variables du jeu ↗</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1717,8 +1718,10 @@ var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
|
||||
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
||||
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
||||
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ").
|
||||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}]+)\\s*\\}\\}$');
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
||||
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
||||
// variable, utile pour une variable "objet"/"tableau".
|
||||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$');
|
||||
|
||||
function _filterValueDefinitionId(objName) {
|
||||
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||
@@ -1742,8 +1745,10 @@ function updateFilterValueFromDynamic(wrap) {
|
||||
|
||||
function updateFilterValueFromVariable(wrap) {
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var pathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + _FILTER_REF_CLOSE) : '';
|
||||
var path = pathInput ? pathInput.value.trim() : '';
|
||||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
@@ -1779,6 +1784,7 @@ function initFilterValuePickers() {
|
||||
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var varPathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var current = (wrap.dataset.currentValue || '').trim();
|
||||
var varMatch = _VAR_REF_RE.exec(current);
|
||||
var objMatch = _FILTER_REF_RE.exec(current);
|
||||
@@ -1787,6 +1793,7 @@ function initFilterValuePickers() {
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
wrap.querySelector('.filterValueVariable').style.display = 'block';
|
||||
varSel.value = varMatch[1].trim();
|
||||
if (varPathInput) varPathInput.value = (varMatch[2] || '').trim();
|
||||
} else if (objMatch) {
|
||||
modeSel.value = 'dynamique';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Écrans — {{ game.name }}{% endblock %}
|
||||
{% block content_class %}content-wide content-objectEdit{% endblock %}
|
||||
{% block body_class %}objectEditBody{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
|
||||
<li class="is-active"><a>Écrans</a></li>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="title">Écrans du jeu</h1>
|
||||
{% if request.args.get('blocked_screen') %}
|
||||
<div class="warningBanner">
|
||||
⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="twoCol">
|
||||
<div class="colLeft">
|
||||
<h2>Créer</h2>
|
||||
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="card">
|
||||
<div class="formField">
|
||||
<label for="name">Nom du nouvel écran</label>
|
||||
<input type="text" id="name" name="name" placeholder="ex. Accueil, Formulaire d'inscription, Scène 1..." required>
|
||||
</div>
|
||||
<div class="actionsRow"><button type="submit" class="primary">+ Créer l'écran</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="colRight">
|
||||
<h2>Enchaînement des écrans</h2>
|
||||
{% if screen_list %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead><tr><th>#</th><th>Nom</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in screen_list %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">{{ s.name }}</a>
|
||||
{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
|
||||
</td>
|
||||
<td class="fieldsTableActions">
|
||||
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="button is-small" {{ 'disabled' if loop.first else '' }}>↑</button></form>
|
||||
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="button is-small" {{ 'disabled' if loop.last else '' }}>↓</button></form>
|
||||
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">✏️</a>
|
||||
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
|
||||
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tous ses éléments ?');">
|
||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucun écran pour l'instant — crée le premier à gauche.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -100,15 +100,17 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object):
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
html = client.get(f"/game/{slug}/element-types").data.decode()
|
||||
html = client.get(f"/game/{slug}").data.decode()
|
||||
m = re.search(r"/screens/(\d+)/edit", html)
|
||||
assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue"
|
||||
template_screen_id = int(m.group(1))
|
||||
|
||||
# L'écran-modèle est un écran caché (is_template) : absent de la liste
|
||||
# normale des écrans jouables.
|
||||
screens_html = client.get(f"/game/{slug}/screens").data.decode()
|
||||
assert "CarteMail" not in screens_html or "Modèle" not in screens_html
|
||||
# normale des écrans jouables (onglet "Écrans du jeu" du tableau de
|
||||
# bord) — son nom complet ("Modèle : CarteMail") ne doit donc apparaître
|
||||
# nulle part sur cette page, contrairement à "CarteMail" seul (le nom de
|
||||
# l'élément de jeu lui-même, affiché dans l'onglet "Éléments de jeu").
|
||||
assert "Modèle : CarteMail" not in html
|
||||
|
||||
# Construit le contenu du modèle : un titre lié au champ "sujet".
|
||||
resp = client.post(
|
||||
@@ -266,7 +268,7 @@ def test_screen_editor_property_panels_closed_by_default(client, game):
|
||||
|
||||
def test_delete_element_type_removes_hidden_template_screen(client, game):
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/element-types").data.decode()
|
||||
html = client.get(f"/game/{game}").data.decode()
|
||||
m = re.search(r"/screens/(\d+)/edit", html)
|
||||
template_screen_id = int(m.group(1))
|
||||
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Variables globales de type "objet"/"tableau" (db/constants.py) : la
|
||||
valeur est du JSON, lisible via un chemin ".champ"/"[index]" chaînable
|
||||
(_resolve_variable_path, screens/rendering/filter_repeater_rows.py) —
|
||||
aussi bien dans un filtre de Répéteur ({{$var.chemin}}) que dans une
|
||||
Condition de visibilité en mode "variable" (contrôle séparé
|
||||
"visibilite_cond_variable_chemin")."""
|
||||
import json
|
||||
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 _add_element(client, slug, screen_id, widget="texte"):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _rendered_html(client, slug):
|
||||
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
|
||||
return payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
|
||||
|
||||
def _create_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "12"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "99"})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_create_and_update_validate_json_with_safe_fallback(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet", "default_value": "not json"})
|
||||
variable = db.get_global_variable(game, "perso")
|
||||
assert variable["value"] == "{}" # JSON invalide -> défaut sûr, pas la chaîne brute
|
||||
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "scores", "type": "tableau", "default_value": ""})
|
||||
variable = db.get_global_variable(game, "scores")
|
||||
assert variable["value"] == "[]" # vide -> défaut sûr propre au type
|
||||
|
||||
db.update_global_variable(game, db.get_global_variable(game, "perso")["id"], "objet", json.dumps({"nom": "Alice"}))
|
||||
assert json.loads(db.get_global_variable(game, "perso")["value"]) == {"nom": "Alice"}
|
||||
|
||||
|
||||
def test_repeater_filter_reads_a_field_inside_an_object_variable(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "perso", "type": "objet",
|
||||
"default_value": json.dumps({"nom": "Alice", "arme": {"degats": 12}}),
|
||||
})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
# {{$perso.arme.degats}} (chemin chaîné) doit résoudre à 12, et donc ne
|
||||
# garder que la ligne "numero = 12".
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$perso.arme.degats}}",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert html.count("repeaterItem") == 1
|
||||
assert 'data-row-id="1"' in html
|
||||
assert 'data-row-id="2"' not in html
|
||||
|
||||
|
||||
def test_visibility_condition_reads_an_array_element_by_index(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "scores", "type": "tableau", "default_value": json.dumps([10, 20, 30]),
|
||||
})
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
|
||||
# scores[1] == 20 -> visible.
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "scores",
|
||||
"ctrl_visibilite_cond_variable_chemin": "[1]",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "20",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" not in html
|
||||
|
||||
# scores[0] == 10, pas 20 -> masqué.
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "scores",
|
||||
"ctrl_visibilite_cond_variable_chemin": "[0]",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "20",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" in html
|
||||
|
||||
|
||||
def test_invalid_or_missing_path_never_crashes(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "perso", "type": "objet", "default_value": json.dumps({"nom": "Alice"}),
|
||||
})
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "perso",
|
||||
"ctrl_visibilite_cond_variable_chemin": ".champ_inexistant",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "peu importe",
|
||||
})
|
||||
resp = client.get(f"/game/{game}/runtime-payload")
|
||||
assert resp.status_code == 200
|
||||
assert "display:none" in resp.get_json()["screens"][0]["elements"][0]["rendered_html"]
|
||||
Reference in New Issue
Block a user