Phase A (2/2) : éditeur de scène 2D — route, template, runtime jouable
Build and deploy / test-python (push) Successful in 1m56s
Build and deploy / test-js (push) Successful in 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-08-31 10:05:40 +02:00
co-authored by Claude Sonnet 5
parent efa7d1d9b0
commit f0070faced
17 changed files with 1300 additions and 28 deletions
+1 -1
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from . import (
scene_object_add,
scene_object_geometry,
scene_object_delete,
scene_object_personnage_data,
)
+79
View File
@@ -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)}),
)
+19
View File
@@ -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))
+15
View File
@@ -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))
+19
View File
@@ -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)})
+7
View File
@@ -6,11 +6,18 @@ import db
import screens
from core.flask_app import app
from ..scenes.scene_edit_view import render_scene_edit
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
def screen_edit(slug, screen_id):
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)
if not screen:
abort(404)