Fusion des deux moteurs : type d'écran par écran, plus par projet
Build and deploy / test-python (push) Failing after 6m10s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

"document" (écrans %) et "jeu_2d" (scène pixels) devient une propriété
PAR ÉCRAN (_screens.kind, migration automatique idempotente dans
ensure_schema.py, source = l'ancien game_type au niveau projet) plutôt
qu'un choix figé pour tout le jeu — un même projet peut désormais
mélanger écrans classiques et scènes 2D librement.

- routes/screens/screen_edit.py : dispatch vers l'éditeur de scène selon
  screen["kind"] (l'écran demandé), plus game["game_type"].
- screens/payload/full_game_payload.py, templates/play.html,
  static/js/play/screens.js : le rendu jouable (payload, markup, bascule
  du mode plein-écran #playFrame) décide écran par écran, y compris en
  cours de partie (changer d'écran ne recharge pas la page).
- screens/screens_repo/create_screen.py : nouveau paramètre kind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 06:21:13 +02:00
co-authored by Claude Sonnet 5
parent f767ed4fab
commit d5a59bdbd8
12 changed files with 132 additions and 80 deletions
+8 -5
View File
@@ -8,12 +8,13 @@ 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
screen_edit.py (le dispatcher, selon screen["kind"]), 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."""
d'écran. Réutilise TEL QUEL tout ce qui est déjà générique côté logique
de flow/animation/variables/événements (voir screens/screens_repo/
ensure_schema.py — le type d'écran est une propriété PAR ÉCRAN) —
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)
@@ -68,6 +69,8 @@ def render_scene_edit(slug, screen_id, game):
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,
score_status_choices=db.SCORE_STATUS_CHOICES,
score_status_labels_json=json.dumps(db.SCORE_STATUS_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),
+10 -6
View File
@@ -12,15 +12,17 @@ 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)
if screen["kind"] == "jeu_2d":
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
# le type d'écran ("document" ou "jeu_2d") est une propriété de
# L'ÉCRAN, pas du projet — même route/endpoint pour les deux,
# dispatch ici selon l'écran demandé — tout le reste de cette
# fonction ne concerne QUE l'éditeur document (comportement
# inchangé pour un écran "document").
return render_scene_edit(slug, screen_id, game)
elements = screens.list_elements(slug, screen_id)
all_screens = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
@@ -106,6 +108,8 @@ def screen_edit(slug, screen_id):
element_animations_map_json=json.dumps(element_animations_map),
data_operations=screens.DATA_OPERATIONS,
data_operation_labels=screens.DATA_OPERATION_LABELS,
score_status_choices=db.SCORE_STATUS_CHOICES,
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
definitions=definitions, definitions_json=json.dumps(definitions_data),
selected_id=selected_id, selected_controls=selected_controls,
flow_nodes=flow_nodes, flow_edges=flow_edges,
+7 -7
View File
@@ -27,18 +27,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
conception), l'identifiant réel du visiteur pour une partie publique
(voir core/player_identity.py) — chaque objet/variable per_player
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 %) ;
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# écran "jeu_2d" construit "elements" depuis _scene_objects (objets de
# scène en pixels) plutôt que _screen_elements (DOM en %) — décidé
# ÉCRAN PAR ÉCRAN (s["kind"]), un même jeu peut mélanger les deux ;
# 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"
# est déjà générique et inchangé pour les deux types d'écran.
screens_ = list_screens(slug)
payload_screens = []
flows = {}
animations = {}
for s in screens_:
if is_scene_game:
if s["kind"] == "jeu_2d":
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
@@ -80,7 +80,7 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
"edges": list_flow_edges(slug, s["id"]),
}
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
if is_scene_game:
if s["kind"] == "jeu_2d":
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
+3 -3
View File
@@ -3,7 +3,7 @@ import db
from .ensure_schema import ensure_schema
def create_screen(slug, name, is_template=False):
def create_screen(slug, name, is_template=False, kind="document"):
ensure_schema(slug)
conn = db.connect(slug)
if is_template:
@@ -12,8 +12,8 @@ def create_screen(slug, name, is_template=False):
max_order = conn.execute("SELECT MAX(order_index) AS m FROM _screens WHERE is_template = 0").fetchone()["m"]
order_index = (max_order or 0) + 1
conn.execute(
"INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)",
(name, order_index, 1 if is_template else 0),
"INSERT INTO _screens (name, order_index, is_template, kind) VALUES (?, ?, ?, ?)",
(name, order_index, 1 if is_template else 0, kind),
)
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
+9
View File
@@ -123,6 +123,15 @@ def ensure_schema(slug):
# (jamais lu par son rendu) — voir screens/scenes/.
conn.execute("ALTER TABLE _screens ADD COLUMN scene_width INTEGER NOT NULL DEFAULT 960")
conn.execute("ALTER TABLE _screens ADD COLUMN scene_height INTEGER NOT NULL DEFAULT 540")
if "kind" not in screen_cols:
# Fusion des moteurs — "document" (écran %) ou "jeu_2d" (scène
# pixels) devient une propriété PAR ÉCRAN plutôt que par projet
# (voir db/games/get_game_type.py, désormais lu UNIQUEMENT ici,
# pour la rétro-compatibilité) : un même jeu peut mélanger les
# deux types d'écran librement (voir routes/screens/screen_edit.py,
# qui dispatche maintenant sur screen["kind"]).
conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'document'")
conn.execute("UPDATE _screens SET kind = ?", (db.get_game_type(slug),))
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
for col_name, col_def in [
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
+14
View File
@@ -37,6 +37,7 @@ function showScreen(id) {
el.style.display = 'block';
window.currentScreenId = id;
applyScreenAspect(el.dataset.aspect || '9/16');
applyScreenKind(el.dataset.kind || 'document');
}
// Phase 7 — nettoie les animations de sprites de l'écran quitté (sinon
// une boucle setInterval reste active sur un élément désormais caché,
@@ -83,6 +84,19 @@ function applyScreenAspect(aspect) {
}
}
// Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le
// type d'écran est désormais choisi PAR ÉCRAN — changer d'écran ne
// recharge pas la page, donc #playFrame doit pouvoir basculer entre sa
// taille fixe en pixels (scène "jeu_2d", voir .sceneMode dans play.html)
// et sa taille en ratio fluide (écran "document") À CHAQUE showScreen(),
// pas une seule fois au chargement.
function applyScreenKind(kind) {
var frame = document.getElementById('playFrame');
if (frame) {
frame.classList.toggle('sceneMode', kind === 'jeu_2d');
}
}
function screenIndexById(id) {
return screensData.findIndex(s => s.id === id);
}
+8
View File
@@ -34,6 +34,13 @@
</div>
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-screens" style="display:none;">
<input type="text" name="name" placeholder="Nom du nouvel écran (ex. Accueil, Scène 1...)" required autocomplete="off">
<!-- Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
chaque écran choisit son propre type à la création, librement,
plus le jeu entier. -->
<select name="kind">
<option value="document">Écran classique (%, quiz/formulaire/contenu)</option>
<option value="jeu_2d">Scène 2D (pixels, RPG/serious game)</option>
</select>
<button type="submit" class="primary">Créer</button>
</form>
{% if screen_list %}
@@ -45,6 +52,7 @@
<tr>
<td>{{ loop.index }}</td>
<td>
{{ '🧩' if s.kind == 'jeu_2d' else '📄' }}
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">{{ s.name }}</a>
{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
</td>
+19 -18
View File
@@ -27,18 +27,19 @@
}
#playFrame{
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)));
height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9)));
{% endif %}
}
/* Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le
type d'écran ("document"/"jeu_2d") est désormais choisi PAR ÉCRAN, pas
figé pour toute la page au chargement — changer d'écran ne recharge
pas la page (voir showScreen() dans static/js/play/screens.js), donc
la taille de #playFrame doit pouvoir basculer EN COURS DE PARTIE.
.sceneMode (posée/enlevée par showScreen() selon screen.kind) donne à
une scène jeu_2d sa taille FIXE en pixels (scene_width/scene_height) —
elle-même se centre dedans (voir .playScreen.playScene ci-dessous) —
au lieu du ratio fluide par défaut ci-dessus. */
#playFrame.sceneMode{ width:100vw; height:100vh; }
.playScreen{ position:absolute; inset:0; display:none; }
.playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; }
.playElement{
@@ -70,27 +71,25 @@
<body>
<div id="playRoot">
<div id="playFrame">
{% if game.game_type == 'jeu_2d' %}
<!-- Fondations multi-éditeurs — scène jeu_2d : objets déjà entièrement
{% for s in screens_data %}
{% if s.kind == 'jeu_2d' %}
<!-- Fusion des moteurs — écran "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;">
au %/DOM d'un écran "document") : juste le HTML tel quel. -->
<div class="playScreen playScene" data-screen-id="{{ s.id }}" data-kind="jeu_2d" 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 %}
<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-kind="document" data-aspect="{{ s.preview_aspect or '9/16' }}">
{% for el in s.elements if not el.parent_id %}
<div class="playElement" data-el-id="{{ el.id }}" data-anim-target="{{ el.id }}" style="{{ el|elstyle }}">
<div class="playElementInner" style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
{% endfor %}
{% if not screens_data %}
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
{% endif %}
@@ -118,6 +117,8 @@
runtimePayload: {{ runtime_payload_url|tojson }},
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }},
flowNodeRunScore: {{ flow_node_run_score_url|tojson }},
flowNodeRunStatus: {{ flow_node_run_status_url|tojson }},
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
};
</script>
+7 -4
View File
@@ -46,7 +46,10 @@ def test_document_editor_shows_animal_characters_to_admin(game, client):
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
resp = post_client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# jeu n'a plus de type propre — seul l'écran créé ensuite par chaque
# test (screens.create_screen(..., kind="jeu_2d")) est une scène.
resp = post_client.post("/games/new", data={"name": name}, follow_redirects=False)
assert resp.status_code == 302
return resp.headers["Location"].rstrip("/").split("/")[-1]
@@ -56,7 +59,7 @@ def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_c
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
@@ -66,7 +69,7 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = user_client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
@@ -76,7 +79,7 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
screen_id = screens.create_screen(slug, "Scène 1")
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
+37 -27
View File
@@ -1,38 +1,48 @@
"""Type de jeu (fondations multi-éditeurs) : "document" (éditeur générique
actuel) ou "jeu_2d" (futur éditeur de scène) — choisi à la création,
jamais modifié ensuite. Réutilise _meta comme is_public_played, aucune
migration pour les jeux déjà créés (retombent sur "document")."""
"""Type d'écran (fusion des moteurs, voir screens/screens_repo/
ensure_schema.py) : "document" (éditeur générique, éléments en %) ou
"jeu_2d" (éditeur de scène, objets en pixels) — choisi PAR ÉCRAN, à sa
création, plus par jeu. Un jeu peut librement mélanger les deux."""
import db
import screens
def test_default_game_type_is_document(client, game):
assert db.get_game_type(game) == "document"
assert db.game_meta(game)["game_type"] == "document"
def test_default_screen_kind_is_document(client, game):
screen_id = screens.create_screen(game, "Accueil")
assert screens.get_screen(game, screen_id)["kind"] == "document"
def test_create_game_with_jeu_2d_type(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test", game_type="jeu_2d"))
assert db.get_game_type(slug) == "jeu_2d"
assert db.game_meta(slug)["game_type"] == "jeu_2d"
def test_create_screen_with_jeu_2d_kind(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
screen = screens.get_screen(slug, screen_id)
assert screen["kind"] == "jeu_2d"
def test_games_new_route_creates_jeu_2d_game(client):
resp = client.post("/games/new", data={"name": "pytest_route_jeu2d", "game_type": "jeu_2d"}, follow_redirects=False)
def test_a_game_can_mix_both_screen_kinds(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_mixed_test"))
doc_id = screens.create_screen(slug, "Accueil", kind="document")
scene_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
assert screens.get_screen(slug, doc_id)["kind"] == "document"
assert screens.get_screen(slug, scene_id)["kind"] == "jeu_2d"
def test_screens_new_route_creates_jeu_2d_screen(client, game):
resp = client.post(
f"/game/{game}/screens/new",
data={"name": "pytest_route_jeu2d", "kind": "jeu_2d"},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
try:
assert db.get_game_type(slug) == "jeu_2d"
finally:
import shutil
shutil.rmtree(db.game_dir(slug), ignore_errors=True)
screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2])
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
def test_games_new_route_rejects_unknown_game_type(client):
resp = client.post("/games/new", data={"name": "pytest_route_bad_type", "game_type": "n_importe_quoi"}, follow_redirects=False)
def test_screens_new_route_rejects_unknown_kind(client, game):
resp = client.post(
f"/game/{game}/screens/new",
data={"name": "pytest_route_bad_kind", "kind": "n_importe_quoi"},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
try:
assert db.get_game_type(slug) == "document"
finally:
import shutil
shutil.rmtree(db.game_dir(slug), ignore_errors=True)
screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2])
assert screens.get_screen(game, screen_id)["kind"] == "document"
+6 -6
View File
@@ -1,8 +1,8 @@
"""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"."""
appelée par routes/screens/screen_edit.py selon screen["kind"] (fusion des
moteurs, voir screens/screens_repo/ensure_schema.py), RÉUTILISE tel quel
le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/
animation-timeline.js)."""
import json
import re
@@ -12,9 +12,9 @@ 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)
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = screens.create_screen(slug, "Scène 1")
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
+4 -4
View File
@@ -1,6 +1,6 @@
"""Objets de scène (_scene_objects) — fondations de l'éditeur de scène 2D
(jeux "jeu_2d", voir db/games/get_game_type.py). Positions en PIXELS
fixes (pas des %, voir ensure_scene_schema.py). kind="personnage"
(écrans "jeu_2d", voir screens/screens_repo/ensure_schema.py). Positions
en PIXELS fixes (pas des %, voir ensure_scene_schema.py). kind="personnage"
réutilise TELLE QUELLE la structure _personnage_data et les fonctions
resolve_personnage_* déjà écrites pour le widget "personnage" de
l'éditeur document (Phase 8) — même bibliothèque Forge, même moteur
@@ -10,8 +10,8 @@ import screens
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_scene_test"):
slug = tmp_game_slug_cleanup(db.create_game(name, game_type="jeu_2d"))
screen_id = screens.create_screen(slug, "Scène 1")
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id