Dev #2

Merged
w-vandal merged 24 commits from dev into main 2026-08-27 13:00:44 +00:00
49 changed files with 1939 additions and 1054 deletions
+7 -1
View File
@@ -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)"},
}
+21
View File
@@ -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
+2
View File
@@ -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()
+9 -7
View File
@@ -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()
+1 -1
View File
@@ -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"))
+1 -1
View File
@@ -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"))
+8 -11
View File
@@ -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"))
+52 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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"))
-19
View File
@@ -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,
)
+1 -1
View File
@@ -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"))
+1 -1
View File
@@ -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"))
+5 -4
View File
@@ -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"))
+9 -17
View File
@@ -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"))
+6 -5
View File
@@ -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"))
+11 -15
View File
@@ -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))
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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))
+7 -11
View File
@@ -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))
+12 -27
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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"))
+1 -1
View File
@@ -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"))
-12
View File
@@ -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))
+1 -1
View File
@@ -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"))
+16 -8
View File
@@ -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
+29 -1
View File
@@ -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,
}
+39 -3
View File
@@ -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:
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-75
View File
@@ -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 %}
-128
View File
@@ -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 %}
-73
View File
@@ -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 %}
File diff suppressed because it is too large Load Diff
-75
View File
@@ -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
View File
@@ -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 %}
-194
View File
@@ -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 %}
-104
View File
@@ -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
View File
@@ -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
View File
@@ -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';
-62
View File
@@ -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 %}
+7 -5
View File
@@ -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))
+121
View File
@@ -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"]