Phase A (2/2) : éditeur de scène 2D — route, template, runtime jouable
Deuxième et dernier commit de la Phase A du plan "Fondations d'une
plateforme multi-éditeurs" (le premier, efa7d1d, posait le schéma et le
CRUD des objets de scène). Ce commit branche l'éditeur et le mode
jouable sur ces fondations, en réutilisant TEL QUEL tout ce qui est déjà
générique côté moteur de logique.
Éditeur (routes/scenes/, templates/scene_edit.html,
static/js/scenes/scene-editor.js) :
- routes/screens/screen_edit.py délègue à render_scene_edit() dès que
game_type == "jeu_2d" — même endpoint Flask "screen_edit" pour les
deux éditeurs, aucune route dupliquée.
- Nouveau template scene_edit.html : canevas à taille FIXE en pixels
(scene_width/scene_height) avec glisser-déposer/redimensionnement en
pixels (scene-editor.js, mirror pixel de tree-panels.js), galerie
personnages/décors. Les onglets "Blocs de logique"/"Timeline
d'animation"/"Événements" réutilisent tels quels flow-editor.js,
tabs-and-blocks.js et animation-timeline.js.
- flow-editor.js : nouveau flag FLOW_TARGETS_OBJECTS (false par défaut,
true dans scene_edit.html) qui fait écrire submitNodeForm() vers
trigger_object_id/target_object_id au lieu de trigger_element_id/
target_element_id, plus une branche "Modifier un objet de scène"
(position px, visibilité, orientation) et des gardes null partout
(le DOM de scene_edit.html n'a pas tous les champs de l'éditeur
document).
- blocks_view.py étend l'ensemble d'ids d'éléments concernés par un
bloc pour inclure aussi trigger_object_id/target_object_id.
Runtime jouable (templates/play.html, static/js/play/actions.js,
screens/payload/full_game_payload.py) :
- full_game_payload() construit les écrans d'un jeu jeu_2d à partir de
_scene_objects (render_scene_object) au lieu de _screen_elements, et
récupère les animations de personnage sur les objets de scène.
- play.html : #playFrame occupe tout le viewport pour une scène jeu_2d
(pas de ratio fluide) ; la scène se centre via .playScreen.playScene,
qui réutilise tel quel showScreen() (déjà indexé par id d'écran, pas
par forme DOM) — aucun fichier "scenes.js" séparé n'a été nécessaire.
- actions.js : jouer_animation_sprite retombe sur target_object_id si
target_element_id est absent ; nouvelle action modifier_objet_scene
avec applyObjectProperty() (positions en px, contrairement aux % de
applyElementProperty()).
Un bug de gabarit a été découvert et corrigé pendant la vérification :
le commentaire CSS de play.html contenait littéralement "{% for %}"
comme texte français, que Jinja interprétait comme une vraie balise et
faisait planter le rendu — reformulé. render_scene_object() n'était en
outre jamais appelé par la vue de l'éditeur (les objets de scène
s'affichaient sans image) — scene_edit_view.py attache maintenant
rendered_html à chaque objet avant de les passer au template.
Nouveaux tests (tests/test_scene_edit_view.py, 10 cas) : dispatch
document vs jeu_2d, rendu d'un personnage sélectionné, CRUD géométrie/
suppression d'objet, nœuds de flow ciblant un objet de scène (action et
condition de collision), payload et page /play pour un jeu jeu_2d,
présence des nouveaux helpers JS. Suite complète : 309 tests passent
(299 existants + 10 nouveaux, aucune régression). node --check et
node --test (13 tests JS) passent également.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
efa7d1d9b0
commit
f0070faced
+1
-1
@@ -1 +1 @@
|
|||||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play
|
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from . import (
|
||||||
|
scene_object_add,
|
||||||
|
scene_object_geometry,
|
||||||
|
scene_object_delete,
|
||||||
|
scene_object_personnage_data,
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from flask import render_template, request, abort
|
||||||
|
|
||||||
|
import db
|
||||||
|
import screens
|
||||||
|
|
||||||
|
|
||||||
|
def render_scene_edit(slug, screen_id, game):
|
||||||
|
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
||||||
|
screen_edit.py (le dispatcher, selon game["game_type"]), PAS une route
|
||||||
|
Flask elle-même : même URL/endpoint "screen_edit" pour les deux types
|
||||||
|
de jeu. Réutilise TEL QUEL tout ce qui est déjà générique côté logique
|
||||||
|
de flow/animation/variables/événements (voir le plan "Fondations
|
||||||
|
d'une plateforme multi-éditeurs") — seuls les objets de scène et la
|
||||||
|
palette de nœuds proposée diffèrent de l'éditeur document."""
|
||||||
|
screen = screens.get_screen(slug, screen_id)
|
||||||
|
if not screen:
|
||||||
|
abort(404)
|
||||||
|
objects = screens.list_scene_objects(slug, screen_id)
|
||||||
|
for o in objects:
|
||||||
|
o["rendered_html"] = screens.render_scene_object(o)
|
||||||
|
selected_id = request.args.get("selected", type=int)
|
||||||
|
personnage_data = None
|
||||||
|
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
||||||
|
if selected_obj and selected_obj["kind"] == "personnage":
|
||||||
|
personnage_data = screens.resolve_personnage_data(selected_obj)
|
||||||
|
|
||||||
|
object_labels = {
|
||||||
|
o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor"))
|
||||||
|
for o in objects
|
||||||
|
}
|
||||||
|
element_animations_map = {
|
||||||
|
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
|
||||||
|
}
|
||||||
|
|
||||||
|
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||||
|
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||||
|
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||||
|
animation_clips = screens.list_animation_clips(slug, screen_id)
|
||||||
|
global_variables = db.list_global_variables(slug)
|
||||||
|
custom_events = db.list_custom_events(slug)
|
||||||
|
custom_event_usages = screens.list_custom_event_usages(slug)
|
||||||
|
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
|
||||||
|
all_screens = screens.list_screens(slug)
|
||||||
|
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||||
|
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, object_labels)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"scene_edit.html",
|
||||||
|
game=game, screen=screen, all_screens=all_screens,
|
||||||
|
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
|
||||||
|
sprite_library=screens.SPRITE_LIBRARY, sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
|
||||||
|
element_animations_map_json=json.dumps(element_animations_map),
|
||||||
|
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||||
|
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||||
|
element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES,
|
||||||
|
element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
|
||||||
|
trigger_events=screens.TRIGGER_EVENTS_2D, action_type_labels=screens.ACTION_TYPE_LABELS_2D,
|
||||||
|
object_action_properties=screens.OBJECT_ACTION_PROPERTIES,
|
||||||
|
element_action_property_labels_json=json.dumps(dict(screens.OBJECT_ACTION_PROPERTIES)),
|
||||||
|
condition_operators=screens.CONDITION_OPERATORS,
|
||||||
|
data_operations=screens.DATA_OPERATIONS, data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||||
|
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||||
|
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||||
|
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS_2D),
|
||||||
|
elements_labels_json=json.dumps(object_labels), screens_map_json=json.dumps(screens_map),
|
||||||
|
flow_nodes=flow_nodes, flow_edges=flow_edges,
|
||||||
|
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
|
||||||
|
flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view,
|
||||||
|
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
||||||
|
animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
|
||||||
|
global_variables=global_variables,
|
||||||
|
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||||
|
custom_events_map_json=json.dumps(custom_events_map),
|
||||||
|
definitions=db.list_definitions(slug),
|
||||||
|
definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}),
|
||||||
|
definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
|
||||||
|
def scene_object_add(slug, screen_id):
|
||||||
|
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
|
||||||
|
element_add.py, mais pour _scene_objects (voir screens/scenes/
|
||||||
|
add_scene_object.py)."""
|
||||||
|
kind = request.form.get("kind", "personnage")
|
||||||
|
forge_character = request.form.get("forge_character")
|
||||||
|
new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character)
|
||||||
|
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||||
|
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||||
|
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
|
||||||
|
def scene_object_delete(slug, object_id):
|
||||||
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
|
if not obj:
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
screen_id = obj["scene_id"]
|
||||||
|
screens.delete_scene_object(slug, object_id)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/geometry", methods=["POST"])
|
||||||
|
def scene_object_geometry(slug, object_id):
|
||||||
|
"""Mirror exact de routes/elements/element_geometry.py, mais en pixels
|
||||||
|
(voir screens/scenes/update_scene_object_geometry.py) — appelé en AJAX
|
||||||
|
pendant/à la fin d'un glisser-déposer sur la scène."""
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
screens.update_scene_object_geometry(
|
||||||
|
slug, object_id,
|
||||||
|
x=float(data["x"]), y=float(data["y"]),
|
||||||
|
width=float(data["width"]), height=float(data["height"]),
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/personnage", methods=["POST"])
|
||||||
|
def scene_object_personnage_data(slug, object_id):
|
||||||
|
"""Mirror exact de routes/elements/element_set_personnage_data.py,
|
||||||
|
mais pour un objet de scène — même réponse (animations résolues) pour
|
||||||
|
relancer immédiatement l'aperçu animé côté client sans recharger la
|
||||||
|
page (voir static/js/screen_edit/personnage-preview.js, réutilisable
|
||||||
|
tel quel puisqu'il ne dépend que de data-object-id/data-personnage,
|
||||||
|
pas de la table d'origine)."""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
|
if not obj:
|
||||||
|
return jsonify({"error": "objet introuvable"}), 404
|
||||||
|
screens.set_scene_object_personnage_data(slug, object_id, body)
|
||||||
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
|
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
||||||
@@ -6,11 +6,18 @@ import db
|
|||||||
import screens
|
import screens
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
from ..scenes.scene_edit_view import render_scene_edit
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
|
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
|
||||||
def screen_edit(slug, screen_id):
|
def screen_edit(slug, screen_id):
|
||||||
game = db.game_meta(slug)
|
game = db.game_meta(slug)
|
||||||
|
if game["game_type"] == "jeu_2d":
|
||||||
|
# Fondations multi-éditeurs (voir db/games/get_game_type.py) :
|
||||||
|
# même route/endpoint pour les deux types de jeu, dispatch ici —
|
||||||
|
# tout le reste de cette fonction ne concerne QUE l'éditeur
|
||||||
|
# document ("document", le défaut, comportement inchangé).
|
||||||
|
return render_scene_edit(slug, screen_id, game)
|
||||||
screen = screens.get_screen(slug, screen_id)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
if not screen:
|
if not screen:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
def blocks_view(flow_blocks, flow_nodes, elements_labels):
|
def blocks_view(flow_blocks, flow_nodes, elements_labels):
|
||||||
"""Enrichit chaque bloc (nom/description bruts) avec son nombre de
|
"""Enrichit chaque bloc (nom/description bruts) avec son nombre de
|
||||||
nœuds et les éléments qu'il concerne (trigger_element_id des
|
nœuds et les éléments qu'il concerne (trigger_element_id/
|
||||||
déclencheurs + target_element_id des actions, dédoublonnés) — calculé
|
trigger_object_id des déclencheurs + target_element_id/
|
||||||
en Python à partir de ce que routes/screens/screen_edit.py a déjà
|
target_object_id des actions, dédoublonnés) — calculé en Python à
|
||||||
chargé, sans requête SQL supplémentaire."""
|
partir de ce que routes/screens/screen_edit.py a déjà chargé, sans
|
||||||
|
requête SQL supplémentaire. trigger_object_id/target_object_id
|
||||||
|
(fondations multi-éditeurs, objets de scène) sont toujours None sur
|
||||||
|
un jeu "document" — sans effet sur son comportement existant."""
|
||||||
nodes_by_block = {}
|
nodes_by_block = {}
|
||||||
for n in flow_nodes:
|
for n in flow_nodes:
|
||||||
nodes_by_block.setdefault(n.get("block_id"), []).append(n)
|
nodes_by_block.setdefault(n.get("block_id"), []).append(n)
|
||||||
@@ -12,6 +15,8 @@ def blocks_view(flow_blocks, flow_nodes, elements_labels):
|
|||||||
block_nodes = nodes_by_block.get(b["id"], [])
|
block_nodes = nodes_by_block.get(b["id"], [])
|
||||||
element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")}
|
element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")}
|
||||||
element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")}
|
element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")}
|
||||||
|
element_ids |= {n["trigger_object_id"] for n in block_nodes if n.get("trigger_object_id")}
|
||||||
|
element_ids |= {n["target_object_id"] for n in block_nodes if n.get("target_object_id")}
|
||||||
view.append({
|
view.append({
|
||||||
**b,
|
**b,
|
||||||
"node_count": len(block_nodes),
|
"node_count": len(block_nodes),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from ..flow.list_flow_edges import list_flow_edges
|
|||||||
from ..animations.list_animation_clips import list_animation_clips
|
from ..animations.list_animation_clips import list_animation_clips
|
||||||
from ..element_types.list_element_types import list_element_types
|
from ..element_types.list_element_types import list_element_types
|
||||||
from ..rendering.personnage_data import resolve_personnage_animations
|
from ..rendering.personnage_data import resolve_personnage_animations
|
||||||
|
from ..scenes.list_scene_objects import list_scene_objects
|
||||||
|
from ..scenes.render_scene_object import render_scene_object
|
||||||
|
|
||||||
|
|
||||||
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||||
@@ -25,11 +27,23 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
conception), l'identifiant réel du visiteur pour une partie publique
|
conception), l'identifiant réel du visiteur pour une partie publique
|
||||||
(voir core/player_identity.py) — chaque objet/variable per_player
|
(voir core/player_identity.py) — chaque objet/variable per_player
|
||||||
renvoie alors la valeur propre à CE joueur."""
|
renvoie alors la valeur propre à CE joueur."""
|
||||||
|
# Fondations multi-éditeurs — un jeu "jeu_2d" (voir db/games/
|
||||||
|
# get_game_type.py) construit "elements" depuis _scene_objects
|
||||||
|
# (objets de scène en pixels) plutôt que _screen_elements (DOM en %) ;
|
||||||
|
# tout le reste de cette fonction (flows/animations/variables/data)
|
||||||
|
# est déjà générique et inchangé pour les deux types de jeu.
|
||||||
|
is_scene_game = db.get_game_type(slug) == "jeu_2d"
|
||||||
screens_ = list_screens(slug)
|
screens_ = list_screens(slug)
|
||||||
payload_screens = []
|
payload_screens = []
|
||||||
flows = {}
|
flows = {}
|
||||||
animations = {}
|
animations = {}
|
||||||
for s in screens_:
|
for s in screens_:
|
||||||
|
if is_scene_game:
|
||||||
|
objects = list_scene_objects(slug, s["id"])
|
||||||
|
for o in objects:
|
||||||
|
o["rendered_html"] = render_scene_object(o)
|
||||||
|
payload_screens.append({**s, "elements": objects})
|
||||||
|
else:
|
||||||
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
||||||
payload_screens.append({**s, "elements": elements})
|
payload_screens.append({**s, "elements": elements})
|
||||||
|
|
||||||
@@ -66,6 +80,11 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
"edges": list_flow_edges(slug, s["id"]),
|
"edges": list_flow_edges(slug, s["id"]),
|
||||||
}
|
}
|
||||||
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
|
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
|
||||||
|
if is_scene_game:
|
||||||
|
for o in list_scene_objects(slug, s["id"]):
|
||||||
|
if o["kind"] == "personnage":
|
||||||
|
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
||||||
|
else:
|
||||||
for el in list_elements(slug, s["id"]):
|
for el in list_elements(slug, s["id"]):
|
||||||
if el.get("widget") == "personnage":
|
if el.get("widget") == "personnage":
|
||||||
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
|
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ def render_scene_object(obj):
|
|||||||
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||||
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']};"
|
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']};"
|
||||||
)
|
)
|
||||||
|
# data-element-id (en plus de data-object-id) : réutilise TELS QUELS
|
||||||
|
# tous les sélecteurs [data-element-id=...] déjà écrits pour l'éditeur
|
||||||
|
# document (collision, animation de sprites, "Modifier un élément") —
|
||||||
|
# voir static/js/play/actions.js/conditions.js. Sans risque de
|
||||||
|
# collision d'id : un jeu est ENTIÈREMENT "document" (_screen_elements)
|
||||||
|
# OU "jeu_2d" (_scene_objects), jamais les deux à la fois.
|
||||||
if obj["kind"] == "personnage":
|
if obj["kind"] == "personnage":
|
||||||
src = resolve_personnage_idle_frame(obj)
|
src = resolve_personnage_idle_frame(obj)
|
||||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||||
return f'<img data-object-id="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>'
|
return f'<img data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>'
|
||||||
src = (obj.get("attributes") or {}).get("src", "")
|
src = (obj.get("attributes") or {}).get("src", "")
|
||||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||||
return f'<img data-object-id="{obj["id"]}" style="{style}"{src_attr}>'
|
return f'<img data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{style}"{src_attr}>'
|
||||||
|
|||||||
@@ -124,6 +124,29 @@ function applyElementProperty(targetEl, property, value) {
|
|||||||
targetEl.style[spec.css] = value + spec.suffix;
|
targetEl.style[spec.css] = value + spec.suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent
|
||||||
|
// d'applyElementProperty() ci-dessus pour un objet de scène (action
|
||||||
|
// "Modifier un objet de scène"), mais en PIXELS (pas de %, voir
|
||||||
|
// scene_width/scene_height) — visibilite/orientation restent identiques
|
||||||
|
// à l'éditeur document (déléguées à applyElementProperty), seule la
|
||||||
|
// position change de suffixe.
|
||||||
|
function applyObjectProperty(targetEl, property, value) {
|
||||||
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
|
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||||
|
const delta = parseFloat(value);
|
||||||
|
if (isNaN(delta)) return;
|
||||||
|
const current = parseFloat(targetEl.style[css]) || 0;
|
||||||
|
targetEl.style[css] = (current + delta) + 'px';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (property === 'pos_x' || property === 'pos_y') {
|
||||||
|
if (value === undefined || value === null) return;
|
||||||
|
targetEl.style[property === 'pos_x' ? 'left' : 'top'] = value + 'px';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyElementProperty(targetEl, property, value);
|
||||||
|
}
|
||||||
|
|
||||||
function runActionNode(node) {
|
function runActionNode(node) {
|
||||||
if (node.action_type === 'ecran_suivant') {
|
if (node.action_type === 'ecran_suivant') {
|
||||||
const idx = screenIndexById(window.currentScreenId);
|
const idx = screenIndexById(window.currentScreenId);
|
||||||
@@ -163,6 +186,15 @@ function runActionNode(node) {
|
|||||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
} else if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||||
|
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||||
|
// "modifier_element" ci-dessus, mais en pixels (voir
|
||||||
|
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||||||
|
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||||||
|
// render_scene_object.py), donc le même sélecteur suffit.
|
||||||
|
const targetEl = document.querySelector('[data-element-id="' + (node.target_element_id || node.target_object_id) + '"]');
|
||||||
|
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||||
|
return Promise.resolve();
|
||||||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||||
// signaler l'événement, jamais choisir un élément — c'est à
|
// signaler l'événement, jamais choisir un élément — c'est à
|
||||||
@@ -240,12 +272,13 @@ function runActionNode(node) {
|
|||||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||||
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} else if (node.action_type === 'jouer_animation_sprite' && node.target_element_id) {
|
} else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||||
|
const targetEl = document.querySelector('[data-element-id="' + spriteTargetId + '"]');
|
||||||
if (targetEl) {
|
if (targetEl) {
|
||||||
let spriteData = {};
|
let spriteData = {};
|
||||||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
||||||
runSpriteAnimation(targetEl, resolveSpriteFrames(node.target_element_id, spriteData));
|
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// ---------- Éditeur de scène 2D (fondations multi-éditeurs) ----------
|
||||||
|
// JS propre à templates/scene_edit.html : ajout/déplacement/redimension-
|
||||||
|
// nement d'objets de scène EN PIXELS (pas de %, voir ensure_scene_schema.py),
|
||||||
|
// mirror des fonctions équivalentes de static/js/screen_edit/tree-panels.js
|
||||||
|
// pour l'éditeur document (onElementMouseDown/onResizeMouseDown/
|
||||||
|
// saveGeometry), mais sur une scène à taille FIXE plutôt qu'un canevas
|
||||||
|
// fluide — la logique de flow elle-même (flow-editor.js, tabs-and-blocks.js,
|
||||||
|
// animation-timeline.js, personnage-panel.js, personnage-preview.js) est
|
||||||
|
// réutilisée TELLE QUELLE, chargée telle quelle par scene_edit.html.
|
||||||
|
|
||||||
|
function addSceneObject(kind, forgeCharacter) {
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'post';
|
||||||
|
form.action = "/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add";
|
||||||
|
var html = '<input type="hidden" name="kind" value="' + kind + '">';
|
||||||
|
if (forgeCharacter) html += '<input type="hidden" name="forge_character" value="' + forgeCharacter + '">';
|
||||||
|
form.innerHTML = html;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSceneObjectGeometry(id, x, y, width, height) {
|
||||||
|
return fetch("/game/" + GAME_SLUG + "/scene-objects/" + id + "/geometry", {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ x: x, y: y, width: width, height: height }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScenePosFields(x, y, w, h) {
|
||||||
|
var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y');
|
||||||
|
var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h');
|
||||||
|
if (xEl) xEl.value = Math.round(x);
|
||||||
|
if (yEl) yEl.value = Math.round(y);
|
||||||
|
if (wEl) wEl.value = Math.round(w);
|
||||||
|
if (hEl) hEl.value = Math.round(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glisser-déposer en pixels, clampé à la taille FIXE de la scène
|
||||||
|
// (SCENE_WIDTH/SCENE_HEIGHT, voir templates/scene_edit.html) — mirror
|
||||||
|
// d'onElementMouseDown() (tree-panels.js), mais sans conversion en % :
|
||||||
|
// une scène a des coordonnées pixel absolues, pas fluides.
|
||||||
|
function onSceneObjectMouseDown(e, id) {
|
||||||
|
if (e.target.classList.contains('resizeHandle')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var el = document.getElementById('el-' + id);
|
||||||
|
var startX = e.clientX, startY = e.clientY;
|
||||||
|
var startLeft = parseFloat(el.style.left) || 0, startTop = parseFloat(el.style.top) || 0;
|
||||||
|
var width = parseFloat(el.style.width) || 0, height = parseFloat(el.style.height) || 0;
|
||||||
|
var moved = false;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||||
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
|
||||||
|
var newLeft = Math.max(0, Math.min(SCENE_WIDTH - width, startLeft + dx));
|
||||||
|
var newTop = Math.max(0, Math.min(SCENE_HEIGHT - height, startTop + dy));
|
||||||
|
el.style.left = newLeft + 'px';
|
||||||
|
el.style.top = newTop + 'px';
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
if (moved) {
|
||||||
|
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
|
||||||
|
updateScenePosFields(newLeft, newTop, width, height);
|
||||||
|
saveSceneObjectGeometry(id, newLeft, newTop, width, height);
|
||||||
|
} else {
|
||||||
|
window.location.href = SCREEN_EDIT_URL + '?selected=' + id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSceneObjectResizeMouseDown(e, id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
var el = document.getElementById('el-' + id);
|
||||||
|
var startX = e.clientX, startY = e.clientY;
|
||||||
|
var startWidth = parseFloat(el.style.width) || 0, startHeight = parseFloat(el.style.height) || 0;
|
||||||
|
var left = parseFloat(el.style.left) || 0, top = parseFloat(el.style.top) || 0;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||||
|
var newWidth = Math.max(8, Math.min(SCENE_WIDTH - left, startWidth + dx));
|
||||||
|
var newHeight = Math.max(8, Math.min(SCENE_HEIGHT - top, startHeight + dy));
|
||||||
|
el.style.width = newWidth + 'px';
|
||||||
|
el.style.height = newHeight + 'px';
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
|
||||||
|
updateScenePosFields(left, top, newWidth, newHeight);
|
||||||
|
saveSceneObjectGeometry(id, left, top, newWidth, newHeight);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs numériques Position & taille du panneau de propriétés — mirror
|
||||||
|
// du comportement équivalent pour l'éditeur document (voir l'IIFE dans
|
||||||
|
// initBuilderPanel(), panel-init.js), mais ids/route distincts (pixels).
|
||||||
|
(function () {
|
||||||
|
var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y');
|
||||||
|
var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h');
|
||||||
|
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
||||||
|
function apply() {
|
||||||
|
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
|
||||||
|
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
|
||||||
|
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
|
||||||
|
if (canvasEl) {
|
||||||
|
canvasEl.style.left = x + 'px'; canvasEl.style.top = y + 'px';
|
||||||
|
canvasEl.style.width = w + 'px'; canvasEl.style.height = h + 'px';
|
||||||
|
}
|
||||||
|
saveSceneObjectGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||||||
|
}
|
||||||
|
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Personnage d'un objet de scène : mirror de swapPersonnageCharacter()
|
||||||
|
// (personnage-panel.js), mais POST vers /scene-objects/.../personnage
|
||||||
|
// (voir routes/scenes/scene_object_personnage_data.py) plutôt que
|
||||||
|
// /elements/.../personnage — même réponse (animations résolues),
|
||||||
|
// relance l'aperçu animé via restartPersonnageIdlePreview() (personnage-
|
||||||
|
// preview.js, déjà générique : ne dépend que de data-element-id/
|
||||||
|
// data-personnage, présents sur un objet de scène comme sur un élément).
|
||||||
|
function swapSceneObjectCharacter(forgeCharacter) {
|
||||||
|
var body = document.getElementById('personnagePanelBody');
|
||||||
|
if (!body) return;
|
||||||
|
var objectId = parseInt(body.dataset.elementId, 10);
|
||||||
|
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) {
|
||||||
|
tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapSceneObjectCharacter('" + forgeCharacter + "')");
|
||||||
|
});
|
||||||
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/personnage', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source: 'forge', forge_character: forgeCharacter }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (json) {
|
||||||
|
if (json.animations) {
|
||||||
|
ELEMENT_ANIMATIONS_MAP[objectId] = json.animations;
|
||||||
|
restartPersonnageIdlePreview(objectId, json.animations);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@
|
|||||||
// Jinja) — voir static/js/play/ pour le même patron déjà en place côté
|
// Jinja) — voir static/js/play/ pour le même patron déjà en place côté
|
||||||
// moteur de jeu.
|
// moteur de jeu.
|
||||||
var FLOW_SCREEN_ID = SCREEN_ID;
|
var FLOW_SCREEN_ID = SCREEN_ID;
|
||||||
|
// Fondations multi-éditeurs — ce même fichier (moteur de logique de flow
|
||||||
|
// partagé, voir le plan "Fondations d'une plateforme multi-éditeurs")
|
||||||
|
// sert aussi bien à l'éditeur document (cible trigger_element_id/
|
||||||
|
// target_element_id, vers _screen_elements) qu'à l'éditeur de scène 2D
|
||||||
|
// (cible trigger_object_id/target_object_id, vers _scene_objects) : posé
|
||||||
|
// à `true` par templates/scene_edit.html, sinon absent (donc falsy) sur
|
||||||
|
// templates/screen_edit.html — décide quelle paire de colonnes remplir à
|
||||||
|
// l'enregistrement d'un nœud (voir submitNodeForm()).
|
||||||
|
var FLOW_TARGETS_OBJECTS = typeof FLOW_TARGETS_OBJECTS !== 'undefined' ? FLOW_TARGETS_OBJECTS : false;
|
||||||
|
var FLOW_TRIGGER_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'trigger_object_id' : 'trigger_element_id';
|
||||||
|
var FLOW_TARGET_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'target_object_id' : 'target_element_id';
|
||||||
// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les
|
// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les
|
||||||
// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ;
|
// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ;
|
||||||
// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas
|
// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas
|
||||||
@@ -44,7 +55,7 @@ function nodeLabel(node) {
|
|||||||
return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms';
|
return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms';
|
||||||
}
|
}
|
||||||
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
|
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
|
||||||
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
|
(ELEMENTS_LABELS[node.trigger_element_id || node.trigger_object_id] || '?');
|
||||||
}
|
}
|
||||||
if (node.node_type === 'condition') {
|
if (node.node_type === 'condition') {
|
||||||
var clauseSubject = function(c, field, variable) {
|
var clauseSubject = function(c, field, variable) {
|
||||||
@@ -83,6 +94,18 @@ function nodeLabel(node) {
|
|||||||
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
|
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
|
||||||
} else if (node.action_type === 'activer_onglet') {
|
} else if (node.action_type === 'activer_onglet') {
|
||||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
|
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
|
||||||
|
} else if (node.action_type === 'modifier_objet_scene') {
|
||||||
|
// Fondations multi-éditeurs — équivalent scène de "modifier_element"
|
||||||
|
// ci-dessous, palette réduite (visibilité/position en pixels/
|
||||||
|
// orientation seulement).
|
||||||
|
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
|
||||||
|
if (node.element_property === 'visibilite') {
|
||||||
|
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
|
||||||
|
} else if (node.element_property === 'orientation') {
|
||||||
|
base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value);
|
||||||
|
} else {
|
||||||
|
base += ' = ' + node.element_value;
|
||||||
|
}
|
||||||
} else if (node.action_type === 'modifier_element') {
|
} else if (node.action_type === 'modifier_element') {
|
||||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
|
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
|
||||||
if (node.element_property === 'visibilite') {
|
if (node.element_property === 'visibilite') {
|
||||||
@@ -118,7 +141,7 @@ function nodeLabel(node) {
|
|||||||
} else if (node.action_type === 'jouer_son') {
|
} else if (node.action_type === 'jouer_son') {
|
||||||
base += ' « ' + (node.data_value || '?') + ' »';
|
base += ' « ' + (node.data_value || '?') + ' »';
|
||||||
} else if (node.action_type === 'jouer_animation_sprite') {
|
} else if (node.action_type === 'jouer_animation_sprite') {
|
||||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?');
|
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?');
|
||||||
try {
|
try {
|
||||||
var spriteData = JSON.parse(node.data_value || '{}');
|
var spriteData = JSON.parse(node.data_value || '{}');
|
||||||
base += ' « ' + (spriteData.animation || '?') + ' » (' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')';
|
base += ' « ' + (spriteData.animation || '?') + ' » (' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')';
|
||||||
@@ -502,6 +525,7 @@ function toggleFlowActionFields() {
|
|||||||
var typeSel = document.getElementById('nf-action-type');
|
var typeSel = document.getElementById('nf-action-type');
|
||||||
var screenSel = document.getElementById('nf-action-target-screen');
|
var screenSel = document.getElementById('nf-action-target-screen');
|
||||||
var elementFields = document.getElementById('nf-action-element-fields');
|
var elementFields = document.getElementById('nf-action-element-fields');
|
||||||
|
var objectFields = document.getElementById('nf-action-object-fields');
|
||||||
var dataFields = document.getElementById('nf-action-data-fields');
|
var dataFields = document.getElementById('nf-action-data-fields');
|
||||||
var variableFields = document.getElementById('nf-action-variable-fields');
|
var variableFields = document.getElementById('nf-action-variable-fields');
|
||||||
var waitFields = document.getElementById('nf-action-wait-fields');
|
var waitFields = document.getElementById('nf-action-wait-fields');
|
||||||
@@ -510,24 +534,53 @@ function toggleFlowActionFields() {
|
|||||||
var soundFields = document.getElementById('nf-action-sound-fields');
|
var soundFields = document.getElementById('nf-action-sound-fields');
|
||||||
var spriteFields = document.getElementById('nf-action-sprite-fields');
|
var spriteFields = document.getElementById('nf-action-sprite-fields');
|
||||||
var isOnglet = typeSel.value === 'activer_onglet';
|
var isOnglet = typeSel.value === 'activer_onglet';
|
||||||
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
|
// Champs propres à l'éditeur document ci-dessous (nf-action-element-*/
|
||||||
spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? 'block' : 'none';
|
// -onglet-*) : absents du DOM de l'éditeur de scène (templates/
|
||||||
|
// scene_edit.html), d'où les gardes "if (x)" partout — même
|
||||||
|
// #nodeForm/flow-editor.js réutilisé pour les deux éditeurs (voir
|
||||||
|
// FLOW_TARGETS_OBJECTS en tête de ce fichier).
|
||||||
|
if (soundFields) soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
|
||||||
|
if (spriteFields) spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? 'block' : 'none';
|
||||||
if (typeSel.value === 'jouer_animation_sprite') onSpriteTargetElementChange();
|
if (typeSel.value === 'jouer_animation_sprite') onSpriteTargetElementChange();
|
||||||
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
if (screenSel) screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
||||||
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
if (elementFields) elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
||||||
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
|
if (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none';
|
||||||
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
|
if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
|
||||||
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
|
if (variableFields) variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
|
||||||
|
if (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
|
||||||
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
|
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
|
||||||
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none';
|
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none';
|
||||||
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
|
var ongletHint = document.getElementById('nf-action-onglet-hint');
|
||||||
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
|
if (ongletHint) ongletHint.style.display = isOnglet ? 'block' : 'none';
|
||||||
|
var elementPropertyWrap = document.getElementById('nf-action-element-property-wrap');
|
||||||
|
if (elementPropertyWrap) elementPropertyWrap.style.display = isOnglet ? 'none' : 'block';
|
||||||
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
|
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
|
||||||
|
if (typeSel.value === 'modifier_objet_scene') toggleFlowObjectPropertyValue();
|
||||||
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
|
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
|
||||||
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
|
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
|
||||||
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value');
|
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue()
|
||||||
|
// pour l'action "Modifier un objet de scène" (palette réduite : pas de
|
||||||
|
// contenu/couleur/largeur/hauteur, juste visibilité/position en pixels/
|
||||||
|
// orientation — voir screens/scenes/flow_palette.py OBJECT_ACTION_PROPERTIES).
|
||||||
|
function toggleFlowObjectPropertyValue() {
|
||||||
|
var propSel = document.getElementById('nf-action-object-property');
|
||||||
|
if (!propSel) return;
|
||||||
|
var visSel = document.getElementById('nf-action-object-value-visibility');
|
||||||
|
var orientSel = document.getElementById('nf-action-object-value-orientation');
|
||||||
|
var numInp = document.getElementById('nf-action-object-value-number');
|
||||||
|
[visSel, orientSel, numInp].forEach(function(el) { if (el) el.style.display = 'none'; });
|
||||||
|
if (propSel.value === 'visibilite' && visSel) {
|
||||||
|
visSel.style.display = 'block';
|
||||||
|
} else if (propSel.value === 'orientation' && orientSel) {
|
||||||
|
orientSel.style.display = 'block';
|
||||||
|
} else if (numInp) {
|
||||||
|
numInp.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir
|
// "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir
|
||||||
// screens/data_actions/compute_operation.py) plutôt qu'un seul nombre
|
// screens/data_actions/compute_operation.py) plutôt qu'un seul nombre
|
||||||
// comme les autres opérations — indice visuel dans le champ plutôt que
|
// comme les autres opérations — indice visuel dans le champ plutôt que
|
||||||
@@ -635,7 +688,7 @@ function submitNodeForm() {
|
|||||||
} else if (payload.trigger_event === 'minuteur') {
|
} else if (payload.trigger_event === 'minuteur') {
|
||||||
payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10);
|
payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10);
|
||||||
} else if (payload.trigger_event !== 'affichage') {
|
} else if (payload.trigger_event !== 'affichage') {
|
||||||
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
|
payload[FLOW_TRIGGER_ID_FIELD] = parseInt(document.getElementById('nf-trigger-element').value, 10);
|
||||||
}
|
}
|
||||||
} else if (currentNodeFormType === 'condition') {
|
} else if (currentNodeFormType === 'condition') {
|
||||||
var condSource = document.getElementById('nf-cond-source').value;
|
var condSource = document.getElementById('nf-cond-source').value;
|
||||||
@@ -717,6 +770,22 @@ function submitNodeForm() {
|
|||||||
payload.element_value = v1;
|
payload.element_value = v1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (actionType === 'modifier_objet_scene') {
|
||||||
|
// Fondations multi-éditeurs — équivalent scène de "modifier_element"
|
||||||
|
// ci-dessus, palette réduite (voir OBJECT_ACTION_PROPERTIES) : pas
|
||||||
|
// de va-et-vient/couleur, juste visibilité/position en pixels/
|
||||||
|
// orientation. FLOW_TARGET_ID_FIELD vaut "target_object_id" ici
|
||||||
|
// (voir tête de fichier).
|
||||||
|
payload[FLOW_TARGET_ID_FIELD] = parseInt(document.getElementById('nf-action-target-object').value, 10);
|
||||||
|
var objProp = document.getElementById('nf-action-object-property').value;
|
||||||
|
payload.element_property = objProp;
|
||||||
|
if (objProp === 'visibilite') {
|
||||||
|
payload.element_value = document.getElementById('nf-action-object-value-visibility').value;
|
||||||
|
} else if (objProp === 'orientation') {
|
||||||
|
payload.element_value = document.getElementById('nf-action-object-value-orientation').value;
|
||||||
|
} else {
|
||||||
|
payload.element_value = document.getElementById('nf-action-object-value-number').value;
|
||||||
|
}
|
||||||
} else if (actionType === 'modifier_donnee') {
|
} else if (actionType === 'modifier_donnee') {
|
||||||
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
|
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
|
||||||
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
|
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
|
||||||
@@ -754,7 +823,7 @@ function submitNodeForm() {
|
|||||||
// logique, qui continuaient à jouer les frames de l'ancien
|
// logique, qui continuaient à jouer les frames de l'ancien
|
||||||
// personnage indéfiniment (bug signalé par l'utilisateur).
|
// personnage indéfiniment (bug signalé par l'utilisateur).
|
||||||
var elementId = document.getElementById('nf-action-sprite-target-element').value;
|
var elementId = document.getElementById('nf-action-sprite-target-element').value;
|
||||||
payload.target_element_id = parseInt(elementId, 10);
|
payload[FLOW_TARGET_ID_FIELD] = parseInt(elementId, 10);
|
||||||
payload.data_value = JSON.stringify({
|
payload.data_value = JSON.stringify({
|
||||||
animation: document.getElementById('nf-action-sprite-animation-name').value,
|
animation: document.getElementById('nf-action-sprite-animation-name').value,
|
||||||
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
|
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
|
||||||
|
|||||||
@@ -27,10 +27,20 @@
|
|||||||
}
|
}
|
||||||
#playFrame{
|
#playFrame{
|
||||||
position:relative; overflow:hidden; background:#0b0d12;
|
position:relative; overflow:hidden; background:#0b0d12;
|
||||||
|
{% if game.game_type == 'jeu_2d' %}
|
||||||
|
/* Fondations multi-éditeurs — une scène jeu_2d a une taille FIXE en
|
||||||
|
pixels (voir scene_width/scene_height, ensure_schema.py), pas un
|
||||||
|
ratio fluide : #playFrame remplit simplement tout le viewport, la
|
||||||
|
scène elle-même se centre dedans (voir .playScreen ci-dessous et
|
||||||
|
la boucle d'écrans jeu_2d plus bas). */
|
||||||
|
width:100vw; height:100vh;
|
||||||
|
{% else %}
|
||||||
width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16)));
|
width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16)));
|
||||||
height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9)));
|
height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9)));
|
||||||
|
{% endif %}
|
||||||
}
|
}
|
||||||
.playScreen{ position:absolute; inset:0; display:none; }
|
.playScreen{ position:absolute; inset:0; display:none; }
|
||||||
|
.playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; }
|
||||||
.playElement{
|
.playElement{
|
||||||
position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif;
|
position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif;
|
||||||
}
|
}
|
||||||
@@ -60,6 +70,17 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="playRoot">
|
<div id="playRoot">
|
||||||
<div id="playFrame">
|
<div id="playFrame">
|
||||||
|
{% if game.game_type == 'jeu_2d' %}
|
||||||
|
<!-- Fondations multi-éditeurs — scène jeu_2d : objets déjà entièrement
|
||||||
|
positionnés en pixels absolus par render_scene_object.py (voir
|
||||||
|
screens/scenes/), pas de wrapper .playElement/elstyle (spécifiques
|
||||||
|
au %/DOM de l'éditeur document) : juste le HTML tel quel. -->
|
||||||
|
{% for s in screens_data %}
|
||||||
|
<div class="playScreen playScene" data-screen-id="{{ s.id }}" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
|
||||||
|
{% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
{% for s in screens_data %}
|
{% for s in screens_data %}
|
||||||
<div class="playScreen" data-screen-id="{{ s.id }}" data-aspect="{{ s.preview_aspect or '9/16' }}">
|
<div class="playScreen" data-screen-id="{{ s.id }}" data-aspect="{{ s.preview_aspect or '9/16' }}">
|
||||||
{% for el in s.elements if not el.parent_id %}
|
{% for el in s.elements if not el.parent_id %}
|
||||||
@@ -69,6 +90,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% if not screens_data %}
|
{% if not screens_data %}
|
||||||
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
|
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %}
|
||||||
|
{% block content_class %}content-builder{% endblock %}
|
||||||
|
{% block body_class %}builderBody{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<!-- Fondations multi-éditeurs — éditeur de scène 2D (jeux "jeu_2d", voir
|
||||||
|
db/games/get_game_type.py). Réutilise TEL QUEL le moteur de logique de
|
||||||
|
flow partagé (mêmes JS : flow-editor.js/tabs-and-blocks.js/
|
||||||
|
animation-timeline.js) — seule la palette de nœuds et le panneau
|
||||||
|
gauche/canevas diffèrent de templates/screen_edit.html. Voir le plan
|
||||||
|
"Fondations d'une plateforme multi-éditeurs". -->
|
||||||
|
<p class="hint" style="background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:10px 14px; margin:10px 0;">
|
||||||
|
🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="builder3" id="builder3">
|
||||||
|
{% set obj = objects|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
||||||
|
|
||||||
|
<button type="button" id="panelLeftReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openLeftPanel()" title="Rouvrir le panneau des objets">🧩 Objets</button>
|
||||||
|
<div class="floatPanel floatPanel--left" id="panelLeftFloat">
|
||||||
|
<div class="floatPanelHeader" data-drag-panel="panelLeftFloat" data-drag-key="left">
|
||||||
|
<span class="floatPanelTitle">⋮⋮ Objets de cette scène</span>
|
||||||
|
<button type="button" class="button is-small" onclick="closeLeftPanel()" title="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelBody">
|
||||||
|
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="field has-addons" style="align-items:center; margin-bottom:8px;">
|
||||||
|
<div class="control is-expanded"><input class="input is-small" type="text" name="name" value="{{ screen.name }}" required></div>
|
||||||
|
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
|
||||||
|
</form>
|
||||||
|
<a class="button is-small is-primary is-fullwidth mb-2" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
|
||||||
|
<hr style="border-color:var(--border); margin:10px 0;">
|
||||||
|
|
||||||
|
<h2 class="subtitle is-6">Objets de cette scène</h2>
|
||||||
|
{% if objects %}
|
||||||
|
<div class="elementTree">
|
||||||
|
{% for o in objects %}
|
||||||
|
<div class="treeNode" data-element-id="{{ o.id }}">
|
||||||
|
<a class="treeNodeLabel {{ 'selected' if o.id == selected_id else '' }}" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=o.id) }}">
|
||||||
|
{{ '🏃' if o.kind == 'personnage' else '🖼️' }} <span class="treeLabel">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aucun objet pour l'instant.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
|
||||||
|
tuile pose un personnage déjà configuré, animé immédiatement (voir
|
||||||
|
personnage-preview.js, réutilisé tel quel : ne dépend que de
|
||||||
|
data-personnage, pas de la table d'origine). -->
|
||||||
|
<details class="propGroup" open>
|
||||||
|
<summary>🎭 Personnages</summary>
|
||||||
|
<div class="propGroupBody">
|
||||||
|
<div class="iconGallery">
|
||||||
|
{% for slug_c, character in sprite_library.items() %}
|
||||||
|
<button type="button" class="iconGalleryTile" title="{{ character.label }}" onclick="addSceneObject('personnage', '{{ slug_c }}')">
|
||||||
|
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="help">Personnage Kenney prêt à l'emploi (licence libre CC0), avec toutes ses animations.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('decor')">🖼️ + Ajouter un décor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builderCanvasArea">
|
||||||
|
<div class="builderTabs" id="builderTabs">
|
||||||
|
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button>
|
||||||
|
<button type="button" class="builderTabBtn" data-tab="blocks" onclick="switchBuilderTab('blocks')">🧩 Blocs de logique</button>
|
||||||
|
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
||||||
|
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builderTabPanel active" data-tab-panel="screen">
|
||||||
|
<div class="canvasFrame">
|
||||||
|
<div class="canvas" id="canvas" style="width:{{ screen.scene_width }}px; height:{{ screen.scene_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;">
|
||||||
|
{% for o in objects %}
|
||||||
|
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }}" id="el-{{ o.id }}"
|
||||||
|
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
||||||
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
||||||
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builderTabPanel" id="blocksTabPanel" data-tab-panel="blocks">
|
||||||
|
<div class="dashPanelHeader">
|
||||||
|
<h2>Blocs de logique</h2>
|
||||||
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('blocks')">+ Créer un bloc</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('flow_block_new', slug=game.slug, screen_id=screen.id) }}" class="dashCreateBar" id="dashCreate-blocks" style="display:none;">
|
||||||
|
<input type="text" name="name" placeholder="Nom du bloc (ex. Ouvrir la porte)" required autocomplete="off">
|
||||||
|
<input type="text" name="description" placeholder="Description (optionnel)">
|
||||||
|
<button type="submit" class="primary">Créer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if blocks_view %}
|
||||||
|
{% for b in blocks_view %}
|
||||||
|
<form method="post" action="{{ url_for('flow_block_edit', slug=game.slug, screen_id=screen.id, block_id=b.id) }}" id="blockEditForm{{ b.id }}"></form>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="fieldsTableWrap">
|
||||||
|
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||||
|
<thead><tr><th>Nom</th><th>Description</th><th>Objets concernés</th><th>Nœuds</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in blocks_view %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ b.name }}</strong></td>
|
||||||
|
<td>{{ b.description|truncatesentences(3) }}</td>
|
||||||
|
<td>{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}<span class="hint">—</span>{% endif %}</td>
|
||||||
|
<td>{{ b.node_count }}</td>
|
||||||
|
<td class="fieldsTableActions">
|
||||||
|
<button type="button" class="button is-small is-primary" onclick="openLogicBlockPanel({{ b.id }})" title="Ouvrir le graphe de ce bloc">🧩 Ouvrir</button>
|
||||||
|
<button type="button" class="button is-small" onclick="toggleBlockEditRow({{ b.id }})" title="Modifier le nom/la description">✏️</button>
|
||||||
|
<form method="post" action="{{ url_for('flow_block_delete', slug=game.slug, screen_id=screen.id, block_id=b.id) }}"
|
||||||
|
onsubmit="return confirm('Supprimer le bloc « {{ b.name }} » ? Cela supprime aussi ses {{ b.node_count }} nœud(s) et leurs connexions.');" style="display:inline;">
|
||||||
|
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hidden" id="blockEditRow{{ b.id }}">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="dashCreateBar" style="display:flex;">
|
||||||
|
<input type="text" form="blockEditForm{{ b.id }}" name="name" value="{{ b.name }}" required>
|
||||||
|
<input type="text" form="blockEditForm{{ b.id }}" name="description" value="{{ b.description or '' }}" placeholder="Description">
|
||||||
|
<button type="submit" form="blockEditForm{{ b.id }}" class="primary">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="floatPanel floatPanel--center hidden" id="logicBlockPanel">
|
||||||
|
<div class="floatPanelHeader" id="logicBlockPanelHeader">
|
||||||
|
<span class="floatPanelTitle">⋮⋮ <span id="logicBlockPanelTitle">Bloc</span></span>
|
||||||
|
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
|
||||||
|
<button type="button" class="button is-small" onclick="closeLogicBlockPanel()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelBody logicBlockPanelBody">
|
||||||
|
<div class="flowToolbar">
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
|
||||||
|
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nodeForm" class="nodeForm" style="display:none;">
|
||||||
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
|
<strong id="nodeFormTitle">Nouveau nœud</strong>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nodeFormTrigger" style="display:none;">
|
||||||
|
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
|
||||||
|
{% for key, label in trigger_events %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label id="nf-trigger-element-row" style="display:none;">Objet<select id="nf-trigger-element">
|
||||||
|
{% for o in objects %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
|
||||||
|
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
|
||||||
|
<div id="nf-trigger-event-row" style="display:none;">
|
||||||
|
<label>Événement personnalisé<select id="nf-trigger-custom-event">
|
||||||
|
{% for ev in custom_events %}
|
||||||
|
<option value="{{ ev.id }}">{{ ev.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
{% if not custom_events %}
|
||||||
|
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="nf-trigger-key-row" style="display:none;">
|
||||||
|
<label>Touche à surveiller
|
||||||
|
<input type="text" id="nf-trigger-key-display" readonly placeholder="Clique ici puis appuie sur une touche" onkeydown="captureFlowTriggerKey(event)">
|
||||||
|
</label>
|
||||||
|
<input type="hidden" id="nf-trigger-key">
|
||||||
|
</div>
|
||||||
|
<div id="nf-trigger-interval-row" style="display:none;">
|
||||||
|
<label>Toutes les... millisecondes
|
||||||
|
<input type="number" id="nf-trigger-interval" min="50" step="50" value="1000" placeholder="ex. 1000">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nodeFormCondition" style="display:none;">
|
||||||
|
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
|
||||||
|
<option value="objet">Champ d'un objet de données</option>
|
||||||
|
<option value="variable">Variable globale</option>
|
||||||
|
<option value="collision">Collision entre deux objets de scène</option>
|
||||||
|
</select></label>
|
||||||
|
|
||||||
|
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
|
||||||
|
<label>Objet de données<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
|
||||||
|
{% for d in definitions %}
|
||||||
|
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label>Ligne concernée<select id="nf-cond-row"></select></label>
|
||||||
|
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
|
||||||
|
</div>
|
||||||
|
{% if not definitions %}
|
||||||
|
<p class="hint" id="nf-cond-no-objet-hint">Aucun objet de données 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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="nf-cond-variable-fields" style="display:none;">
|
||||||
|
<label>Variable<select id="nf-cond-variable">
|
||||||
|
<option value="">(sélectionner une variable)</option>
|
||||||
|
{% for v in global_variables %}
|
||||||
|
<option value="{{ v.name }}">{{ v.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label>Chemin (optionnel)<input type="text" id="nf-cond-variable-chemin" placeholder="ex. .nom ou [0].valeur"></label>
|
||||||
|
{% if not global_variables %}
|
||||||
|
<p class="hint">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 id="nf-cond-collision-fields" style="display:none;">
|
||||||
|
<label>Premier objet<select id="nf-cond-element-a">
|
||||||
|
{% for o in objects %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label>Deuxième objet<select id="nf-cond-element-b">
|
||||||
|
{% for o in objects %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<p class="hint">Vrai si les deux objets se touchent (leurs rectangles se chevauchent à l'écran) — relie le port "Faux" du nœud pour "ne se touchent pas".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-cond-operator-value-row">
|
||||||
|
<label>Condition<select id="nf-cond-operator">
|
||||||
|
{% for key, label in condition_operators %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-cond-extra-wrap">
|
||||||
|
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
|
||||||
|
<select id="nf-cond-combinator">
|
||||||
|
<option value="et">ET (toutes les conditions doivent être vraies)</option>
|
||||||
|
<option value="ou">OU (au moins une condition doit être vraie)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div id="nf-cond-extra-clauses"></div>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nodeFormAction" style="display:none;">
|
||||||
|
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
|
||||||
|
{% for key, label in action_type_labels.items() %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
|
||||||
|
<select id="nf-action-target-screen" style="display:none;">
|
||||||
|
{% for s in all_screens %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Équivalent scène de "Modifier un élément" — palette réduite
|
||||||
|
(visibilité/position en pixels/orientation), voir
|
||||||
|
OBJECT_ACTION_PROPERTIES (screens/scenes/flow_palette.py). -->
|
||||||
|
<div id="nf-action-object-fields" style="display:none;">
|
||||||
|
<select id="nf-action-target-object">
|
||||||
|
{% for o in objects %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="nf-action-object-property" onchange="toggleFlowObjectPropertyValue()" style="margin-top:6px;">
|
||||||
|
{% for key, label in object_action_properties %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="nf-action-object-value-visibility" style="margin-top:6px;">
|
||||||
|
{% for key, label in element_visibility_values %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="nf-action-object-value-orientation" style="display:none; margin-top:6px;">
|
||||||
|
{% for key, label in element_orientation_values %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="number" id="nf-action-object-value-number" style="display:none; margin-top:6px;" placeholder="Nouvelle valeur en pixels">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-wait-fields" style="display:none;">
|
||||||
|
<label>Délai en secondes avant de continuer
|
||||||
|
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-data-fields" style="display:none;">
|
||||||
|
{% if not definitions %}
|
||||||
|
<p class="hint">Aucun objet de données 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 %}
|
||||||
|
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="nf-action-data-row" style="margin-top:6px;"></select>
|
||||||
|
<select id="nf-action-data-field" style="margin-top:6px;"></select>
|
||||||
|
<select id="nf-action-data-operation" style="margin-top:6px;" onchange="updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value')">
|
||||||
|
{% for key, label in data_operations %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-sound-fields" style="display:none;">
|
||||||
|
<label>URL du son
|
||||||
|
<input type="text" id="nf-action-sound-url" placeholder="URL du fichier audio">
|
||||||
|
</label>
|
||||||
|
<div style="margin-top:6px;">
|
||||||
|
<label for="nf-action-sound-upload" style="font-size:12px;">Envoyer un fichier :</label>
|
||||||
|
<input type="file" class="nodeFormUploadInput" id="nf-action-sound-upload" data-target="nf-action-sound-url" accept="audio/*" style="font-size:12px;">
|
||||||
|
<span class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-sprite-fields" style="display:none;">
|
||||||
|
<label>Personnage à animer
|
||||||
|
<select id="nf-action-sprite-target-element" onchange="onSpriteTargetElementChange()">
|
||||||
|
<option value="">— Choisir —</option>
|
||||||
|
{% for o in objects if o.kind == 'personnage' %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or 'Personnage' }} (obj-{{ o.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% if not objects|selectattr('kind', 'equalto', 'personnage')|list %}
|
||||||
|
<p class="hint">Aucun personnage sur cette scène — ajoute-en un depuis la galerie "🎭 Personnages" du panneau de gauche.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div id="nf-action-sprite-animation-grid" class="iconGallery" style="margin-top:6px;"></div>
|
||||||
|
<input type="hidden" id="nf-action-sprite-animation-name" value="">
|
||||||
|
<div class="fieldRow" style="margin-top:6px;">
|
||||||
|
<label style="flex:1;">Images/seconde
|
||||||
|
<input type="number" id="nf-action-sprite-fps" min="1" step="1" value="8">
|
||||||
|
</label>
|
||||||
|
<label style="flex:1; display:flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" id="nf-action-sprite-loop" checked style="width:auto;"> En boucle
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-variable-fields" style="display:none;">
|
||||||
|
{% if not global_variables %}
|
||||||
|
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis le tableau de bord.</p>
|
||||||
|
{% else %}
|
||||||
|
<select id="nf-action-variable-name">
|
||||||
|
{% for v in global_variables %}
|
||||||
|
<option value="{{ v.name }}">{{ v.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="nf-action-variable-operation" style="margin-top:6px;" onchange="updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value')">
|
||||||
|
{% for key, label in data_operations %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-action-event-fields" style="display:none;">
|
||||||
|
{% if not custom_events %}
|
||||||
|
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<select id="nf-action-custom-event">
|
||||||
|
{% for ev in custom_events %}
|
||||||
|
<option value="{{ ev.id }}">{{ ev.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flowCanvasWrap">
|
||||||
|
<svg id="flowEdges" class="flowEdgesSvg"></svg>
|
||||||
|
<div id="flowCanvas" class="flowCanvas"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelResizeHandle" id="logicBlockPanelResizeHandle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
|
||||||
|
<div class="flowToolbar">
|
||||||
|
<label style="display:flex; align-items:center; gap:6px;">
|
||||||
|
Piste
|
||||||
|
<select id="anim-new-track-element">
|
||||||
|
{% for o in objects %}
|
||||||
|
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="animClipForm" class="nodeForm" style="display:none;">
|
||||||
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
|
<strong id="animClipFormTitle">Clip d'animation</strong>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Type
|
||||||
|
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
|
||||||
|
<option value="animate_css">Animation Animate.css</option>
|
||||||
|
<option value="custom">Animation personnalisée — images-clés</option>
|
||||||
|
<option value="sprite">Animation de sprites</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="anim-clip-animate-css-fields">
|
||||||
|
<label>Animation
|
||||||
|
<select id="anim-clip-animate-name">
|
||||||
|
{% for category, items in animate_css_catalog %}
|
||||||
|
<optgroup label="{{ category }}">
|
||||||
|
{% for key, label in items %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="anim-clip-custom-fields" style="display:none;">
|
||||||
|
<div id="anim-clip-keyframes"></div>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="anim-clip-sprite-fields" style="display:none;">
|
||||||
|
<p class="hint" id="anim-clip-sprite-no-animations" style="display:none;">Cet objet n'est pas un personnage, ou n'a encore aucune animation.</p>
|
||||||
|
<div id="anim-clip-sprite-animation-grid" class="iconGallery"></div>
|
||||||
|
<input type="hidden" id="anim-clip-sprite-animation-name" value="">
|
||||||
|
<label style="margin-top:6px; display:block;">Images/seconde
|
||||||
|
<input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fieldRow">
|
||||||
|
<label style="flex:1;">Départ en secondes
|
||||||
|
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
|
||||||
|
</label>
|
||||||
|
<label style="flex:1;">Durée en secondes
|
||||||
|
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="fieldRow">
|
||||||
|
<label style="flex:1;">Délai en secondes
|
||||||
|
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
|
||||||
|
</label>
|
||||||
|
<label style="flex:1;">Vitesse
|
||||||
|
<select id="anim-clip-easing">
|
||||||
|
<option value="ease">Progressive</option>
|
||||||
|
<option value="linear">Constante</option>
|
||||||
|
<option value="ease-in">Accélère</option>
|
||||||
|
<option value="ease-out">Ralentit</option>
|
||||||
|
<option value="ease-in-out">Accélère puis ralentit</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Répétitions
|
||||||
|
<select id="anim-clip-iteration">
|
||||||
|
<option value="1">Une fois</option>
|
||||||
|
<option value="2">2 fois</option>
|
||||||
|
<option value="3">3 fois</option>
|
||||||
|
<option value="infinite">En boucle</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actionsRow">
|
||||||
|
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
|
||||||
|
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
|
||||||
|
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animTimelineWrap">
|
||||||
|
<div id="animRuler" class="animRuler"></div>
|
||||||
|
<div id="animTracks" class="animTracks"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
|
||||||
|
<div class="dashPanelHeader">
|
||||||
|
<h2>Événements personnalisés</h2>
|
||||||
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('events')">+ Créer un événement</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('custom_event_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-events" style="display:none;">
|
||||||
|
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||||
|
<input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off">
|
||||||
|
<input type="text" name="description" placeholder="Description (optionnel)">
|
||||||
|
<button type="submit" class="primary">Créer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if custom_events %}
|
||||||
|
{% for ev in custom_events %}
|
||||||
|
<form method="post" action="{{ url_for('custom_event_edit', slug=game.slug, event_id=ev.id) }}" id="eventEditForm{{ ev.id }}">
|
||||||
|
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="fieldsTableWrap">
|
||||||
|
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||||
|
<thead><tr><th>Nom</th><th>Description</th><th>Utilisé par</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ev in custom_events %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ ev.name }}</code></td>
|
||||||
|
<td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td>
|
||||||
|
<td>
|
||||||
|
{% set usages = custom_event_usages.get(ev.id, []) %}
|
||||||
|
{% if usages %}
|
||||||
|
{% for u in usages %}
|
||||||
|
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=u.screen_id) }}" class="linkLikeButton" title="{{ 'Écouté ici' if u.role == 'ecoute' else 'Déclenché ici' }}">{{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}</a>{{ ', ' if not loop.last else '' }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="hint">Inutilisé</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="fieldsTableActions">
|
||||||
|
<button type="submit" form="eventEditForm{{ ev.id }}" class="button is-small" title="Enregistrer">💾</button>
|
||||||
|
<form method="post" action="{{ url_for('custom_event_delete', slug=game.slug, event_id=ev.id) }}"
|
||||||
|
onsubmit="return confirm('Supprimer l\'événement « {{ ev.name }} » ? Cela supprime aussi tous ses écouteurs et déclencheurs, sur toutes les scènes et tous les modèles.');">
|
||||||
|
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||||
|
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau droit : propriétés de l'objet sélectionné. -->
|
||||||
|
<button type="button" id="propsPanelReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openPropsPanel()" title="Rouvrir le panneau de propriétés">⚙️ Propriétés</button>
|
||||||
|
<div class="floatPanel floatPanel--right" id="propsPanelFloat">
|
||||||
|
<div class="floatPanelHeader" data-drag-panel="propsPanelFloat" data-drag-key="props">
|
||||||
|
<span class="floatPanelTitle">⋮⋮ Propriétés</span>
|
||||||
|
<button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelBody">
|
||||||
|
{% if not selected_id or not obj %}
|
||||||
|
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
|
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else '🖼️' }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else 'Décor') }}</h2>
|
||||||
|
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
|
||||||
|
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="propGroup" open>
|
||||||
|
<summary>Position & taille (pixels)</summary>
|
||||||
|
<div class="propGroupBody">
|
||||||
|
<div class="columns is-multiline posGrid">
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">X (px)</label>
|
||||||
|
<div class="control"><input class="input is-small" type="number" id="scene-pos-x" min="0" value="{{ obj.x }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Y (px)</label>
|
||||||
|
<div class="control"><input class="input is-small" type="number" id="scene-pos-y" min="0" value="{{ obj.y }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Largeur (px)</label>
|
||||||
|
<div class="control"><input class="input is-small" type="number" id="scene-pos-w" min="1" value="{{ obj.width }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Hauteur (px)</label>
|
||||||
|
<div class="control"><input class="input is-small" type="number" id="scene-pos-h" min="1" value="{{ obj.height }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if obj.kind == 'personnage' %}
|
||||||
|
<details class="propGroup" open>
|
||||||
|
<summary>🎭 Personnage</summary>
|
||||||
|
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ obj.id }}">
|
||||||
|
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
|
||||||
|
<div class="iconGallery" id="personnageGallerySwap">
|
||||||
|
{% for slug_c, character in sprite_library.items() %}
|
||||||
|
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
|
||||||
|
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
|
||||||
|
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var GAME_SLUG = "{{ game.slug }}";
|
||||||
|
var SCREEN_ID = {{ screen.id }};
|
||||||
|
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
|
||||||
|
var SCENE_WIDTH = {{ screen.scene_width }};
|
||||||
|
var SCENE_HEIGHT = {{ screen.scene_height }};
|
||||||
|
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
|
||||||
|
var DEFINITIONS_DATA = {{ definitions_json|safe }};
|
||||||
|
var DEFINITIONS_NAMES = {{ definitions_names_json|safe }};
|
||||||
|
var FLOW_NODES_INITIAL = {{ flow_nodes_json|safe }};
|
||||||
|
var FLOW_EDGES_INITIAL = {{ flow_edges_json|safe }};
|
||||||
|
var FLOW_BLOCKS = {{ flow_blocks_json|safe }};
|
||||||
|
var FLOW_TARGETS_OBJECTS = true;
|
||||||
|
var ELEMENTS_LABELS = {{ elements_labels_json|safe }};
|
||||||
|
var SCREENS_MAP = {{ screens_map_json|safe }};
|
||||||
|
var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }};
|
||||||
|
var ELEMENT_PROPERTY_LABELS_MAP = {{ element_action_property_labels_json|safe }};
|
||||||
|
var ELEMENT_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
|
||||||
|
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
||||||
|
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
|
||||||
|
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||||
|
var TRIGGER_LABELS_MAP = {};
|
||||||
|
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
|
||||||
|
var ANIM_CLIPS = {{ animation_clips_json|safe }};
|
||||||
|
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
|
||||||
|
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||||
|
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/personnage-panel.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/flow-editor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/tabs-and-blocks.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/animation-timeline.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) —
|
||||||
|
appelée par routes/screens/screen_edit.py selon game_type, RÉUTILISE tel
|
||||||
|
quel le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/
|
||||||
|
animation-timeline.js) — voir le plan "Fondations d'une plateforme
|
||||||
|
multi-éditeurs"."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import db
|
||||||
|
import screens
|
||||||
|
from conftest import screen_edit_js_bundle
|
||||||
|
|
||||||
|
|
||||||
|
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
|
||||||
|
resp = client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
|
||||||
|
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||||
|
screen_id = screens.create_screen(slug, "Scène 1")
|
||||||
|
return slug, screen_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_game_still_uses_generic_editor(client, game):
|
||||||
|
"""Non-régression — critère de succès n°1 du plan : un jeu "document"
|
||||||
|
existant continue de fonctionner à l'identique."""
|
||||||
|
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||||
|
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||||
|
assert "ÉLÉMENTS DE CET ÉCRAN" in html.upper() or "Éléments de cet écran" in html
|
||||||
|
assert "scene-editor.js" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||||
|
assert "Objets de cette scène" in html
|
||||||
|
assert "scene-editor.js" in html
|
||||||
|
assert "🎭 Personnages" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||||
|
data={"kind": "personnage", "forge_character": "zombie"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
||||||
|
assert "/static/characters/zombie/idle.png" in html
|
||||||
|
assert f'data-object-id="{obj_id}"' in html
|
||||||
|
assert "Personnage Forge" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_object_geometry_route_persists_pixels(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id)
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/scene-objects/{obj_id}/geometry",
|
||||||
|
data=json.dumps({"x": 111, "y": 222, "width": 48, "height": 96}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
|
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (111, 222, 48, 96)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_object_delete_route(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id)
|
||||||
|
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert screens.get_scene_object(slug, obj_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_node_targets_scene_object(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
||||||
|
data=json.dumps({
|
||||||
|
"node_type": "action", "action_type": "jouer_animation_sprite",
|
||||||
|
"target_object_id": obj_id, "data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
node_id = resp.get_json()["id"]
|
||||||
|
node = screens.get_flow_node(slug, node_id)
|
||||||
|
assert node["target_object_id"] == obj_id
|
||||||
|
assert node["target_element_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_node_collision_condition_targets_scene_objects(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_a = screens.add_scene_object(slug, screen_id)
|
||||||
|
obj_b = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
||||||
|
data=json.dumps({
|
||||||
|
"node_type": "condition", "cond_source": "collision",
|
||||||
|
"cond_element_a": obj_a, "cond_element_b": obj_b,
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
node = screens.get_flow_node(slug, resp.get_json()["id"])
|
||||||
|
assert node["cond_element_a"] == obj_a
|
||||||
|
assert node["cond_element_b"] == obj_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_payload_renders_scene_objects_for_jeu2d_game(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
screens.add_scene_object(slug, screen_id, forge_character="robot")
|
||||||
|
payload = screens.full_game_payload(slug)
|
||||||
|
scene_payload = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||||||
|
assert len(scene_payload["elements"]) == 1
|
||||||
|
assert "/static/characters/robot/idle.png" in scene_payload["elements"][0]["rendered_html"]
|
||||||
|
assert any(payload["personnage_animations"].values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
||||||
|
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
|
||||||
|
assert "playScene" in html
|
||||||
|
assert "/static/characters/zombie/idle.png" in html
|
||||||
|
assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_edit_js_bundle_contains_scene_flow_helpers():
|
||||||
|
js = screen_edit_js_bundle()
|
||||||
|
assert "FLOW_TARGETS_OBJECTS" in js
|
||||||
|
assert "toggleFlowObjectPropertyValue" in js
|
||||||
Reference in New Issue
Block a user